Compare commits
10 Commits
289284a532
...
aab478202b
| Author | SHA1 | Date | |
|---|---|---|---|
| aab478202b | |||
|
|
45e16313ba | ||
|
|
64c737c023 | ||
|
|
75d9149c76 | ||
|
|
28b3946e86 | ||
|
|
6a01a75cce | ||
|
|
189dd32f8c | ||
|
|
7461e8b123 | ||
|
|
f88c238b0a | ||
|
|
8caa1f45ae |
@@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.8"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
anyhow = "1.0"
|
||||
|
||||
|
||||
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
@@ -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);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Create external calendar cache table for storing ICS data
|
||||
CREATE TABLE external_calendar_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
external_calendar_id INTEGER NOT NULL,
|
||||
ics_data TEXT NOT NULL,
|
||||
cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
etag TEXT,
|
||||
FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE,
|
||||
UNIQUE(external_calendar_id)
|
||||
);
|
||||
|
||||
-- Index for faster lookups
|
||||
CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id);
|
||||
CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at);
|
||||
@@ -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<crate::db::User, ApiError> {
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||
use sqlx::{FromRow, Result};
|
||||
@@ -99,6 +99,38 @@ pub struct UserPreferences {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_fetched: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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,149 @@ 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<Vec<ExternalCalendar>> {
|
||||
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<i32> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Get cached ICS data for an external calendar
|
||||
pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> {
|
||||
let result = sqlx::query_as::<_, (String, DateTime<Utc>)>(
|
||||
"SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?",
|
||||
)
|
||||
.bind(external_calendar_id)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Update cache with new ICS data
|
||||
pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(external_calendar_id) DO UPDATE SET
|
||||
ics_data = excluded.ics_data,
|
||||
etag = excluded.etag,
|
||||
cached_at = excluded.cached_at",
|
||||
)
|
||||
.bind(external_calendar_id)
|
||||
.bind(ics_data)
|
||||
.bind(etag)
|
||||
.bind(Utc::now())
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if cache is stale (older than max_age_minutes)
|
||||
pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> {
|
||||
let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes);
|
||||
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM external_calendar_cache
|
||||
WHERE external_calendar_id = ? AND cached_at > ?",
|
||||
)
|
||||
.bind(external_calendar_id)
|
||||
.bind(cutoff_time)
|
||||
.fetch_one(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result == 0)
|
||||
}
|
||||
|
||||
/// Clear cache for an external calendar
|
||||
pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> {
|
||||
sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?")
|
||||
.bind(external_calendar_id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -93,6 +93,7 @@ pub async fn get_user_info(
|
||||
path: path.clone(),
|
||||
display_name: extract_calendar_name(path),
|
||||
color: generate_calendar_color(path),
|
||||
is_visible: true, // Default to visible
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
142
backend/src/handlers/external_calendars.rs
Normal file
142
backend/src/handlers/external_calendars.rs
Normal file
@@ -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<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl From<ExternalCalendar> 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<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<ExternalCalendarResponse>>, 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<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect();
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn create_external_calendar(
|
||||
headers: axum::http::HeaderMap,
|
||||
State(app_state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateExternalCalendarRequest>,
|
||||
) -> Result<Json<ExternalCalendarResponse>, 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<Arc<AppState>>,
|
||||
Path(id): Path<i32>,
|
||||
Json(request): Json<UpdateExternalCalendarRequest>,
|
||||
) -> Result<Json<()>, 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<Arc<AppState>>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<()>, 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(()))
|
||||
}
|
||||
410
backend/src/handlers/ics_fetcher.rs
Normal file
410
backend/src/handlers/ics_fetcher.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
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<VEvent>,
|
||||
pub last_fetched: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn fetch_external_calendar_events(
|
||||
headers: axum::http::HeaderMap,
|
||||
State(app_state): State<Arc<AppState>>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<ExternalCalendarEventsResponse>, 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(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
let cache_max_age_minutes = 5;
|
||||
let mut ics_content = String::new();
|
||||
let mut last_fetched = Utc::now();
|
||||
let mut fetched_from_cache = false;
|
||||
|
||||
// Try to get from cache if not stale
|
||||
match repo.is_cache_stale(id, cache_max_age_minutes).await {
|
||||
Ok(is_stale) => {
|
||||
if !is_stale {
|
||||
// Cache is fresh, use it
|
||||
if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await {
|
||||
ics_content = cached_data;
|
||||
last_fetched = cached_at;
|
||||
fetched_from_cache = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// If cache check fails, proceed to fetch from URL
|
||||
}
|
||||
}
|
||||
|
||||
// If not fetched from cache, get from external URL
|
||||
if !fetched_from_cache {
|
||||
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())));
|
||||
}
|
||||
|
||||
ics_content = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?;
|
||||
|
||||
// Store in cache for future requests
|
||||
let etag = None; // TODO: Extract ETag from response headers if available
|
||||
if let Err(_) = repo.update_cache(id, &ics_content, etag).await {
|
||||
// Log error but don't fail the request
|
||||
}
|
||||
|
||||
// Update last_fetched timestamp
|
||||
if let Err(_) = repo.update_last_fetched(id, &user.id).await {
|
||||
}
|
||||
|
||||
last_fetched = Utc::now();
|
||||
}
|
||||
|
||||
// Parse ICS content
|
||||
let events = parse_ics_content(&ics_content)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?;
|
||||
|
||||
Ok(Json(ExternalCalendarEventsResponse {
|
||||
events,
|
||||
last_fetched,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> {
|
||||
let reader = ical::IcalParser::new(ics_content.as_bytes());
|
||||
let mut events = Vec::new();
|
||||
let mut _total_components = 0;
|
||||
let mut _failed_conversions = 0;
|
||||
|
||||
for calendar in reader {
|
||||
let calendar = calendar?;
|
||||
for component in calendar.events {
|
||||
_total_components += 1;
|
||||
match convert_ical_to_vevent(component) {
|
||||
Ok(vevent) => {
|
||||
events.push(vevent);
|
||||
}
|
||||
Err(_) => {
|
||||
_failed_conversions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
// Extract timezone info from parameters
|
||||
let tzid = property.params.as_ref()
|
||||
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||
.and_then(|(_, v)| v.first().cloned());
|
||||
|
||||
// Parse datetime with timezone information
|
||||
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||
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 {
|
||||
// Extract timezone info from parameters
|
||||
let tzid = property.params.as_ref()
|
||||
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||
.and_then(|(_, v)| v.first().cloned());
|
||||
|
||||
// Parse datetime with timezone information
|
||||
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||
dtend = Some(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"UID" => {
|
||||
uid = property.value;
|
||||
}
|
||||
"RRULE" => {
|
||||
rrule = property.value;
|
||||
}
|
||||
_ => {} // Ignore other properties for now
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let dtstart = dtstart.ok_or("Missing DTSTART")?;
|
||||
|
||||
let vevent = VEvent {
|
||||
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
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_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
use chrono::TimeZone;
|
||||
use chrono_tz::Tz;
|
||||
|
||||
|
||||
// Try various datetime formats commonly found in ICS files
|
||||
|
||||
// Format: 20231201T103000Z (UTC) - handle as naive datetime first
|
||||
if datetime_str.ends_with('Z') {
|
||||
let datetime_without_z = &datetime_str[..datetime_str.len()-1];
|
||||
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") {
|
||||
return Some(naive_dt.and_utc());
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Handle naive datetime with timezone parameter
|
||||
let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") {
|
||||
Some(dt)
|
||||
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
|
||||
Some(dt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(naive_dt) = naive_dt {
|
||||
// If TZID is provided, try to parse it
|
||||
if let Some(tzid_str) = tzid {
|
||||
// Handle common timezone formats
|
||||
let tz_result = if tzid_str.starts_with("/mozilla.org/") {
|
||||
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||
tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||
} else if tzid_str.contains('/') {
|
||||
// Standard timezone format: America/New_York, Europe/London
|
||||
tzid_str.parse::<Tz>().ok()
|
||||
} else {
|
||||
// Try common abbreviations and Windows timezone names
|
||||
match tzid_str {
|
||||
// Standard abbreviations
|
||||
"EST" => Some(Tz::America__New_York),
|
||||
"PST" => Some(Tz::America__Los_Angeles),
|
||||
"MST" => Some(Tz::America__Denver),
|
||||
"CST" => Some(Tz::America__Chicago),
|
||||
|
||||
// North America - Windows timezone names to IANA mapping
|
||||
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||
|
||||
// Europe
|
||||
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||
|
||||
// Asia
|
||||
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||
"Afghanistan Standard Time" => Some(Tz::Asia__Kabul),
|
||||
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||
"Arabian Standard Time" => Some(Tz::Asia__Dubai),
|
||||
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||
"Jordan Standard Time" => Some(Tz::Asia__Amman),
|
||||
"Syria Standard Time" => Some(Tz::Asia__Damascus),
|
||||
"Middle East Standard Time" => Some(Tz::Asia__Beirut),
|
||||
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||
"E. Africa Standard Time" => Some(Tz::Africa__Nairobi),
|
||||
"W. Central Africa Standard Time" => Some(Tz::Africa__Lagos),
|
||||
|
||||
// Asia Pacific
|
||||
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||
"AUS Central Standard Time" => Some(Tz::Australia__Darwin),
|
||||
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||
|
||||
// South America
|
||||
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||
"E. South America Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||
"SA Eastern Standard Time" => Some(Tz::America__Cayenne),
|
||||
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||
"Pacific SA Standard Time" => Some(Tz::America__Santiago),
|
||||
"Venezuela Standard Time" => Some(Tz::America__Caracas),
|
||||
"Montevideo Standard Time" => Some(Tz::America__Montevideo),
|
||||
|
||||
// Try parsing as IANA name
|
||||
_ => tzid_str.parse::<Tz>().ok()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tz) = tz_result {
|
||||
if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() {
|
||||
return Some(dt_with_tz.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no timezone info or parsing failed, treat as UTC (safer than local time assumptions)
|
||||
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
15
backend/src/handlers/mod.rs
Normal file
15
backend/src/handlers/mod.rs
Normal file
@@ -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::*;
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
.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)
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct CalendarInfo {
|
||||
pub path: String,
|
||||
pub display_name: String,
|
||||
pub color: String,
|
||||
pub is_visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 gloo_timers::callback::Interval;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
@@ -73,6 +75,12 @@ pub fn App() -> Html {
|
||||
let _recurring_edit_modal_open = use_state(|| false);
|
||||
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
||||
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
||||
|
||||
// External calendar state
|
||||
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
|
||||
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
|
||||
let external_calendar_modal_open = use_state(|| false);
|
||||
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
||||
|
||||
// Calendar view state - load from localStorage if available
|
||||
let current_view = use_state(|| {
|
||||
@@ -301,6 +309,80 @@ pub fn App() -> Html {
|
||||
});
|
||||
}
|
||||
|
||||
// Function to refresh external calendars
|
||||
let refresh_external_calendars = {
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
Callback::from(move |_| {
|
||||
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(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", calendar.id));
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Load external calendars when auth token is available and set up auto-refresh
|
||||
{
|
||||
let auth_token = auth_token.clone();
|
||||
let refresh_external_calendars = refresh_external_calendars.clone();
|
||||
let refresh_interval = refresh_interval.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
|
||||
use_effect_with((*auth_token).clone(), move |token| {
|
||||
if let Some(_) = token {
|
||||
// Initial load
|
||||
refresh_external_calendars.emit(());
|
||||
|
||||
// Set up 5-minute refresh interval
|
||||
let refresh_external_calendars = refresh_external_calendars.clone();
|
||||
let interval = Interval::new(5 * 60 * 1000, move || {
|
||||
refresh_external_calendars.emit(());
|
||||
});
|
||||
refresh_interval.set(Some(interval));
|
||||
} else {
|
||||
// Clear data and interval when logged out
|
||||
external_calendars.set(Vec::new());
|
||||
external_calendar_events.set(Vec::new());
|
||||
refresh_interval.set(None);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
let refresh_interval = refresh_interval.clone();
|
||||
move || {
|
||||
// Clear interval on cleanup
|
||||
refresh_interval.set(None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let on_outside_click = {
|
||||
let color_picker_open = color_picker_open.clone();
|
||||
let context_menu_open = context_menu_open.clone();
|
||||
@@ -924,11 +1006,146 @@ 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(mut events) = CalendarService::fetch_external_calendar_events(cal.id).await {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", cal.id));
|
||||
}
|
||||
all_events.extend(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
external_calendar_events.set(all_events);
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
on_external_calendar_delete={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 {
|
||||
// Delete the external calendar from the server
|
||||
if let Err(err) = CalendarService::delete_external_calendar(id).await {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to delete external calendar: {}", err).into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove calendar from local state
|
||||
let mut calendars = (*external_calendars).clone();
|
||||
calendars.retain(|c| c.id != id);
|
||||
external_calendars.set(calendars.clone());
|
||||
|
||||
// Remove events from this calendar
|
||||
let mut events = (*external_calendar_events).clone();
|
||||
events.retain(|e| {
|
||||
if let Some(ref calendar_path) = e.calendar_path {
|
||||
calendar_path != &format!("external_{}", id)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
external_calendar_events.set(events);
|
||||
});
|
||||
}
|
||||
})}
|
||||
on_external_calendar_refresh={Callback::from({
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
move |id: i32| {
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Force refresh of this specific calendar
|
||||
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", id));
|
||||
}
|
||||
|
||||
// Update events for this calendar
|
||||
let mut all_events = (*external_calendar_events).clone();
|
||||
// Remove old events from this calendar
|
||||
all_events.retain(|e| {
|
||||
if let Some(ref calendar_path) = e.calendar_path {
|
||||
calendar_path != &format!("external_{}", id)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Add new events
|
||||
all_events.extend(events);
|
||||
external_calendar_events.set(all_events);
|
||||
|
||||
// Update the last_fetched timestamp in calendars list
|
||||
if let Ok(calendars) = CalendarService::get_external_calendars().await {
|
||||
external_calendars.set(calendars);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
color_picker_open={(*color_picker_open).clone()}
|
||||
on_color_change={on_color_change}
|
||||
on_color_picker_toggle={on_color_picker_toggle}
|
||||
available_colors={(*available_colors).clone()}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
on_calendar_visibility_toggle={Callback::from({
|
||||
let user_info = user_info.clone();
|
||||
move |calendar_path: String| {
|
||||
let user_info = user_info.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(mut info) = (*user_info).clone() {
|
||||
// Toggle the visibility
|
||||
if let Some(calendar) = info.calendars.iter_mut().find(|c| c.path == calendar_path) {
|
||||
calendar.is_visible = !calendar.is_visible;
|
||||
user_info.set(Some(info));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
current_view={(*current_view).clone()}
|
||||
on_view_change={on_view_change}
|
||||
current_theme={(*current_theme).clone()}
|
||||
@@ -941,6 +1158,8 @@ 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()}
|
||||
external_calendars={(*external_calendars).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 +1412,59 @@ pub fn App() -> Html {
|
||||
on_create={on_event_create}
|
||||
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
||||
/>
|
||||
|
||||
<ExternalCalendarModal
|
||||
is_open={*external_calendar_modal_open}
|
||||
on_close={Callback::from({
|
||||
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
||||
move |_| external_calendar_modal_open.set(false)
|
||||
})}
|
||||
on_success={Callback::from({
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
move |new_calendar_id: i32| {
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// First, refresh the calendar list to get the new calendar
|
||||
match CalendarService::get_external_calendars().await {
|
||||
Ok(calendars) => {
|
||||
external_calendars.set(calendars.clone());
|
||||
|
||||
// Then immediately fetch events for the new calendar if it's visible
|
||||
if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) {
|
||||
if new_calendar.is_visible {
|
||||
match CalendarService::fetch_external_calendar_events(new_calendar_id).await {
|
||||
Ok(mut events) => {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", new_calendar_id));
|
||||
}
|
||||
|
||||
// Add the new calendar's events to existing events
|
||||
let mut all_events = (*external_calendar_events).clone();
|
||||
all_events.extend(events);
|
||||
external_calendar_events.set(all_events);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to refresh calendars after creation: {}", err).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::components::{
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use std::collections::HashMap;
|
||||
@@ -14,6 +14,10 @@ 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)>>,
|
||||
@@ -101,10 +105,14 @@ 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(), 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();
|
||||
@@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
.await
|
||||
{
|
||||
Ok(vevents) => {
|
||||
let grouped_events = CalendarService::group_events_by_date(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);
|
||||
}
|
||||
@@ -452,6 +491,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
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)}
|
||||
@@ -467,6 +507,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
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)}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct CalendarListItemProps {
|
||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||
pub on_visibility_toggle: Callback<String>, // calendar_path
|
||||
}
|
||||
|
||||
#[function_component(CalendarListItem)]
|
||||
@@ -32,44 +33,59 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_visibility_toggle = {
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_visibility_toggle = props.on_visibility_toggle.clone();
|
||||
Callback::from(move |_| {
|
||||
on_visibility_toggle.emit(cal_path.clone());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||
<span class="calendar-color"
|
||||
style={format!("background-color: {}", props.calendar.color)}
|
||||
onclick={on_color_click}>
|
||||
{
|
||||
if props.color_picker_open {
|
||||
html! {
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_change = props.on_color_change.clone();
|
||||
<div class="calendar-info">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.calendar.is_visible}
|
||||
onchange={on_visibility_toggle}
|
||||
/>
|
||||
<span class="calendar-color"
|
||||
style={format!("background-color: {}", props.calendar.color)}
|
||||
onclick={on_color_click}>
|
||||
{
|
||||
if props.color_picker_open {
|
||||
html! {
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_change = props.on_color_change.clone();
|
||||
|
||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||
});
|
||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||
});
|
||||
|
||||
let is_selected = props.calendar.color == *color;
|
||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||
let is_selected = props.calendar.color == *color;
|
||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||
|
||||
html! {
|
||||
<div class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
html! {
|
||||
<div class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||
</span>
|
||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
222
frontend/src/components/external_calendar_modal.rs
Normal file
222
frontend/src/components/external_calendar_modal.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
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<i32>, // Pass the newly created calendar ID
|
||||
}
|
||||
|
||||
#[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(new_calendar) => {
|
||||
is_loading.set(false);
|
||||
on_success.emit(new_calendar.id);
|
||||
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-backdrop" onclick={on_cancel_clone}>
|
||||
<div class="external-calendar-modal" 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-help" style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
|
||||
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #495057;">{"Setting up External Calendars"}</h4>
|
||||
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; line-height: 1.4;">
|
||||
{"Currently tested with Outlook 365 and Google Calendar. To get your calendar link:"}
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<strong style="font-size: 0.8rem; color: #495057;">{"Outlook 365:"}</strong>
|
||||
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||
<li>{"Go to Outlook Settings"}</li>
|
||||
<li>{"Navigate to Calendar → Shared Calendars"}</li>
|
||||
<li>{"Click \"Publish a calendar\""}</li>
|
||||
<li>{"Select your calendar and choose \"Can view all details\""}</li>
|
||||
<li>{"Copy the ICS link and paste it below"}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong style="font-size: 0.8rem; color: #495057;">{"Google Calendar:"}</strong>
|
||||
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||
<li>{"Hover over your calendar name in the left sidebar"}</li>
|
||||
<li>{"Click the three dots that appear"}</li>
|
||||
<li>{"Select \"Settings and sharing\""}</li>
|
||||
<li>{"Scroll to \"Integrate calendar\""}</li>
|
||||
<li>{"Copy the \"Public address in iCal format\" link"}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
@@ -17,6 +17,8 @@ pub struct MonthViewProps {
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[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)>>,
|
||||
@@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
// Check external calendars first (path format: "external_{id}")
|
||||
if calendar_path.starts_with("external_") {
|
||||
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||
if let Some(external_calendar) = props.external_calendars
|
||||
.iter()
|
||||
.find(|cal| cal.id == id_str)
|
||||
{
|
||||
return external_calendar.color.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check regular calendars
|
||||
else if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar) = user_info
|
||||
.calendars
|
||||
.iter()
|
||||
@@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
<div
|
||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||
style={format!("background-color: {}", event_color)}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::components::{Login, ViewMode};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
@@ -20,6 +20,10 @@ 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 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, chrono::NaiveDate)>>,
|
||||
@@ -48,6 +52,8 @@ 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 external_calendars = props.external_calendars.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 +66,8 @@ 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 external_calendars = external_calendars.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 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
html! {
|
||||
<CalendarView
|
||||
user_info={user_info}
|
||||
external_calendar_events={external_calendar_events}
|
||||
external_calendars={external_calendars}
|
||||
on_event_context_menu={on_event_context_menu}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
view={view}
|
||||
@@ -108,6 +118,10 @@ 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 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, chrono::NaiveDate)>>,
|
||||
@@ -139,6 +153,8 @@ 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()}
|
||||
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()}
|
||||
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,11 +101,17 @@ 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 on_external_calendar_delete: Callback<i32>,
|
||||
pub on_external_calendar_refresh: Callback<i32>,
|
||||
pub color_picker_open: Option<String>,
|
||||
pub on_color_change: Callback<(String, String)>,
|
||||
pub on_color_picker_toggle: Callback<String>,
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||
pub on_calendar_visibility_toggle: Callback<String>,
|
||||
pub current_view: ViewMode,
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
@@ -116,6 +122,7 @@ pub struct SidebarProps {
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
let external_context_menu_open = use_state(|| None::<i32>);
|
||||
let on_view_change = {
|
||||
let on_view_change = props.on_view_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
@@ -155,6 +162,30 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_external_calendar_context_menu = {
|
||||
let external_context_menu_open = external_context_menu_open.clone();
|
||||
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
|
||||
e.prevent_default();
|
||||
external_context_menu_open.set(Some(cal_id));
|
||||
})
|
||||
};
|
||||
|
||||
let on_external_calendar_delete = {
|
||||
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
|
||||
let external_context_menu_open = external_context_menu_open.clone();
|
||||
Callback::from(move |cal_id: i32| {
|
||||
on_external_calendar_delete.emit(cal_id);
|
||||
external_context_menu_open.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
let close_external_context_menu = {
|
||||
let external_context_menu_open = external_context_menu_open.clone();
|
||||
Callback::from(move |_| {
|
||||
external_context_menu_open.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -192,6 +223,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
||||
available_colors={props.available_colors.clone()}
|
||||
on_context_menu={props.on_calendar_context_menu.clone()}
|
||||
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
@@ -206,10 +238,127 @@ 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" style="position: relative;">
|
||||
<div
|
||||
class="external-calendar-info"
|
||||
oncontextmenu={{
|
||||
let on_context_menu = on_external_calendar_context_menu.clone();
|
||||
let cal_id = cal.id;
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_context_menu.emit((e, cal_id));
|
||||
})
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div class="external-calendar-actions">
|
||||
{
|
||||
if let Some(last_fetched) = cal.last_fetched {
|
||||
let local_time = last_fetched.with_timezone(&chrono::Local);
|
||||
html! {
|
||||
<span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}>
|
||||
{format!("{}", local_time.format("%H:%M"))}
|
||||
</span>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<span class="last-updated">{"Never"}</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
<button
|
||||
class="external-calendar-refresh-btn"
|
||||
title="Refresh calendar"
|
||||
onclick={{
|
||||
let on_refresh = props.on_external_calendar_refresh.clone();
|
||||
let cal_id = cal.id;
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_refresh.emit(cal_id);
|
||||
})
|
||||
}}
|
||||
>
|
||||
{"🔄"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
if *external_context_menu_open == Some(cal.id) {
|
||||
html! {
|
||||
<>
|
||||
<div
|
||||
class="context-menu-overlay"
|
||||
onclick={close_external_context_menu.clone()}
|
||||
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
|
||||
/>
|
||||
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
|
||||
<div
|
||||
class="context-menu-item"
|
||||
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
|
||||
onclick={{
|
||||
let on_delete = on_external_calendar_delete.clone();
|
||||
let cal_id = cal.id;
|
||||
Callback::from(move |_| {
|
||||
on_delete.emit(cal_id);
|
||||
})
|
||||
}}
|
||||
>
|
||||
{"Delete Calendar"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</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}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
@@ -17,6 +17,8 @@ pub struct WeekViewProps {
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[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)>>,
|
||||
@@ -81,8 +83,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
// Check external calendars first (path format: "external_{id}")
|
||||
if calendar_path.starts_with("external_") {
|
||||
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||
if let Some(external_calendar) = props.external_calendars
|
||||
.iter()
|
||||
.find(|cal| cal.id == id_str)
|
||||
{
|
||||
return external_calendar.color.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check regular calendars
|
||||
else if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar) = user_info
|
||||
.calendars
|
||||
.iter()
|
||||
@@ -371,6 +385,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
<div
|
||||
class="all-day-event"
|
||||
style={format!("background-color: {}", event_color)}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
>
|
||||
@@ -905,6 +920,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
column_width
|
||||
)
|
||||
}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
onmousedown={onmousedown_event}
|
||||
@@ -992,6 +1008,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
<div
|
||||
class="temp-event-box moving-event"
|
||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
>
|
||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||
{if duration_pixels > 30.0 {
|
||||
@@ -1025,6 +1042,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
<div
|
||||
class="temp-event-box resizing-event"
|
||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
>
|
||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||
{if new_height > 30.0 {
|
||||
@@ -1052,6 +1070,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
<div
|
||||
class="temp-event-box resizing-event"
|
||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
||||
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||
>
|
||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||
{if new_height > 30.0 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::JsCast;
|
||||
@@ -43,6 +44,7 @@ pub struct CalendarInfo {
|
||||
pub path: String,
|
||||
pub display_name: String,
|
||||
pub color: String,
|
||||
pub is_visible: bool,
|
||||
}
|
||||
|
||||
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
|
||||
@@ -1854,4 +1856,257 @@ impl CalendarService {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ==================== EXTERNAL CALENDAR METHODS ====================
|
||||
|
||||
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
|
||||
let token = LocalStorage::get::<String>("auth_token")
|
||||
.map_err(|_| "No authentication token found".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("GET");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let service = Self::new();
|
||||
let url = format!("{}/external-calendars", service.base_url);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP error: {}", resp.status()));
|
||||
}
|
||||
|
||||
let json = JsFuture::from(resp.json().unwrap())
|
||||
.await
|
||||
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||
|
||||
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
|
||||
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||
|
||||
Ok(external_calendars)
|
||||
}
|
||||
|
||||
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
|
||||
let token = LocalStorage::get::<String>("auth_token")
|
||||
.map_err(|_| "No authentication token found".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"color": color
|
||||
});
|
||||
|
||||
let service = Self::new();
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
opts.set_body(&body_string.into());
|
||||
|
||||
let url = format!("{}/external-calendars", service.base_url);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Content-Type", "application/json")
|
||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP error: {}", resp.status()));
|
||||
}
|
||||
|
||||
let json = JsFuture::from(resp.json().unwrap())
|
||||
.await
|
||||
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||
|
||||
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
|
||||
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||
|
||||
Ok(external_calendar)
|
||||
}
|
||||
|
||||
pub async fn update_external_calendar(
|
||||
id: i32,
|
||||
name: &str,
|
||||
url: &str,
|
||||
color: &str,
|
||||
is_visible: bool,
|
||||
) -> Result<(), String> {
|
||||
let token = LocalStorage::get::<String>("auth_token")
|
||||
.map_err(|_| "No authentication token found".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"color": color,
|
||||
"is_visible": is_visible
|
||||
});
|
||||
|
||||
let service = Self::new();
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
opts.set_body(&body_string.into());
|
||||
|
||||
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Content-Type", "application/json")
|
||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP error: {}", resp.status()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
|
||||
let token = LocalStorage::get::<String>("auth_token")
|
||||
.map_err(|_| "No authentication token found".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("DELETE");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let service = Self::new();
|
||||
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP error: {}", resp.status()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
|
||||
let token = LocalStorage::get::<String>("auth_token")
|
||||
.map_err(|_| "No authentication token found".to_string())?;
|
||||
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("GET");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let service = Self::new();
|
||||
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP error: {}", resp.status()));
|
||||
}
|
||||
|
||||
let json = JsFuture::from(resp.json().unwrap())
|
||||
.await
|
||||
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExternalCalendarEventsResponse {
|
||||
events: Vec<VEvent>,
|
||||
last_fetched: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
||||
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||
|
||||
Ok(response.events)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExternalCalendar {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub color: String,
|
||||
pub is_visible: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
@@ -3745,3 +3745,383 @@ body {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== EXTERNAL CALENDARS STYLES ==================== */
|
||||
|
||||
/* External Calendar Section in Sidebar */
|
||||
.external-calendar-list {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.external-calendar-list h3 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.external-calendar-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.external-calendar-info:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.external-calendar-info input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.external-calendar-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.external-calendar-name {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.external-calendar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.external-calendar-refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.external-calendar-refresh-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.external-calendar-indicator {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* CalDAV Calendar Styles */
|
||||
.calendar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-info:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-info input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Create External Calendar Button */
|
||||
.create-external-calendar-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(10px);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-external-calendar-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.create-external-calendar-button {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.create-external-calendar-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.create-external-calendar-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* External Calendar Modal */
|
||||
.external-calendar-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modalSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2rem 2rem 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-header h3 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-header h3::before {
|
||||
content: "📅";
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-close:hover {
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-group input[type="text"],
|
||||
.external-calendar-modal .form-group input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-group input[type="text"]:focus,
|
||||
.external-calendar-modal .form-group input[type="url"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #667eea);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-group input[type="color"] {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.external-calendar-modal .form-help {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn-primary {
|
||||
background: var(--primary-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.external-calendar-modal .btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.external-calendar-modal .error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
/* External Calendar Events (Visual Distinction) */
|
||||
.event[data-external="true"] {
|
||||
position: relative;
|
||||
border-style: dashed !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.event[data-external="true"]::before {
|
||||
content: "📅";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.external-calendar-modal {
|
||||
max-height: 95vh;
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.external-calendar-modal .modal-header,
|
||||
.external-calendar-modal .modal-body,
|
||||
.external-calendar-modal .modal-actions {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-info {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.external-calendar-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.create-external-calendar-button {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem 0.5rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user