Add external calendars feature: display read-only ICS calendars alongside CalDAV calendars
- Database: Add external_calendars table with user relationships and CRUD operations - Backend: Implement REST API endpoints for external calendar management and ICS fetching - Frontend: Add external calendar modal, sidebar section with visibility toggles - Calendar integration: Merge external events with regular events in unified view - ICS parsing: Support multiple datetime formats, recurring events, and timezone handling - Authentication: Integrate with existing JWT token system for user-specific calendars - UI: Visual distinction with 📅 indicator and separate management section 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub external_calendar_events: Vec<VEvent>,
|
||||
#[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)>>,
|
||||
@@ -101,10 +103,13 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
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, props.view.clone()), move |(date, _view)| {
|
||||
use_effect_with((*current_date, view.clone(), external_events.len()), move |(date, _view, _external_len)| {
|
||||
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
|
||||
|
||||
if let Some(token) = auth_token {
|
||||
let events = events.clone();
|
||||
@@ -141,7 +146,11 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
.await
|
||||
{
|
||||
Ok(vevents) => {
|
||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||
// Combine regular events with external calendar events
|
||||
let mut all_events = vevents;
|
||||
all_events.extend(external_events);
|
||||
|
||||
let grouped_events = CalendarService::group_events_by_date(all_events);
|
||||
events.set(grouped_events);
|
||||
loading.set(false);
|
||||
}
|
||||
|
||||
193
frontend/src/components/external_calendar_modal.rs
Normal file
193
frontend/src/components/external_calendar_modal.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::services::calendar_service::CalendarService;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ExternalCalendarModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_success: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(ExternalCalendarModal)]
|
||||
pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html {
|
||||
let name_ref = use_node_ref();
|
||||
let url_ref = use_node_ref();
|
||||
let color_ref = use_node_ref();
|
||||
let is_loading = use_state(|| false);
|
||||
let error_message = use_state(|| None::<String>);
|
||||
|
||||
let on_submit = {
|
||||
let name_ref = name_ref.clone();
|
||||
let url_ref = url_ref.clone();
|
||||
let color_ref = color_ref.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let error_message = error_message.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let on_success = props.on_success.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let name = name_ref
|
||||
.cast::<HtmlInputElement>()
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let url = url_ref
|
||||
.cast::<HtmlInputElement>()
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let color = color_ref
|
||||
.cast::<HtmlInputElement>()
|
||||
.map(|input| input.value())
|
||||
.unwrap_or_else(|| "#4285f4".to_string());
|
||||
|
||||
if name.is_empty() {
|
||||
error_message.set(Some("Calendar name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if url.is_empty() {
|
||||
error_message.set(Some("Calendar URL is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
error_message.set(None);
|
||||
is_loading.set(true);
|
||||
|
||||
let is_loading = is_loading.clone();
|
||||
let error_message = error_message.clone();
|
||||
let on_close = on_close.clone();
|
||||
let on_success = on_success.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||
Ok(_) => {
|
||||
is_loading.set(false);
|
||||
on_success.emit(());
|
||||
on_close.emit(());
|
||||
}
|
||||
Err(e) => {
|
||||
is_loading.set(false);
|
||||
error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel_clone = on_cancel.clone();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="modal-overlay" onclick={on_cancel_clone}>
|
||||
<div class="modal-content" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||
<div class="modal-header">
|
||||
<h3>{"Add External Calendar"}</h3>
|
||||
<button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="modal-body">
|
||||
{
|
||||
if let Some(error) = (*error_message).as_ref() {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="external-calendar-name">{"Calendar Name"}</label>
|
||||
<input
|
||||
ref={name_ref}
|
||||
id="external-calendar-name"
|
||||
type="text"
|
||||
placeholder="My External Calendar"
|
||||
disabled={*is_loading}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="external-calendar-url">{"ICS URL"}</label>
|
||||
<input
|
||||
ref={url_ref}
|
||||
id="external-calendar-url"
|
||||
type="url"
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
disabled={*is_loading}
|
||||
required={true}
|
||||
/>
|
||||
<small class="form-help">
|
||||
{"Enter the public ICS URL for the calendar you want to add"}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="external-calendar-color">{"Color"}</label>
|
||||
<input
|
||||
ref={color_ref}
|
||||
id="external-calendar-color"
|
||||
type="color"
|
||||
value="#4285f4"
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick={on_cancel}
|
||||
disabled={*is_loading}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={*is_loading}
|
||||
>
|
||||
{
|
||||
if *is_loading {
|
||||
"Adding..."
|
||||
} else {
|
||||
"Add Calendar"
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod create_event_modal;
|
||||
pub mod event_context_menu;
|
||||
pub mod event_form;
|
||||
pub mod event_modal;
|
||||
pub mod external_calendar_modal;
|
||||
pub mod login;
|
||||
pub mod month_view;
|
||||
pub mod recurring_edit_modal;
|
||||
@@ -26,6 +27,7 @@ pub use create_event_modal::CreateEventModal;
|
||||
pub use event_form::EventCreationData;
|
||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||
pub use event_modal::EventModal;
|
||||
pub use external_calendar_modal::ExternalCalendarModal;
|
||||
pub use login::Login;
|
||||
pub use month_view::MonthView;
|
||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct RouteHandlerProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_login: Callback<String>,
|
||||
#[prop_or_default]
|
||||
pub external_calendar_events: Vec<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
@@ -48,6 +50,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
let auth_token = props.auth_token.clone();
|
||||
let user_info = props.user_info.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
let external_calendar_events = props.external_calendar_events.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();
|
||||
@@ -60,6 +63,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
let auth_token = auth_token.clone();
|
||||
let user_info = user_info.clone();
|
||||
let on_login = on_login.clone();
|
||||
let external_calendar_events = external_calendar_events.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();
|
||||
@@ -87,6 +91,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
html! {
|
||||
<CalendarView
|
||||
user_info={user_info}
|
||||
external_calendar_events={external_calendar_events}
|
||||
on_event_context_menu={on_event_context_menu}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
view={view}
|
||||
@@ -108,6 +113,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
pub struct CalendarViewProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub external_calendar_events: Vec<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
@@ -139,6 +146,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
<div class="calendar-view">
|
||||
<Calendar
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendar_events={props.external_calendar_events.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()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::components::CalendarListItem;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
@@ -101,6 +101,9 @@ pub struct SidebarProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_logout: Callback<()>,
|
||||
pub on_create_calendar: Callback<()>,
|
||||
pub on_create_external_calendar: Callback<()>,
|
||||
pub external_calendars: Vec<ExternalCalendar>,
|
||||
pub on_external_calendar_toggle: Callback<i32>,
|
||||
pub color_picker_open: Option<String>,
|
||||
pub on_color_change: Callback<(String, String)>,
|
||||
pub on_color_picker_toggle: Callback<String>,
|
||||
@@ -206,10 +209,59 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
// External calendars section
|
||||
<div class="external-calendar-list">
|
||||
<h3>{"External Calendars"}</h3>
|
||||
{
|
||||
if !props.external_calendars.is_empty() {
|
||||
html! {
|
||||
<ul class="external-calendar-items">
|
||||
{
|
||||
props.external_calendars.iter().map(|cal| {
|
||||
let on_toggle = {
|
||||
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
|
||||
let cal_id = cal.id;
|
||||
Callback::from(move |_| {
|
||||
on_external_calendar_toggle.emit(cal_id);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li class="external-calendar-item">
|
||||
<div class="external-calendar-info">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cal.is_visible}
|
||||
onchange={on_toggle}
|
||||
/>
|
||||
<span
|
||||
class="external-calendar-color"
|
||||
style={format!("background-color: {}", cal.color)}
|
||||
/>
|
||||
<span class="external-calendar-name">{&cal.name}</span>
|
||||
<span class="external-calendar-indicator">{"📅"}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||
{"+ Create Calendar"}
|
||||
</button>
|
||||
|
||||
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
|
||||
{"+ Add External Calendar"}
|
||||
</button>
|
||||
|
||||
<div class="view-selector">
|
||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||
|
||||
Reference in New Issue
Block a user