Users can now toggle visibility of CalDAV calendars using checkboxes in the sidebar, matching the behavior of external calendars. Events from hidden calendars are automatically filtered out of the calendar view. Changes: - Add is_visible field to CalendarInfo (frontend & backend) - Add visibility checkboxes to CalDAV calendar list items - Implement real-time event filtering based on calendar visibility - Add CSS styling matching external calendar checkboxes - Default new calendars to visible state 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
569 lines
23 KiB
Rust
569 lines
23 KiB
Rust
use crate::components::{
|
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
|
};
|
|
use crate::models::ical::VEvent;
|
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
|
use gloo_storage::{LocalStorage, Storage};
|
|
use std::collections::HashMap;
|
|
use web_sys::MouseEvent;
|
|
use yew::prelude::*;
|
|
|
|
#[derive(Properties, PartialEq)]
|
|
pub struct CalendarProps {
|
|
#[prop_or_default]
|
|
pub user_info: Option<UserInfo>,
|
|
#[prop_or_default]
|
|
pub external_calendar_events: Vec<VEvent>,
|
|
#[prop_or_default]
|
|
pub external_calendars: Vec<ExternalCalendar>,
|
|
#[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>>,
|
|
Option<String>,
|
|
Option<String>,
|
|
)>,
|
|
>,
|
|
#[prop_or_default]
|
|
pub context_menus_open: bool,
|
|
}
|
|
|
|
#[function_component]
|
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
|
let today = Local::now().date_naive();
|
|
|
|
// Event management state
|
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
|
let loading = use_state(|| true);
|
|
let error = use_state(|| None::<String>);
|
|
let refreshing_event_uid = use_state(|| None::<String>);
|
|
// 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
|
|
}
|
|
});
|
|
|
|
// Fetch events when current_date changes
|
|
{
|
|
let events = events.clone();
|
|
let loading = loading.clone();
|
|
let error = error.clone();
|
|
let current_date = current_date.clone();
|
|
let external_events = props.external_calendar_events.clone(); // Clone before the effect
|
|
let view = props.view.clone(); // Clone before the effect
|
|
|
|
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
|
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
let date = *date; // Clone the date to avoid lifetime issues
|
|
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
|
|
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
|
|
|
|
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()
|
|
};
|
|
|
|
let current_year = date.year();
|
|
let current_month = date.month();
|
|
|
|
match calendar_service
|
|
.fetch_events_for_month_vevent(
|
|
&token,
|
|
&password,
|
|
current_year,
|
|
current_month,
|
|
)
|
|
.await
|
|
{
|
|
Ok(vevents) => {
|
|
// Filter CalDAV events based on calendar visibility
|
|
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
|
|
vevents.into_iter()
|
|
.filter(|event| {
|
|
if let Some(calendar_path) = event.calendar_path.as_ref() {
|
|
// Find the calendar info for this event
|
|
user_info.calendars.iter()
|
|
.find(|cal| &cal.path == calendar_path)
|
|
.map(|cal| cal.is_visible)
|
|
.unwrap_or(true) // Default to visible if not found
|
|
} else {
|
|
true // Show events without calendar path
|
|
}
|
|
})
|
|
.collect()
|
|
} else {
|
|
vevents // Show all events if no user info
|
|
};
|
|
|
|
// Mark external events as external by adding a special category
|
|
let marked_external_events: Vec<VEvent> = external_events
|
|
.into_iter()
|
|
.map(|mut event| {
|
|
// Add a special category to identify external events
|
|
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
|
|
event
|
|
})
|
|
.collect();
|
|
|
|
filtered_events.extend(marked_external_events);
|
|
|
|
let grouped_events = CalendarService::group_events_by_date(filtered_events);
|
|
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()));
|
|
}
|
|
|
|
|| ()
|
|
});
|
|
}
|
|
|
|
// Handle event click to refresh individual events
|
|
let on_event_click = {
|
|
let events = events.clone();
|
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
|
|
|
Callback::from(move |event: VEvent| {
|
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
|
|
if let Some(token) = auth_token {
|
|
let events = events.clone();
|
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
|
let uid = event.uid.clone();
|
|
|
|
refreshing_event_uid.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 = 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_uid.set(None);
|
|
});
|
|
}
|
|
})
|
|
};
|
|
|
|
// 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,
|
|
update_scope,
|
|
occurrence_date,
|
|
): (
|
|
VEvent,
|
|
chrono::NaiveDateTime,
|
|
chrono::NaiveDateTime,
|
|
bool,
|
|
Option<chrono::DateTime<chrono::Utc>>,
|
|
Option<String>,
|
|
Option<String>,
|
|
)| {
|
|
if let Some(callback) = &on_event_update_request {
|
|
callback.emit((
|
|
event,
|
|
new_start,
|
|
new_end,
|
|
preserve_rrule,
|
|
until_date,
|
|
update_scope,
|
|
occurrence_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)}
|
|
/>
|
|
|
|
{
|
|
if *loading {
|
|
html! {
|
|
<div class="calendar-loading">
|
|
<p>{"Loading calendar events..."}</p>
|
|
</div>
|
|
}
|
|
} else if let Some(err) = (*error).clone() {
|
|
html! {
|
|
<div class="calendar-error">
|
|
<p>{format!("Error: {}", err)}</p>
|
|
</div>
|
|
}
|
|
} else {
|
|
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={(*events).clone()}
|
|
on_event_click={on_event_click.clone()}
|
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
|
user_info={props.user_info.clone()}
|
|
external_calendars={props.external_calendars.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={(*events).clone()}
|
|
on_event_click={on_event_click.clone()}
|
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
|
user_info={props.user_info.clone()}
|
|
external_calendars={props.external_calendars.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);
|
|
}
|
|
})
|
|
}}
|
|
/>
|
|
</div>
|
|
}
|
|
}
|