Compare commits
9 Commits
197157cecb
...
edb216347d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb216347d | ||
|
|
508c4f129f | ||
|
|
1c0140292f | ||
|
|
53815c4814 | ||
|
|
df714a43a2 | ||
|
|
a8bb2c8164 | ||
|
|
5d0628878b | ||
|
|
dacc18fe5d | ||
|
|
9ab6377d16 |
200
src/app.rs
200
src/app.rs
@@ -2,7 +2,7 @@ use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::MouseEvent;
|
||||
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
|
||||
use crate::components::{Sidebar, ViewMode, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
|
||||
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
@@ -28,6 +28,19 @@ pub fn App() -> Html {
|
||||
let create_event_modal_open = use_state(|| false);
|
||||
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
||||
|
||||
// Calendar view state - load from localStorage if available
|
||||
let current_view = use_state(|| {
|
||||
// Try to load saved view mode from localStorage
|
||||
if let Ok(saved_view) = LocalStorage::get::<String>("calendar_view_mode") {
|
||||
match saved_view.as_str() {
|
||||
"week" => ViewMode::Week,
|
||||
_ => ViewMode::Month,
|
||||
}
|
||||
} else {
|
||||
ViewMode::Month // Default to month view
|
||||
}
|
||||
});
|
||||
|
||||
let available_colors = [
|
||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
|
||||
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||
@@ -52,6 +65,21 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_view_change = {
|
||||
let current_view = current_view.clone();
|
||||
Callback::from(move |new_view: ViewMode| {
|
||||
// Save view mode to localStorage
|
||||
let view_string = match new_view {
|
||||
ViewMode::Month => "month",
|
||||
ViewMode::Week => "week",
|
||||
};
|
||||
let _ = LocalStorage::set("calendar_view_mode", view_string);
|
||||
|
||||
// Update state
|
||||
current_view.set(new_view);
|
||||
})
|
||||
};
|
||||
|
||||
// Fetch user info when token is available
|
||||
{
|
||||
let user_info = user_info.clone();
|
||||
@@ -215,11 +243,18 @@ pub fn App() -> Html {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Format date and time strings
|
||||
let start_date = event_data.start_date.format("%Y-%m-%d").to_string();
|
||||
let start_time = event_data.start_time.format("%H:%M").to_string();
|
||||
let end_date = event_data.end_date.format("%Y-%m-%d").to_string();
|
||||
let end_time = event_data.end_time.format("%H:%M").to_string();
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_local = event_data.start_date.and_time(event_data.start_time);
|
||||
let end_local = event_data.end_date.and_time(event_data.end_time);
|
||||
|
||||
let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert enums to strings for backend
|
||||
let status_str = match event_data.status {
|
||||
@@ -354,6 +389,8 @@ pub fn App() -> Html {
|
||||
on_color_picker_toggle={on_color_picker_toggle}
|
||||
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
current_view={(*current_view).clone()}
|
||||
on_view_change={on_view_change}
|
||||
/>
|
||||
<main class="app-main">
|
||||
<RouteHandler
|
||||
@@ -362,6 +399,8 @@ pub fn App() -> Html {
|
||||
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())}
|
||||
view={(*current_view).clone()}
|
||||
on_create_event_request={Some(on_event_create.clone())}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
@@ -375,6 +414,7 @@ pub fn App() -> Html {
|
||||
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())}
|
||||
on_create_event_request={Some(on_event_create.clone())}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -620,11 +660,18 @@ pub fn App() -> Html {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Format date and time strings
|
||||
let start_date = updated_data.start_date.format("%Y-%m-%d").to_string();
|
||||
let start_time = updated_data.start_time.format("%H:%M").to_string();
|
||||
let end_date = updated_data.end_date.format("%Y-%m-%d").to_string();
|
||||
let end_time = updated_data.end_time.format("%H:%M").to_string();
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_local = updated_data.start_date.and_time(updated_data.start_time);
|
||||
let end_local = updated_data.end_date.and_time(updated_data.end_time);
|
||||
|
||||
let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert enums to strings for backend
|
||||
let status_str = match updated_data.status {
|
||||
@@ -658,37 +705,108 @@ pub fn App() -> Html {
|
||||
_ => "none",
|
||||
}.to_string();
|
||||
|
||||
match calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
// Check if the calendar has changed
|
||||
let calendar_changed = original_event.calendar_path.as_ref() != updated_data.selected_calendar.as_ref();
|
||||
|
||||
if calendar_changed {
|
||||
// Calendar changed - need to delete from original and create in new
|
||||
web_sys::console::log_1(&"Calendar changed - performing delete + create".into());
|
||||
|
||||
// First delete from original calendar
|
||||
if let Some(original_calendar_path) = &original_event.calendar_path {
|
||||
if let Some(event_href) = &original_event.href {
|
||||
match calendar_service.delete_event(
|
||||
&token,
|
||||
&password,
|
||||
original_calendar_path.clone(),
|
||||
event_href.clone(),
|
||||
"single".to_string(), // delete single occurrence
|
||||
None
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Original event deleted successfully".into());
|
||||
|
||||
// Now create the event in the new calendar
|
||||
match calendar_service.create_event(
|
||||
&token,
|
||||
&password,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event moved to new calendar successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to create event in new calendar: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to move event to new calendar: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to delete original event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to delete original event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
web_sys::console::error_1(&"Original event missing href for deletion".into());
|
||||
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing href").unwrap();
|
||||
}
|
||||
} else {
|
||||
web_sys::console::error_1(&"Original event missing calendar_path for deletion".into());
|
||||
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
} else {
|
||||
// Calendar hasn't changed - normal update
|
||||
match calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
use crate::components::EventModal;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
@@ -19,235 +19,200 @@ pub struct CalendarProps {
|
||||
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)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let today = Local::now().date_naive();
|
||||
let current_month = use_state(|| {
|
||||
// Try to load saved month from localStorage
|
||||
if let Ok(saved_month_str) = LocalStorage::get::<String>("calendar_current_month") {
|
||||
if let Ok(saved_month) = NaiveDate::parse_from_str(&saved_month_str, "%Y-%m-%d") {
|
||||
// Return the first day of the saved month
|
||||
saved_month.with_day(1).unwrap_or(today)
|
||||
// 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 {
|
||||
today
|
||||
}
|
||||
});
|
||||
let selected_day = use_state(|| today);
|
||||
let selected_event = use_state(|| None::<CalendarEvent>);
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &CalendarEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
// Find the calendar that matches this event's path
|
||||
if let Some(calendar) = user_info.calendars.iter()
|
||||
.find(|cal| &cal.path == calendar_path) {
|
||||
return calendar.color.clone();
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// Default color if no match found
|
||||
"#3B82F6".to_string()
|
||||
};
|
||||
});
|
||||
|
||||
let first_day_of_month = current_month.with_day(1).unwrap();
|
||||
let days_in_month = get_days_in_month(*current_month);
|
||||
let first_weekday = first_day_of_month.weekday();
|
||||
let days_from_prev_month = get_days_from_previous_month(*current_month, first_weekday);
|
||||
// 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::<CalendarEvent>);
|
||||
|
||||
let prev_month = {
|
||||
let current_month = current_month.clone();
|
||||
Callback::from(move |_| {
|
||||
let prev = *current_month - Duration::days(1);
|
||||
let first_of_prev = prev.with_day(1).unwrap();
|
||||
current_month.set(first_of_prev);
|
||||
// Save to localStorage
|
||||
let _ = LocalStorage::set("calendar_current_month", first_of_prev.format("%Y-%m-%d").to_string());
|
||||
// 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)>);
|
||||
|
||||
// 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 next_month = {
|
||||
let current_month = current_month.clone();
|
||||
Callback::from(move |_| {
|
||||
let next = if current_month.month() == 12 {
|
||||
NaiveDate::from_ymd_opt(current_month.year() + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() + 1, 1).unwrap()
|
||||
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)
|
||||
},
|
||||
};
|
||||
current_month.set(next);
|
||||
// Save to localStorage
|
||||
let _ = LocalStorage::set("calendar_current_month", next.format("%Y-%m-%d").to_string());
|
||||
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 go_to_today = {
|
||||
let current_month = current_month.clone();
|
||||
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 first_of_today_month = today.with_day(1).unwrap();
|
||||
current_month.set(first_of_today_month);
|
||||
// Save to localStorage
|
||||
let _ = LocalStorage::set("calendar_current_month", first_of_today_month.format("%Y-%m-%d").to_string());
|
||||
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 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);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="calendar">
|
||||
<div class="calendar-header">
|
||||
<button class="nav-button" onclick={prev_month}>{"‹"}</button>
|
||||
<h2 class="month-year">{format!("{} {}", get_month_name(current_month.month()), current_month.year())}</h2>
|
||||
<div class="header-right">
|
||||
<button class="today-button" onclick={go_to_today}>{"Today"}</button>
|
||||
<button class="nav-button" onclick={next_month}>{"›"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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 current month
|
||||
{
|
||||
(1..=days_in_month).map(|day| {
|
||||
let date = current_month.with_day(day).unwrap();
|
||||
let is_today = date == today;
|
||||
let is_selected = date == *selected_day;
|
||||
let events = props.events.get(&date).cloned().unwrap_or_default();
|
||||
|
||||
let mut classes = vec!["calendar-day", "current-month"];
|
||||
if is_today {
|
||||
classes.push("today");
|
||||
}
|
||||
if is_selected {
|
||||
classes.push("selected");
|
||||
}
|
||||
if !events.is_empty() {
|
||||
classes.push("has-events");
|
||||
}
|
||||
|
||||
let selected_day_clone = selected_day.clone();
|
||||
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));
|
||||
}
|
||||
{
|
||||
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! {
|
||||
<div class={classes!(classes)} onclick={on_click} oncontextmenu={on_context_menu}>
|
||||
<div class="day-number">{day}</div>
|
||||
{
|
||||
if !events.is_empty() {
|
||||
html! {
|
||||
<div class="event-indicators">
|
||||
{
|
||||
events.iter().take(2).map(|event| {
|
||||
let event_clone = event.clone();
|
||||
let selected_event_clone = selected_event.clone();
|
||||
let on_event_click = props.on_event_click.clone();
|
||||
let event_click = Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation(); // Prevent day selection
|
||||
on_event_click.emit(event_clone.clone());
|
||||
selected_event_clone.set(Some(event_clone.clone()));
|
||||
});
|
||||
|
||||
let event_context_menu = {
|
||||
let event_clone = event.clone();
|
||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
if let Some(callback) = &on_event_context_menu {
|
||||
callback.emit((e, event_clone.clone()));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let title = event.get_title();
|
||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
|
||||
let event_color = get_event_color(&event);
|
||||
html! {
|
||||
<div class={class_name}
|
||||
title={title.clone()}
|
||||
onclick={event_click}
|
||||
oncontextmenu={event_context_menu}
|
||||
style={format!("background-color: {}", event_color)}>
|
||||
{
|
||||
if is_refreshing {
|
||||
"🔄 Refreshing...".to_string()
|
||||
} else if title.len() > 15 {
|
||||
format!("{}...", &title[..12])
|
||||
} else {
|
||||
title
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
{
|
||||
if events.len() > 2 {
|
||||
html! { <div class="more-events">{format!("+{} more", events.len() - 2)}</div> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<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)}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
},
|
||||
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)}
|
||||
/>
|
||||
},
|
||||
}
|
||||
|
||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||
</div>
|
||||
}
|
||||
|
||||
// Event details modal
|
||||
<EventModal
|
||||
@@ -259,75 +224,47 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
// 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): (CalendarEvent, EventCreationData)| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
// TODO: Handle actual event update
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</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 {
|
||||
// Calculate the previous month
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
47
src/components/calendar_header.rs
Normal file
47
src/components/calendar_header.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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">
|
||||
<button class="nav-button" onclick={props.on_prev.clone()}>{"‹"}</button>
|
||||
<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"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ pub struct CreateEventModalProps {
|
||||
pub on_create: Callback<EventCreationData>,
|
||||
pub on_update: Callback<(CalendarEvent, 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)]
|
||||
@@ -159,13 +163,16 @@ impl Default for EventCreationData {
|
||||
impl EventCreationData {
|
||||
pub fn from_calendar_event(event: &CalendarEvent) -> Self {
|
||||
// Convert CalendarEvent 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.start.date_naive(),
|
||||
start_time: event.start.time(),
|
||||
end_date: event.end.as_ref().map(|e| e.date_naive()).unwrap_or(event.start.date_naive()),
|
||||
end_time: event.end.as_ref().map(|e| e.time()).unwrap_or(event.start.time()),
|
||||
start_date: event.start.with_timezone(&chrono::Local).date_naive(),
|
||||
start_time: event.start.with_timezone(&chrono::Local).time(),
|
||||
end_date: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.start.with_timezone(&chrono::Local).date_naive()),
|
||||
end_time: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.start.with_timezone(&chrono::Local).time()),
|
||||
location: event.location.clone().unwrap_or_default(),
|
||||
all_day: event.all_day,
|
||||
status: EventStatus::from_service_status(&event.status),
|
||||
@@ -187,9 +194,9 @@ 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()), {
|
||||
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)| {
|
||||
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
|
||||
@@ -199,6 +206,15 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||||
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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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;
|
||||
@@ -12,12 +15,15 @@ pub mod route_handler;
|
||||
|
||||
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;
|
||||
pub use sidebar::{Sidebar, ViewMode};
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use route_handler::RouteHandler;
|
||||
203
src/components/month_view.rs
Normal file
203
src/components/month_view.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MonthViewProps {
|
||||
pub current_month: NaiveDate,
|
||||
pub today: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
||||
pub on_event_click: Callback<CalendarEvent>,
|
||||
#[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, CalendarEvent)>>,
|
||||
#[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 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);
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &CalendarEvent| -> 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();
|
||||
|
||||
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">
|
||||
{
|
||||
day_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 |_: 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>()
|
||||
}
|
||||
</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()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use crate::components::Login;
|
||||
use crate::components::{Login, ViewMode};
|
||||
use crate::services::calendar_service::{UserInfo, CalendarEvent};
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
@@ -22,6 +22,10 @@ pub struct RouteHandlerProps {
|
||||
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)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
}
|
||||
|
||||
#[function_component(RouteHandler)]
|
||||
@@ -31,6 +35,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
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();
|
||||
|
||||
html! {
|
||||
<Switch<Route> render={move |route| {
|
||||
@@ -39,6 +45,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
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();
|
||||
|
||||
match route {
|
||||
Route::Home => {
|
||||
@@ -62,6 +70,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
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}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
@@ -80,6 +90,10 @@ pub struct CalendarViewProps {
|
||||
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)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
}
|
||||
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
@@ -238,6 +252,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
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()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -250,6 +266,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
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()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::components::CalendarListItem;
|
||||
|
||||
@@ -13,6 +14,18 @@ pub enum Route {
|
||||
Calendar,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Month,
|
||||
Week,
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
@@ -23,10 +36,27 @@ pub struct SidebarProps {
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -82,6 +112,14 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
<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>
|
||||
|
||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
453
src/components/week_view.rs
Normal file
453
src/components/week_view.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct WeekViewProps {
|
||||
pub current_date: NaiveDate,
|
||||
pub today: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
||||
pub on_event_click: Callback<CalendarEvent>,
|
||||
#[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, CalendarEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct DragState {
|
||||
is_dragging: bool,
|
||||
start_date: NaiveDate,
|
||||
start_y: f64,
|
||||
current_y: f64,
|
||||
}
|
||||
|
||||
#[function_component(WeekView)]
|
||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let start_of_week = get_start_of_week(props.current_date);
|
||||
let week_days: Vec<NaiveDate> = (0..7)
|
||||
.map(|i| start_of_week + Duration::days(i))
|
||||
.collect();
|
||||
|
||||
// Drag state for event creation
|
||||
let drag_state = use_state(|| None::<DragState>);
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &CalendarEvent| -> 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()
|
||||
};
|
||||
|
||||
// Generate time labels - 24 hours plus the final midnight boundary
|
||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
||||
if hour == 0 {
|
||||
"12 AM".to_string()
|
||||
} else if hour < 12 {
|
||||
format!("{} AM", hour)
|
||||
} else if hour == 12 {
|
||||
"12 PM".to_string()
|
||||
} else {
|
||||
format!("{} PM", hour - 12)
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Add the final midnight boundary to show where the day ends
|
||||
time_labels.push("12 AM".to_string());
|
||||
|
||||
html! {
|
||||
<div class="week-view-container">
|
||||
// Header with weekday names and dates
|
||||
<div class="week-header">
|
||||
<div class="time-gutter"></div>
|
||||
{
|
||||
week_days.iter().map(|date| {
|
||||
let is_today = *date == props.today;
|
||||
let weekday_name = get_weekday_name(date.weekday());
|
||||
|
||||
html! {
|
||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||
<div class="weekday-name">{weekday_name}</div>
|
||||
<div class="day-number">{date.day()}</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
|
||||
// Scrollable content area with time grid
|
||||
<div class="week-content">
|
||||
<div class="time-grid">
|
||||
// Time labels
|
||||
<div class="time-labels">
|
||||
{
|
||||
time_labels.iter().enumerate().map(|(index, time)| {
|
||||
let is_final = index == time_labels.len() - 1;
|
||||
html! {
|
||||
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
|
||||
{time}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
|
||||
// Day columns
|
||||
<div class="week-days-grid">
|
||||
{
|
||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||
let is_today = *date == props.today;
|
||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||
|
||||
// Drag event handlers
|
||||
let drag_state_clone = drag_state.clone();
|
||||
let date_for_drag = *date;
|
||||
|
||||
let onmousedown = {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only handle left-click (button 0)
|
||||
if e.button() != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate Y position relative to day column container
|
||||
// Use layer_y which gives coordinates relative to positioned ancestor
|
||||
let relative_y = e.layer_y() as f64;
|
||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||
|
||||
// Snap to 15-minute increments
|
||||
let snapped_y = snap_to_15_minutes(relative_y);
|
||||
|
||||
drag_state.set(Some(DragState {
|
||||
is_dragging: true,
|
||||
start_date: date_for_drag,
|
||||
start_y: snapped_y,
|
||||
current_y: snapped_y,
|
||||
}));
|
||||
e.prevent_default();
|
||||
})
|
||||
};
|
||||
|
||||
let onmousemove = {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
||||
if current_drag.is_dragging {
|
||||
// Use layer_y for consistent coordinate calculation
|
||||
let relative_y = e.layer_y() as f64;
|
||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||
|
||||
// Snap to 15-minute increments
|
||||
let snapped_y = snap_to_15_minutes(relative_y);
|
||||
|
||||
current_drag.current_y = snapped_y;
|
||||
drag_state.set(Some(current_drag));
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let onmouseup = {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
Callback::from(move |_e: MouseEvent| {
|
||||
if let Some(current_drag) = (*drag_state).clone() {
|
||||
if current_drag.is_dragging {
|
||||
// Calculate start and end times
|
||||
let start_time = pixels_to_time(current_drag.start_y);
|
||||
let end_time = pixels_to_time(current_drag.current_y);
|
||||
|
||||
// Ensure start is before end
|
||||
let (actual_start, actual_end) = if start_time <= end_time {
|
||||
(start_time, end_time)
|
||||
} else {
|
||||
(end_time, start_time)
|
||||
};
|
||||
|
||||
// Ensure minimum duration (15 minutes)
|
||||
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
||||
actual_start + chrono::Duration::minutes(15)
|
||||
} else {
|
||||
actual_end
|
||||
};
|
||||
|
||||
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
||||
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
||||
|
||||
if let Some(callback) = &on_create_event {
|
||||
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
||||
}
|
||||
|
||||
drag_state.set(None);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
||||
{onmousedown}
|
||||
{onmousemove}
|
||||
{onmouseup}
|
||||
>
|
||||
// Time slot backgrounds - 24 full hour slots + 1 boundary slot
|
||||
{
|
||||
(0..24).map(|_hour| {
|
||||
html! {
|
||||
<div class="time-slot">
|
||||
<div class="time-slot-half"></div>
|
||||
<div class="time-slot-half"></div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
// Final boundary slot to match the final time label
|
||||
<div class="time-slot boundary-slot"></div>
|
||||
|
||||
// Events positioned absolutely based on their actual times
|
||||
<div class="events-container">
|
||||
{
|
||||
day_events.iter().filter_map(|event| {
|
||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||
|
||||
// Skip events that don't belong on this date or have invalid positioning
|
||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
||||
return None;
|
||||
}
|
||||
|
||||
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 |e: MouseEvent| {
|
||||
e.stop_propagation(); // Prevent calendar click events from also triggering
|
||||
on_event_click.emit(event.clone());
|
||||
})
|
||||
};
|
||||
|
||||
let onmousedown_event = {
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||
})
|
||||
};
|
||||
|
||||
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();
|
||||
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
||||
callback.emit((e, event.clone()));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Format time display for the event
|
||||
let time_display = if event.all_day {
|
||||
"All Day".to_string()
|
||||
} else {
|
||||
let local_start = event.start.with_timezone(&Local);
|
||||
if let Some(end) = event.end {
|
||||
let local_end = end.with_timezone(&Local);
|
||||
|
||||
// Check if both times are in same AM/PM period to avoid redundancy
|
||||
let start_is_am = local_start.hour() < 12;
|
||||
let end_is_am = local_end.hour() < 12;
|
||||
|
||||
if start_is_am == end_is_am {
|
||||
// Same AM/PM period - show "9:00 - 10:30 AM"
|
||||
format!("{} - {}",
|
||||
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
||||
local_end.format("%I:%M %p")
|
||||
)
|
||||
} else {
|
||||
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
||||
format!("{} - {}",
|
||||
local_start.format("%I:%M %p"),
|
||||
local_end.format("%I:%M %p")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No end time, just show start time
|
||||
format!("{}", local_start.format("%I:%M %p"))
|
||||
}
|
||||
};
|
||||
|
||||
Some(html! {
|
||||
<div
|
||||
class={classes!(
|
||||
"week-event",
|
||||
if is_refreshing { Some("refreshing") } else { None },
|
||||
if is_all_day { Some("all-day") } else { None }
|
||||
)}
|
||||
style={format!(
|
||||
"background-color: {}; top: {}px; height: {}px;",
|
||||
event_color,
|
||||
start_pixels,
|
||||
duration_pixels
|
||||
)}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
onmousedown={onmousedown_event}
|
||||
>
|
||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||
{if !is_all_day {
|
||||
html! { <div class="event-time">{time_display}</div> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
})
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
|
||||
// Temporary event box during drag
|
||||
{
|
||||
if let Some(drag) = (*drag_state).clone() {
|
||||
if drag.is_dragging && drag.start_date == *date {
|
||||
let start_y = drag.start_y.min(drag.current_y);
|
||||
let end_y = drag.start_y.max(drag.current_y);
|
||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
||||
|
||||
// Convert pixels to times for display
|
||||
let start_time = pixels_to_time(start_y);
|
||||
let end_time = pixels_to_time(end_y);
|
||||
|
||||
html! {
|
||||
<div
|
||||
class="temp-event-box"
|
||||
style={format!("top: {}px; height: {}px;", start_y, height)}
|
||||
>
|
||||
{format!("{} - {}", start_time.format("%I:%M %p"), end_time.format("%I:%M %p"))}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
|
||||
let weekday = date.weekday();
|
||||
let days_from_sunday = match weekday {
|
||||
Weekday::Sun => 0,
|
||||
Weekday::Mon => 1,
|
||||
Weekday::Tue => 2,
|
||||
Weekday::Wed => 3,
|
||||
Weekday::Thu => 4,
|
||||
Weekday::Fri => 5,
|
||||
Weekday::Sat => 6,
|
||||
};
|
||||
date - Duration::days(days_from_sunday)
|
||||
}
|
||||
|
||||
fn get_weekday_name(weekday: Weekday) -> &'static str {
|
||||
match weekday {
|
||||
Weekday::Sun => "Sun",
|
||||
Weekday::Mon => "Mon",
|
||||
Weekday::Tue => "Tue",
|
||||
Weekday::Wed => "Wed",
|
||||
Weekday::Thu => "Thu",
|
||||
Weekday::Fri => "Fri",
|
||||
Weekday::Sat => "Sat",
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the pixel position of an event based on its time
|
||||
// Each hour is 60px, so we convert time to pixels
|
||||
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
|
||||
fn snap_to_15_minutes(pixels: f64) -> f64 {
|
||||
let increment = 15.0; // 15px = 15 minutes
|
||||
(pixels / increment).round() * increment
|
||||
}
|
||||
|
||||
// Convert pixel position to time (inverse of time to pixels)
|
||||
fn pixels_to_time(pixels: f64) -> NaiveTime {
|
||||
// Since 60px = 1 hour, pixels directly represent minutes
|
||||
let total_minutes = pixels; // 1px = 1 minute
|
||||
let hours = (total_minutes / 60.0) as u32;
|
||||
let minutes = (total_minutes % 60.0) as u32;
|
||||
|
||||
// Clamp to valid time range
|
||||
let hours = hours.min(23);
|
||||
let minutes = minutes.min(59);
|
||||
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||
}
|
||||
|
||||
|
||||
fn calculate_event_position(event: &CalendarEvent, date: NaiveDate) -> (f32, f32, bool) {
|
||||
// Convert UTC times to local time for display
|
||||
let local_start = event.start.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
|
||||
// Only position events that are on this specific date
|
||||
if event_date != date {
|
||||
return (0.0, 0.0, false); // Event not on this date
|
||||
}
|
||||
|
||||
// Handle all-day events - they appear at the top
|
||||
if event.all_day {
|
||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||
}
|
||||
|
||||
// Calculate start position in pixels from midnight
|
||||
let start_hour = local_start.hour() as f32;
|
||||
let start_minute = local_start.minute() as f32;
|
||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||
|
||||
// Calculate duration and height
|
||||
let duration_pixels = if let Some(end) = event.end {
|
||||
let local_end = end.with_timezone(&Local);
|
||||
let end_date = local_end.date_naive();
|
||||
|
||||
// Handle events that span multiple days by capping at midnight
|
||||
if end_date > date {
|
||||
// Event continues past midnight, cap at 24:00 (1440px)
|
||||
1440.0 - start_pixels
|
||||
} else {
|
||||
let end_hour = local_end.hour() as f32;
|
||||
let end_minute = local_end.minute() as f32;
|
||||
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
|
||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
||||
}
|
||||
} else {
|
||||
60.0 // Default 1 hour if no end time
|
||||
};
|
||||
|
||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||
}
|
||||
286
styles.css
286
styles.css
@@ -462,6 +462,246 @@ body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Week View Container */
|
||||
.week-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Week Header */
|
||||
.week-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px repeat(7, 1fr);
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.time-gutter {
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.week-day-header {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-right: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.week-day-header.today {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.weekday-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.week-day-header .day-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.week-day-header.today .weekday-name {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
/* Week Content */
|
||||
.week-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Time Labels */
|
||||
.time-labels {
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e9ecef;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-label.final-boundary {
|
||||
height: 60px; /* Keep same height but this marks the end boundary */
|
||||
border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */
|
||||
color: #999; /* Lighter color to indicate it's the boundary */
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Week Days Grid */
|
||||
.week-days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.week-day-column {
|
||||
position: relative;
|
||||
border-right: 1px solid #e9ecef;
|
||||
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
|
||||
}
|
||||
|
||||
.week-day-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.week-day-column.today {
|
||||
background: #fafffe;
|
||||
}
|
||||
|
||||
/* Time Slots */
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: relative;
|
||||
pointer-events: none; /* Don't capture mouse events */
|
||||
}
|
||||
|
||||
.time-slot-half {
|
||||
height: 30px;
|
||||
border-bottom: 1px dotted #f5f5f5;
|
||||
pointer-events: none; /* Don't capture mouse events */
|
||||
}
|
||||
|
||||
.time-slot-half:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.time-slot.boundary-slot {
|
||||
height: 60px; /* Match the final time label height */
|
||||
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
|
||||
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
|
||||
pointer-events: none; /* Don't capture mouse events */
|
||||
}
|
||||
|
||||
/* Events Container */
|
||||
.events-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none; /* Container doesn't capture, but children (events) do */
|
||||
}
|
||||
|
||||
/* Week Events */
|
||||
.week-event {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
min-height: 20px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.week-event:hover {
|
||||
filter: brightness(1.1);
|
||||
z-index: 4;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.week-event.refreshing {
|
||||
animation: pulse 1.5s ease-in-out infinite alternate;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
/* Temporary event box during drag creation */
|
||||
.temp-event-box {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
border: 2px dashed rgba(59, 130, 246, 0.8);
|
||||
border-radius: 4px;
|
||||
color: rgba(59, 130, 246, 0.9);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 6; /* Higher than events */
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.week-event .event-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.week-event .event-time {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.week-event.all-day {
|
||||
opacity: 0.9;
|
||||
border-left: 4px solid rgba(255,255,255,0.5);
|
||||
font-style: italic;
|
||||
background: linear-gradient(135deg, var(--event-color, #3B82F6), rgba(255,255,255,0.1)) !important;
|
||||
}
|
||||
|
||||
/* Legacy Week Grid (for backward compatibility) */
|
||||
.week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
flex: 1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.week-view .calendar-day {
|
||||
height: 100%; /* Make week view days stretch to full height of their grid cell */
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
@@ -773,6 +1013,15 @@ body {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.view-selector-dropdown {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-left: 0;
|
||||
max-width: 100%;
|
||||
@@ -889,6 +1138,43 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* View Selector */
|
||||
.view-selector {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.view-selector-dropdown {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.view-selector-dropdown:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.view-selector-dropdown:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.view-selector-dropdown option {
|
||||
background: #2a2a2a;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Create Calendar Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user