diff --git a/backend/migrations/006_create_external_calendars_table.sql b/backend/migrations/006_create_external_calendars_table.sql new file mode 100644 index 0000000..d1a934c --- /dev/null +++ b/backend/migrations/006_create_external_calendars_table.sql @@ -0,0 +1,16 @@ +-- Create external_calendars table +CREATE TABLE external_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4285f4', + is_visible BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_fetched DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Create index for performance +CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id); \ No newline at end of file diff --git a/backend/src/auth.rs b/backend/src/auth.rs index ce2c6af..d5c2bde 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -112,6 +112,17 @@ impl AuthService { self.decode_token(token) } + /// Get user from token + pub async fn get_user_from_token(&self, token: &str) -> Result { + let claims = self.verify_token(token)?; + + let user_repo = UserRepository::new(&self.db); + user_repo + .find_or_create(&claims.username, &claims.server_url) + .await + .map_err(|e| ApiError::Database(format!("Failed to get user: {}", e))) + } + /// Create CalDAV config from token pub fn caldav_config_from_token( &self, diff --git a/backend/src/db.rs b/backend/src/db.rs index 86bc8b5..b91a19a 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -99,6 +99,38 @@ pub struct UserPreferences { pub updated_at: DateTime, } +/// External calendar model +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ExternalCalendar { + pub id: i32, + pub user_id: String, + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_fetched: Option>, +} + +impl ExternalCalendar { + /// Create a new external calendar + pub fn new(user_id: String, name: String, url: String, color: String) -> Self { + let now = Utc::now(); + Self { + id: 0, // Will be set by database + user_id, + name, + url, + color, + is_visible: true, + created_at: now, + updated_at: now, + last_fetched: None, + } + } +} + impl UserPreferences { /// Create default preferences for a new user pub fn default_for_user(user_id: String) -> Self { @@ -308,6 +340,91 @@ impl<'a> PreferencesRepository<'a> { .execute(self.db.pool()) .await?; + Ok(()) + } +} + +/// Repository for ExternalCalendar operations +pub struct ExternalCalendarRepository<'a> { + db: &'a Database, +} + +impl<'a> ExternalCalendarRepository<'a> { + pub fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Get all external calendars for a user + pub async fn get_by_user(&self, user_id: &str) -> Result> { + sqlx::query_as::<_, ExternalCalendar>( + "SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC", + ) + .bind(user_id) + .fetch_all(self.db.pool()) + .await + } + + /// Create a new external calendar + pub async fn create(&self, calendar: &ExternalCalendar) -> Result { + let result = sqlx::query( + "INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&calendar.user_id) + .bind(&calendar.name) + .bind(&calendar.url) + .bind(&calendar.color) + .bind(&calendar.is_visible) + .bind(&calendar.created_at) + .bind(&calendar.updated_at) + .execute(self.db.pool()) + .await?; + + Ok(result.last_insert_rowid() as i32) + } + + /// Update an external calendar + pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> { + sqlx::query( + "UPDATE external_calendars + SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ? + WHERE id = ? AND user_id = ?", + ) + .bind(&calendar.name) + .bind(&calendar.url) + .bind(&calendar.color) + .bind(&calendar.is_visible) + .bind(Utc::now()) + .bind(id) + .bind(&calendar.user_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Delete an external calendar + pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> { + sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?") + .bind(id) + .bind(user_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Update last_fetched timestamp + pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> { + sqlx::query( + "UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?", + ) + .bind(Utc::now()) + .bind(id) + .bind(user_id) + .execute(self.db.pool()) + .await?; + Ok(()) } } \ No newline at end of file diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs deleted file mode 100644 index 9d278c7..0000000 --- a/backend/src/handlers.rs +++ /dev/null @@ -1,12 +0,0 @@ -// Re-export all handlers from the modular structure -mod auth; -mod calendar; -mod events; -mod preferences; -mod series; - -pub use auth::{get_user_info, login, verify_token}; -pub use calendar::{create_calendar, delete_calendar}; -pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; -pub use preferences::{get_preferences, logout, update_preferences}; -pub use series::{create_event_series, delete_event_series, update_event_series}; diff --git a/backend/src/handlers/external_calendars.rs b/backend/src/handlers/external_calendars.rs new file mode 100644 index 0000000..05cc91f --- /dev/null +++ b/backend/src/handlers/external_calendars.rs @@ -0,0 +1,142 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::{ + db::{ExternalCalendar, ExternalCalendarRepository}, + models::ApiError, + AppState, +}; + +use super::auth::{extract_bearer_token}; + +#[derive(Debug, Deserialize)] +pub struct CreateExternalCalendarRequest { + pub name: String, + pub url: String, + pub color: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateExternalCalendarRequest { + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, +} + +#[derive(Debug, Serialize)] +pub struct ExternalCalendarResponse { + pub id: i32, + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub last_fetched: Option>, +} + +impl From for ExternalCalendarResponse { + fn from(calendar: ExternalCalendar) -> Self { + Self { + id: calendar.id, + name: calendar.name, + url: calendar.url, + color: calendar.color, + is_visible: calendar.is_visible, + created_at: calendar.created_at, + updated_at: calendar.updated_at, + last_fetched: calendar.last_fetched, + } + } +} + +pub async fn get_external_calendars( + headers: axum::http::HeaderMap, + State(app_state): State>, +) -> Result>, ApiError> { + // Extract and verify token, get user + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + let calendars = repo + .get_by_user(&user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; + + let response: Vec = calendars.into_iter().map(Into::into).collect(); + Ok(Json(response)) +} + +pub async fn create_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Json(request): Json, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let calendar = ExternalCalendar::new( + user.id, + request.name, + request.url, + request.color, + ); + + let repo = ExternalCalendarRepository::new(&app_state.db); + let id = repo + .create(&calendar) + .await + .map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?; + + let mut created_calendar = calendar; + created_calendar.id = id; + + Ok(Json(created_calendar.into())) +} + +pub async fn update_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let mut calendar = ExternalCalendar::new( + user.id, + request.name, + request.url, + request.color, + ); + calendar.is_visible = request.is_visible; + + let repo = ExternalCalendarRepository::new(&app_state.db); + repo.update(id, &calendar) + .await + .map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?; + + Ok(Json(())) +} + +pub async fn delete_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + repo.delete(id, &user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?; + + Ok(Json(())) +} \ No newline at end of file diff --git a/backend/src/handlers/ics_fetcher.rs b/backend/src/handlers/ics_fetcher.rs new file mode 100644 index 0000000..d797cf9 --- /dev/null +++ b/backend/src/handlers/ics_fetcher.rs @@ -0,0 +1,238 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use chrono::{DateTime, Utc}; +use ical::parser::ical::component::IcalEvent; +use reqwest::Client; +use serde::Serialize; +use std::sync::Arc; + +use crate::{ + db::ExternalCalendarRepository, + models::ApiError, + AppState, +}; + +// Import VEvent from calendar-models shared crate +use calendar_models::VEvent; + +use super::auth::{extract_bearer_token}; + +#[derive(Debug, Serialize)] +pub struct ExternalCalendarEventsResponse { + pub events: Vec, + pub last_fetched: DateTime, +} + +pub async fn fetch_external_calendar_events( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + + // Get user's external calendars to verify ownership and get URL + let calendars = repo + .get_by_user(&user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; + + let calendar = calendars + .into_iter() + .find(|c| c.id == id) + .ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?; + + if !calendar.is_visible { + return Ok(Json(ExternalCalendarEventsResponse { + events: vec![], + last_fetched: Utc::now(), + })); + } + + // Fetch ICS content from URL + let client = Client::new(); + let response = client + .get(&calendar.url) + .send() + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; + + if !response.status().is_success() { + return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); + } + + let ics_content = response + .text() + .await + .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; + + // Parse ICS content + let events = parse_ics_content(&ics_content) + .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; + + // Update last_fetched timestamp + repo.update_last_fetched(id, &user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to update last_fetched: {}", e)))?; + + Ok(Json(ExternalCalendarEventsResponse { + events, + last_fetched: Utc::now(), + })) +} + +fn parse_ics_content(ics_content: &str) -> Result, Box> { + let reader = ical::IcalParser::new(ics_content.as_bytes()); + let mut events = Vec::new(); + + for calendar in reader { + let calendar = calendar?; + for component in calendar.events { + if let Ok(vevent) = convert_ical_to_vevent(component) { + events.push(vevent); + } + } + } + + Ok(events) +} + +fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result> { + use uuid::Uuid; + + let mut summary = None; + let mut description = None; + let mut location = None; + let mut dtstart = None; + let mut dtend = None; + let mut uid = None; + let mut all_day = false; + let mut rrule = None; + + // Extract properties + for property in ical_event.properties { + match property.name.as_str() { + "SUMMARY" => { + summary = property.value; + } + "DESCRIPTION" => { + description = property.value; + } + "LOCATION" => { + location = property.value; + } + "DTSTART" => { + if let Some(value) = property.value { + // Check if it's a date-only value (all-day event) + if value.len() == 8 && !value.contains('T') { + all_day = true; + // Parse YYYYMMDD format + if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { + dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); + } + } else { + // Parse datetime - could be various formats + if let Some(dt) = parse_datetime(&value) { + dtstart = Some(dt); + } + } + } + } + "DTEND" => { + if let Some(value) = property.value { + if all_day && value.len() == 8 && !value.contains('T') { + // For all-day events, DTEND is exclusive so use the date as-is at noon + if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { + dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); + } + } else { + if let Some(dt) = parse_datetime(&value) { + dtend = Some(dt); + } + } + } + } + "UID" => { + uid = property.value; + } + "RRULE" => { + rrule = property.value; + } + _ => {} // Ignore other properties for now + } + } + + let vevent = VEvent { + uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), + dtstart: dtstart.ok_or("Missing DTSTART")?, + dtend, + summary, + description, + location, + all_day, + rrule, + exdate: Vec::new(), // External calendars don't need exception handling + recurrence_id: None, + created: None, + last_modified: None, + dtstamp: Utc::now(), + sequence: Some(0), + status: None, + transp: None, + organizer: None, + attendees: Vec::new(), + url: None, + attachments: Vec::new(), + categories: Vec::new(), + priority: None, + resources: Vec::new(), + related_to: None, + geo: None, + duration: None, + class: None, + contact: None, + comment: None, + rdate: Vec::new(), + alarms: Vec::new(), + etag: None, + href: None, + calendar_path: None, + }; + + Ok(vevent) +} + +fn parse_datetime(datetime_str: &str) -> Option> { + // Try various datetime formats commonly found in ICS files + + // Format: 20231201T103000Z (UTC) + if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%SZ") { + return Some(dt.with_timezone(&Utc)); + } + + // Format: 20231201T103000 (floating time - assume UTC) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { + return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); + } + + // Format: 20231201T103000-0500 (with timezone offset) + if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") { + return Some(dt.with_timezone(&Utc)); + } + + // Format: 2023-12-01T10:30:00Z (ISO format) + if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") { + return Some(dt.with_timezone(&Utc)); + } + + // Format: 2023-12-01T10:30:00 (ISO without timezone) + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { + return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); + } + + None +} \ No newline at end of file diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs new file mode 100644 index 0000000..d905b89 --- /dev/null +++ b/backend/src/handlers/mod.rs @@ -0,0 +1,15 @@ +pub mod auth; +pub mod calendar; +pub mod events; +pub mod external_calendars; +pub mod ics_fetcher; +pub mod preferences; +pub mod series; + +pub use auth::*; +pub use calendar::*; +pub use events::*; +pub use external_calendars::*; +pub use ics_fetcher::*; +pub use preferences::*; +pub use series::*; \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 7b719e3..2b9e395 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,6 @@ use axum::{ response::Json, - routing::{get, post}, + routing::{delete, get, post}, Router, }; use std::sync::Arc; @@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/preferences", get(handlers::get_preferences)) .route("/api/preferences", post(handlers::update_preferences)) .route("/api/auth/logout", post(handlers::logout)) + // External calendars endpoints + .route("/api/external-calendars", get(handlers::get_external_calendars)) + .route("/api/external-calendars", post(handlers::create_external_calendar)) + .route("/api/external-calendars/:id", post(handlers::update_external_calendar)) + .route("/api/external-calendars/:id", delete(handlers::delete_external_calendar)) + .route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 1cfeaa0..e6f55b5 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] } ical = "0.7" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde-wasm-bindgen = "0.6" # Date and time handling chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } diff --git a/frontend/src/app.rs b/frontend/src/app.rs index a628517..4c62964 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,10 +1,11 @@ use crate::components::{ CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, - EditAction, EventContextMenu, EventCreationData, RouteHandler, Sidebar, Theme, ViewMode, + EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler, + Sidebar, Theme, ViewMode, }; use crate::components::sidebar::{Style}; use crate::models::ical::VEvent; -use crate::services::{calendar_service::UserInfo, CalendarService}; +use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use chrono::NaiveDate; use gloo_storage::{LocalStorage, Storage}; use wasm_bindgen::JsCast; @@ -73,6 +74,11 @@ pub fn App() -> Html { let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_event = use_state(|| -> Option { None }); let _recurring_edit_data = use_state(|| -> Option { None }); + + // External calendar state + let external_calendars = use_state(|| -> Vec { Vec::new() }); + let external_calendar_events = use_state(|| -> Vec { Vec::new() }); + let external_calendar_modal_open = use_state(|| false); // Calendar view state - load from localStorage if available let current_view = use_state(|| { @@ -301,6 +307,50 @@ pub fn App() -> Html { }); } + // Load external calendars when auth token is available + { + let auth_token = auth_token.clone(); + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + + use_effect_with((*auth_token).clone(), move |token| { + if token.is_some() { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + + wasm_bindgen_futures::spawn_local(async move { + // Load external calendars + match CalendarService::get_external_calendars().await { + Ok(calendars) => { + external_calendars.set(calendars.clone()); + + // Load events for visible external calendars + let mut all_events = Vec::new(); + for calendar in calendars { + if calendar.is_visible { + if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + Err(err) => { + web_sys::console::log_1( + &format!("Failed to load external calendars: {}", err).into(), + ); + } + } + }); + } else { + external_calendars.set(Vec::new()); + external_calendar_events.set(Vec::new()); + } + + || () + }); + } + let on_outside_click = { let color_picker_open = color_picker_open.clone(); let context_menu_open = context_menu_open.clone(); @@ -924,6 +974,53 @@ pub fn App() -> Html { let create_modal_open = create_modal_open.clone(); move |_| create_modal_open.set(true) })} + on_create_external_calendar={Callback::from({ + let external_calendar_modal_open = external_calendar_modal_open.clone(); + move |_| external_calendar_modal_open.set(true) + })} + external_calendars={(*external_calendars).clone()} + on_external_calendar_toggle={Callback::from({ + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + move |id: i32| { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Find the calendar and toggle its visibility + let mut calendars = (*external_calendars).clone(); + if let Some(calendar) = calendars.iter_mut().find(|c| c.id == id) { + calendar.is_visible = !calendar.is_visible; + + // Update on server + if let Err(err) = CalendarService::update_external_calendar( + calendar.id, + &calendar.name, + &calendar.url, + &calendar.color, + calendar.is_visible, + ).await { + web_sys::console::log_1( + &format!("Failed to update external calendar: {}", err).into(), + ); + return; + } + + external_calendars.set(calendars.clone()); + + // Reload events for all visible external calendars + let mut all_events = Vec::new(); + for cal in calendars { + if cal.is_visible { + if let Ok(events) = CalendarService::fetch_external_calendar_events(cal.id).await { + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + }); + } + })} color_picker_open={(*color_picker_open).clone()} on_color_change={on_color_change} on_color_picker_toggle={on_color_picker_toggle} @@ -941,6 +1038,7 @@ pub fn App() -> Html { auth_token={(*auth_token).clone()} user_info={(*user_info).clone()} on_login={on_login.clone()} + external_calendar_events={(*external_calendar_events).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()} @@ -1193,6 +1291,46 @@ pub fn App() -> Html { on_create={on_event_create} available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} /> + + { + external_calendars.set(calendars.clone()); + + // Load events for visible external calendars + let mut all_events = Vec::new(); + for calendar in calendars { + if calendar.is_visible { + if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + Err(err) => { + web_sys::console::log_1( + &format!("Failed to reload external calendars: {}", err).into(), + ); + } + } + }); + } + })} + /> } diff --git a/frontend/src/components/calendar.rs b/frontend/src/components/calendar.rs index b60cdf0..0bf0c9e 100644 --- a/frontend/src/components/calendar.rs +++ b/frontend/src/components/calendar.rs @@ -14,6 +14,8 @@ pub struct CalendarProps { #[prop_or_default] pub user_info: Option, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -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 = 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); } diff --git a/frontend/src/components/external_calendar_modal.rs b/frontend/src/components/external_calendar_modal.rs new file mode 100644 index 0000000..430afa6 --- /dev/null +++ b/frontend/src/components/external_calendar_modal.rs @@ -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::); + + 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::() + .map(|input| input.value()) + .unwrap_or_default() + .trim() + .to_string(); + + let url = url_ref + .cast::() + .map(|input| input.value()) + .unwrap_or_default() + .trim() + .to_string(); + + let color = color_ref + .cast::() + .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! { + + } +} \ No newline at end of file diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index 889444a..a0624bf 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -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}; diff --git a/frontend/src/components/route_handler.rs b/frontend/src/components/route_handler.rs index 1759a3f..d2c7fcb 100644 --- a/frontend/src/components/route_handler.rs +++ b/frontend/src/components/route_handler.rs @@ -20,6 +20,8 @@ pub struct RouteHandlerProps { pub user_info: Option, pub on_login: Callback, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -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! { Html { pub struct CalendarViewProps { pub user_info: Option, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -139,6 +146,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
, pub on_logout: Callback<()>, pub on_create_calendar: Callback<()>, + pub on_create_external_calendar: Callback<()>, + pub external_calendars: Vec, + pub on_external_calendar_toggle: Callback, pub color_picker_open: Option, pub on_color_change: Callback<(String, String)>, pub on_color_picker_toggle: Callback, @@ -206,10 +209,59 @@ pub fn sidebar(props: &SidebarProps) -> Html { html! {} } } + + // External calendars section +
+

{"External Calendars"}

+ { + if !props.external_calendars.is_empty() { + html! { +
    + { + 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! { +
  • +
    + + + {&cal.name} + {"📅"} +
    +
  • + } + }).collect::() + } +
+ } + } else { + html! {} + } + } +
+