Compare commits
4 Commits
b444ae710d
...
749ffaff58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749ffaff58 | ||
|
|
3440403bed | ||
|
|
5c966b2571 | ||
|
|
7e62e3b7e3 |
@@ -694,6 +694,233 @@ impl CalDAVClient {
|
||||
Err(CalDAVError::ServerError(status.as_u16()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new event in a CalDAV calendar
|
||||
pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> {
|
||||
// Generate a unique filename for the event (using UID + .ics extension)
|
||||
let event_filename = format!("{}.ics", event.uid);
|
||||
|
||||
// Construct the full URL for the event
|
||||
let full_url = if calendar_path.starts_with("http") {
|
||||
format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename)
|
||||
} else {
|
||||
// Handle URL construction more carefully
|
||||
let server_url = self.config.server_url.trim_end_matches('/');
|
||||
|
||||
// Remove /dav.php from the end of server URL if present
|
||||
let base_url = if server_url.ends_with("/dav.php") {
|
||||
server_url.trim_end_matches("/dav.php")
|
||||
} else {
|
||||
server_url
|
||||
};
|
||||
|
||||
// Calendar path should start with /dav.php, if not add it
|
||||
let clean_calendar_path = if calendar_path.starts_with("/dav.php") {
|
||||
calendar_path.trim_end_matches('/')
|
||||
} else {
|
||||
// This shouldn't happen in our case, but handle it
|
||||
&format!("/dav.php{}", calendar_path.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
format!("{}{}/{}", base_url, clean_calendar_path, event_filename)
|
||||
};
|
||||
|
||||
println!("📝 Creating event with calendar_path: {}", calendar_path);
|
||||
println!("📝 Server URL: {}", self.config.server_url);
|
||||
println!("📝 Constructed URL: {}", full_url);
|
||||
|
||||
// Generate iCalendar data for the event
|
||||
let ical_data = self.generate_ical_event(event)?;
|
||||
|
||||
println!("Creating event at: {}", full_url);
|
||||
println!("iCal data: {}", ical_data);
|
||||
|
||||
let response = self.http_client
|
||||
.put(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(ical_data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||
|
||||
println!("Event creation response status: {}", response.status());
|
||||
|
||||
if response.status().is_success() || response.status().as_u16() == 201 {
|
||||
println!("✅ Event created successfully at {}", event_filename);
|
||||
Ok(event_filename)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await.unwrap_or_default();
|
||||
println!("❌ Event creation failed: {} - {}", status, error_body);
|
||||
Err(CalDAVError::ServerError(status.as_u16()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate iCalendar data for a CalendarEvent
|
||||
fn generate_ical_event(&self, event: &CalendarEvent) -> Result<String, CalDAVError> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||
let format_datetime = |dt: &DateTime<Utc>| -> String {
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
};
|
||||
|
||||
let format_date = |dt: &DateTime<Utc>| -> String {
|
||||
dt.format("%Y%m%d").to_string()
|
||||
};
|
||||
|
||||
// Start building the iCal event
|
||||
let mut ical = String::new();
|
||||
ical.push_str("BEGIN:VCALENDAR\r\n");
|
||||
ical.push_str("VERSION:2.0\r\n");
|
||||
ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n");
|
||||
ical.push_str("BEGIN:VEVENT\r\n");
|
||||
|
||||
// Required fields
|
||||
ical.push_str(&format!("UID:{}\r\n", event.uid));
|
||||
ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now)));
|
||||
|
||||
// Start and end times
|
||||
if event.all_day {
|
||||
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.start)));
|
||||
if let Some(end) = &event.end {
|
||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
||||
}
|
||||
} else {
|
||||
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.start)));
|
||||
if let Some(end) = &event.end {
|
||||
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if let Some(summary) = &event.summary {
|
||||
ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary)));
|
||||
}
|
||||
|
||||
if let Some(description) = &event.description {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
||||
}
|
||||
|
||||
if let Some(location) = &event.location {
|
||||
ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location)));
|
||||
}
|
||||
|
||||
// Status
|
||||
let status_str = match event.status {
|
||||
EventStatus::Tentative => "TENTATIVE",
|
||||
EventStatus::Confirmed => "CONFIRMED",
|
||||
EventStatus::Cancelled => "CANCELLED",
|
||||
};
|
||||
ical.push_str(&format!("STATUS:{}\r\n", status_str));
|
||||
|
||||
// Classification
|
||||
let class_str = match event.class {
|
||||
EventClass::Public => "PUBLIC",
|
||||
EventClass::Private => "PRIVATE",
|
||||
EventClass::Confidential => "CONFIDENTIAL",
|
||||
};
|
||||
ical.push_str(&format!("CLASS:{}\r\n", class_str));
|
||||
|
||||
// Priority
|
||||
if let Some(priority) = event.priority {
|
||||
ical.push_str(&format!("PRIORITY:{}\r\n", priority));
|
||||
}
|
||||
|
||||
// Categories
|
||||
if !event.categories.is_empty() {
|
||||
let categories = event.categories.join(",");
|
||||
ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories)));
|
||||
}
|
||||
|
||||
// Creation and modification times
|
||||
if let Some(created) = &event.created {
|
||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created)));
|
||||
}
|
||||
|
||||
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
||||
|
||||
// Add alarms/reminders
|
||||
for reminder in &event.reminders {
|
||||
ical.push_str("BEGIN:VALARM\r\n");
|
||||
|
||||
let action = match reminder.action {
|
||||
ReminderAction::Display => "DISPLAY",
|
||||
ReminderAction::Email => "EMAIL",
|
||||
ReminderAction::Audio => "AUDIO",
|
||||
};
|
||||
ical.push_str(&format!("ACTION:{}\r\n", action));
|
||||
|
||||
// Convert minutes to ISO 8601 duration format
|
||||
let trigger = format!("-PT{}M", reminder.minutes_before);
|
||||
ical.push_str(&format!("TRIGGER:{}\r\n", trigger));
|
||||
|
||||
if let Some(description) = &reminder.description {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
||||
} else if let Some(summary) = &event.summary {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary)));
|
||||
}
|
||||
|
||||
ical.push_str("END:VALARM\r\n");
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\n");
|
||||
ical.push_str("END:VCALENDAR\r\n");
|
||||
|
||||
Ok(ical)
|
||||
}
|
||||
|
||||
/// Escape text for iCalendar format (RFC 5545)
|
||||
fn escape_ical_text(&self, text: &str) -> String {
|
||||
text.replace('\\', "\\\\")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "")
|
||||
.replace(',', "\\,")
|
||||
.replace(';', "\\;")
|
||||
}
|
||||
|
||||
/// Delete an event from a CalDAV calendar
|
||||
pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> {
|
||||
// Construct the full URL for the event
|
||||
let full_url = if event_href.starts_with("http") {
|
||||
event_href.to_string()
|
||||
} else if event_href.starts_with("/dav.php") {
|
||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
||||
format!("{}{}", base_url, event_href)
|
||||
} else {
|
||||
// Event href is just a filename, combine with calendar path
|
||||
let clean_path = if calendar_path.starts_with("/dav.php") {
|
||||
calendar_path.trim_start_matches("/dav.php")
|
||||
} else {
|
||||
calendar_path
|
||||
};
|
||||
format!("{}/dav.php{}{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
||||
};
|
||||
|
||||
println!("Deleting event at: {}", full_url);
|
||||
|
||||
let response = self.http_client
|
||||
.delete(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||
|
||||
println!("Event deletion response status: {}", response.status());
|
||||
|
||||
if response.status().is_success() || response.status().as_u16() == 204 {
|
||||
println!("✅ Event deleted successfully at {}", event_href);
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await.unwrap_or_default();
|
||||
println!("❌ Event deletion failed: {} - {}", status, error_body);
|
||||
Err(CalDAVError::ServerError(status.as_u16()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for extracting calendar data from XML responses
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use chrono::Datelike;
|
||||
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse}};
|
||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -323,4 +323,164 @@ pub async fn delete_calendar(
|
||||
success: true,
|
||||
message: "Calendar deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeleteEventRequest>,
|
||||
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
||||
println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}'", request.calendar_path, request.event_href);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// Validate request
|
||||
if request.calendar_path.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
||||
}
|
||||
if request.event_href.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event href is required".to_string()));
|
||||
}
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Delete the event
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponse {
|
||||
success: true,
|
||||
message: "Event deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateEventRequest>,
|
||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||
request.title, request.all_day, request.calendar_path);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// Validate request
|
||||
if request.title.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||
}
|
||||
|
||||
if request.title.len() > 200 {
|
||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
||||
}
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Determine which calendar to use
|
||||
let calendar_path = if let Some(path) = request.calendar_path {
|
||||
path
|
||||
} else {
|
||||
// Use the first available calendar
|
||||
let calendar_paths = client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
||||
}
|
||||
|
||||
calendar_paths[0].clone()
|
||||
};
|
||||
|
||||
// Parse dates and times
|
||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||
|
||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||
|
||||
// Validate that end is after start
|
||||
if end_datetime <= start_datetime {
|
||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
||||
}
|
||||
|
||||
// Generate a unique UID for the event
|
||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
||||
|
||||
// Create the CalendarEvent struct
|
||||
let event = crate::calendar::CalendarEvent {
|
||||
uid,
|
||||
summary: Some(request.title.clone()),
|
||||
description: if request.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
},
|
||||
start: start_datetime,
|
||||
end: Some(end_datetime),
|
||||
location: if request.location.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.location.clone())
|
||||
},
|
||||
status: crate::calendar::EventStatus::Confirmed,
|
||||
class: crate::calendar::EventClass::Public,
|
||||
priority: None,
|
||||
organizer: None,
|
||||
attendees: Vec::new(),
|
||||
categories: Vec::new(),
|
||||
created: Some(chrono::Utc::now()),
|
||||
last_modified: Some(chrono::Utc::now()),
|
||||
recurrence_rule: None,
|
||||
all_day: request.all_day,
|
||||
reminders: Vec::new(),
|
||||
etag: None,
|
||||
href: None,
|
||||
calendar_path: Some(calendar_path.clone()),
|
||||
};
|
||||
|
||||
// Create the event on the CalDAV server
|
||||
let event_href = client.create_event(&calendar_path, &event)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||
|
||||
Ok(Json(CreateEventResponse {
|
||||
success: true,
|
||||
message: "Event created successfully".to_string(),
|
||||
event_href: Some(event_href),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse date and time strings into a UTC DateTime
|
||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
||||
|
||||
// Parse the date
|
||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||
|
||||
if all_day {
|
||||
// For all-day events, use midnight UTC
|
||||
let datetime = date.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
||||
Ok(Utc.from_utc_datetime(&datetime))
|
||||
} else {
|
||||
// Parse the time
|
||||
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||
|
||||
// Combine date and time
|
||||
let datetime = NaiveDateTime::new(date, time);
|
||||
|
||||
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
||||
Ok(Utc.from_utc_datetime(&datetime))
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/api/calendar/create", post(handlers::create_calendar))
|
||||
.route("/api/calendar/delete", post(handlers::delete_calendar))
|
||||
.route("/api/calendar/events", get(handlers::get_calendar_events))
|
||||
.route("/api/calendar/events/create", post(handlers::create_event))
|
||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
|
||||
@@ -58,6 +58,38 @@ pub struct DeleteCalendarResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteEventRequest {
|
||||
pub calendar_path: String,
|
||||
pub event_href: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteEventResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEventRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateEventResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event_href: Option<String>, // The created event's href/filename
|
||||
}
|
||||
|
||||
// Error handling
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
|
||||
183
src/app.rs
183
src/app.rs
@@ -2,8 +2,9 @@ use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::MouseEvent;
|
||||
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, RouteHandler};
|
||||
use crate::services::{CalendarService, calendar_service::UserInfo};
|
||||
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler};
|
||||
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
|
||||
#[function_component]
|
||||
@@ -18,6 +19,14 @@ pub fn App() -> Html {
|
||||
let context_menu_open = use_state(|| false);
|
||||
let context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
||||
let event_context_menu_open = use_state(|| false);
|
||||
let event_context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let event_context_menu_event = use_state(|| -> Option<CalendarEvent> { None });
|
||||
let calendar_context_menu_open = use_state(|| false);
|
||||
let calendar_context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
|
||||
let create_event_modal_open = use_state(|| false);
|
||||
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
||||
|
||||
let available_colors = [
|
||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
|
||||
@@ -99,9 +108,13 @@ pub fn App() -> Html {
|
||||
let on_outside_click = {
|
||||
let color_picker_open = color_picker_open.clone();
|
||||
let context_menu_open = context_menu_open.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
color_picker_open.set(None);
|
||||
context_menu_open.set(false);
|
||||
event_context_menu_open.set(false);
|
||||
calendar_context_menu_open.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
@@ -148,6 +161,94 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_event_context_menu = {
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let event_context_menu_pos = event_context_menu_pos.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
Callback::from(move |(event, calendar_event): (MouseEvent, CalendarEvent)| {
|
||||
event_context_menu_open.set(true);
|
||||
event_context_menu_pos.set((event.client_x(), event.client_y()));
|
||||
event_context_menu_event.set(Some(calendar_event));
|
||||
})
|
||||
};
|
||||
|
||||
let on_calendar_date_context_menu = {
|
||||
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
||||
let calendar_context_menu_pos = calendar_context_menu_pos.clone();
|
||||
let calendar_context_menu_date = calendar_context_menu_date.clone();
|
||||
Callback::from(move |(event, date): (MouseEvent, NaiveDate)| {
|
||||
calendar_context_menu_open.set(true);
|
||||
calendar_context_menu_pos.set((event.client_x(), event.client_y()));
|
||||
calendar_context_menu_date.set(Some(date));
|
||||
})
|
||||
};
|
||||
|
||||
let on_create_event_click = {
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let selected_date_for_event = selected_date_for_event.clone();
|
||||
let calendar_context_menu_date = calendar_context_menu_date.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
create_event_modal_open.set(true);
|
||||
selected_date_for_event.set((*calendar_context_menu_date).clone());
|
||||
})
|
||||
};
|
||||
|
||||
let on_event_create = {
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
Callback::from(move |event_data: EventCreationData| {
|
||||
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
|
||||
create_event_modal_open.set(false);
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
// Get CalDAV password from storage
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Format date and time strings
|
||||
let start_date = event_data.start_date.format("%Y-%m-%d").to_string();
|
||||
let start_time = event_data.start_time.format("%H:%M").to_string();
|
||||
let end_date = event_data.end_date.format("%Y-%m-%d").to_string();
|
||||
let end_time = event_data.end_time.format("%H:%M").to_string();
|
||||
|
||||
match calendar_service.create_event(
|
||||
&token,
|
||||
&password,
|
||||
event_data.title,
|
||||
event_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
event_data.location,
|
||||
event_data.all_day,
|
||||
None // Let backend use first available calendar
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event created successfully".into());
|
||||
// Refresh the page to show the new event
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to create event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let refresh_calendars = {
|
||||
let auth_token = auth_token.clone();
|
||||
let user_info = user_info.clone();
|
||||
@@ -217,6 +318,8 @@ pub fn App() -> Html {
|
||||
auth_token={(*auth_token).clone()}
|
||||
user_info={(*user_info).clone()}
|
||||
on_login={on_login.clone()}
|
||||
on_event_context_menu={Some(on_event_context_menu.clone())}
|
||||
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
@@ -228,6 +331,8 @@ pub fn App() -> Html {
|
||||
auth_token={(*auth_token).clone()}
|
||||
user_info={(*user_info).clone()}
|
||||
on_login={on_login.clone()}
|
||||
on_event_context_menu={Some(on_event_context_menu.clone())}
|
||||
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -323,6 +428,80 @@ pub fn App() -> Html {
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<EventContextMenu
|
||||
is_open={*event_context_menu_open}
|
||||
x={event_context_menu_pos.0}
|
||||
y={event_context_menu_pos.1}
|
||||
on_close={Callback::from({
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
move |_| event_context_menu_open.set(false)
|
||||
})}
|
||||
on_delete={Callback::from({
|
||||
let auth_token = auth_token.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let refresh_calendars = refresh_calendars.clone();
|
||||
move |_: MouseEvent| {
|
||||
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
|
||||
let _refresh_calendars = refresh_calendars.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if let (Some(calendar_path), Some(event_href)) = (&event.calendar_path, &event.href) {
|
||||
match calendar_service.delete_event(&token, &password, calendar_path.clone(), event_href.clone()).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event deleted successfully!".into());
|
||||
// Close the context menu
|
||||
event_context_menu_open.set(false);
|
||||
// Force a page reload to refresh the calendar events
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
web_sys::console::log_1(&"Missing calendar_path or href - cannot delete event".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<CalendarContextMenu
|
||||
is_open={*calendar_context_menu_open}
|
||||
x={calendar_context_menu_pos.0}
|
||||
y={calendar_context_menu_pos.1}
|
||||
on_close={Callback::from({
|
||||
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
||||
move |_| calendar_context_menu_open.set(false)
|
||||
})}
|
||||
on_create_event={on_create_event_click}
|
||||
/>
|
||||
|
||||
<CreateEventModal
|
||||
is_open={*create_event_modal_open}
|
||||
selected_date={(*selected_date_for_event).clone()}
|
||||
on_close={Callback::from({
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
move |_| create_event_modal_open.set(false)
|
||||
})}
|
||||
on_create={on_event_create}
|
||||
/>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
use crate::components::EventModal;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarProps {
|
||||
@@ -13,6 +14,10 @@ pub struct CalendarProps {
|
||||
pub refreshing_event_uid: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
@@ -113,9 +118,34 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let on_click = Callback::from(move |_| {
|
||||
selected_day_clone.set(date);
|
||||
});
|
||||
|
||||
let on_context_menu = {
|
||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only show context menu if we're not right-clicking on an event
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(element) = target.dyn_into::<web_sys::Element>() {
|
||||
// Check if the click is on an event box or inside one
|
||||
let mut current = Some(element);
|
||||
while let Some(el) = current {
|
||||
if el.class_name().contains("event-box") {
|
||||
return; // Don't show calendar context menu on events
|
||||
}
|
||||
current = el.parent_element();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
if let Some(callback) = &on_calendar_context_menu {
|
||||
callback.emit((e, date));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!(classes)} onclick={on_click}>
|
||||
<div class={classes!(classes)} onclick={on_click} oncontextmenu={on_context_menu}>
|
||||
<div class="day-number">{day}</div>
|
||||
{
|
||||
if !events.is_empty() {
|
||||
@@ -131,6 +161,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
on_event_click.emit(event_clone.clone());
|
||||
selected_event_clone.set(Some(event_clone.clone()));
|
||||
});
|
||||
|
||||
let event_context_menu = {
|
||||
let event_clone = event.clone();
|
||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation();
|
||||
if let Some(callback) = &on_event_context_menu {
|
||||
callback.emit((e, event_clone.clone()));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let title = event.get_title();
|
||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||
@@ -140,6 +182,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
<div class={class_name}
|
||||
title={title.clone()}
|
||||
onclick={event_click}
|
||||
oncontextmenu={event_context_menu}
|
||||
style={format!("background-color: {}", event_color)}>
|
||||
{
|
||||
if is_refreshing {
|
||||
|
||||
47
src/components/calendar_context_menu.rs
Normal file
47
src/components/calendar_context_menu.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create_event: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarContextMenu)]
|
||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_create_event_click = {
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_create_event.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||
<span class="context-menu-icon">{"+"}</span>
|
||||
{"Create Event"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
323
src/components/create_event_modal.rs
Normal file
323
src/components/create_event_modal.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateEventModalProps {
|
||||
pub is_open: bool,
|
||||
pub selected_date: Option<NaiveDate>,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create: Callback<EventCreationData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct EventCreationData {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: NaiveDate,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_date: NaiveDate,
|
||||
pub end_time: NaiveTime,
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
}
|
||||
|
||||
impl Default for EventCreationData {
|
||||
fn default() -> Self {
|
||||
let now = chrono::Local::now().naive_local();
|
||||
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
|
||||
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
|
||||
|
||||
Self {
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
start_date: now.date(),
|
||||
start_time,
|
||||
end_date: now.date(),
|
||||
end_time,
|
||||
location: String::new(),
|
||||
all_day: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(CreateEventModal)]
|
||||
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||||
let event_data = use_state(|| EventCreationData::default());
|
||||
|
||||
// Initialize with selected date if provided
|
||||
use_effect_with((props.selected_date, props.is_open), {
|
||||
let event_data = event_data.clone();
|
||||
move |(selected_date, is_open)| {
|
||||
if *is_open {
|
||||
if let Some(date) = selected_date {
|
||||
let mut data = (*event_data).clone();
|
||||
data.start_date = *date;
|
||||
data.end_date = *date;
|
||||
event_data.set(data);
|
||||
} else {
|
||||
event_data.set(EventCreationData::default());
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_title_input = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut data = (*event_data).clone();
|
||||
data.title = input.value();
|
||||
event_data.set(data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_input = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
|
||||
let mut data = (*event_data).clone();
|
||||
data.description = textarea.value();
|
||||
event_data.set(data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_location_input = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut data = (*event_data).clone();
|
||||
data.location = input.value();
|
||||
event_data.set(data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_date_change = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||
let mut data = (*event_data).clone();
|
||||
data.start_date = date;
|
||||
event_data.set(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_time_change = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||
let mut data = (*event_data).clone();
|
||||
data.start_time = time;
|
||||
event_data.set(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_date_change = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||
let mut data = (*event_data).clone();
|
||||
data.end_date = date;
|
||||
event_data.set(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_time_change = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||
let mut data = (*event_data).clone();
|
||||
data.end_time = time;
|
||||
event_data.set(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_all_day_change = {
|
||||
let event_data = event_data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut data = (*event_data).clone();
|
||||
data.all_day = input.checked();
|
||||
event_data.set(data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_create_click = {
|
||||
let event_data = event_data.clone();
|
||||
let on_create = props.on_create.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_create.emit((*event_data).clone());
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let data = &*event_data;
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||
<div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||
<div class="modal-header">
|
||||
<h3>{"Create New Event"}</h3>
|
||||
<button type="button" class="modal-close" onclick={Callback::from({
|
||||
let on_close = props.on_close.clone();
|
||||
move |_: MouseEvent| on_close.emit(())
|
||||
})}>{"×"}</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="event-title">{"Title *"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-title"
|
||||
class="form-input"
|
||||
value={data.title.clone()}
|
||||
oninput={on_title_input}
|
||||
placeholder="Enter event title"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-description">{"Description"}</label>
|
||||
<textarea
|
||||
id="event-description"
|
||||
class="form-input"
|
||||
value={data.description.clone()}
|
||||
oninput={on_description_input}
|
||||
placeholder="Enter event description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.all_day}
|
||||
onchange={on_all_day_change}
|
||||
/>
|
||||
{" All Day"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="start-date">{"Start Date *"}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
class="form-input"
|
||||
value={data.start_date.format("%Y-%m-%d").to_string()}
|
||||
onchange={on_start_date_change}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
if !data.all_day {
|
||||
<div class="form-group">
|
||||
<label for="start-time">{"Start Time"}</label>
|
||||
<input
|
||||
type="time"
|
||||
id="start-time"
|
||||
class="form-input"
|
||||
value={data.start_time.format("%H:%M").to_string()}
|
||||
onchange={on_start_time_change}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="end-date">{"End Date *"}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
class="form-input"
|
||||
value={data.end_date.format("%Y-%m-%d").to_string()}
|
||||
onchange={on_end_date_change}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
if !data.all_day {
|
||||
<div class="form-group">
|
||||
<label for="end-time">{"End Time"}</label>
|
||||
<input
|
||||
type="time"
|
||||
id="end-time"
|
||||
class="form-input"
|
||||
value={data.end_time.format("%H:%M").to_string()}
|
||||
onchange={on_end_time_change}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-location">{"Location"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-location"
|
||||
class="form-input"
|
||||
value={data.location.clone()}
|
||||
oninput={on_location_input}
|
||||
placeholder="Enter event location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick={on_cancel_click}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={on_create_click}
|
||||
disabled={data.title.trim().is_empty()}
|
||||
>
|
||||
{"Create Event"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
47
src/components/event_context_menu.rs
Normal file
47
src/components/event_context_menu.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_delete: Callback<MouseEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(EventContextMenu)]
|
||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_delete_click = {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_delete.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete Event"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ pub mod calendar;
|
||||
pub mod event_modal;
|
||||
pub mod create_calendar_modal;
|
||||
pub mod context_menu;
|
||||
pub mod event_context_menu;
|
||||
pub mod calendar_context_menu;
|
||||
pub mod create_event_modal;
|
||||
pub mod sidebar;
|
||||
pub mod calendar_list_item;
|
||||
pub mod route_handler;
|
||||
@@ -12,6 +15,9 @@ pub use calendar::Calendar;
|
||||
pub use event_modal::EventModal;
|
||||
pub use create_calendar_modal::CreateCalendarModal;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use event_context_menu::EventContextMenu;
|
||||
pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use create_event_modal::{CreateEventModal, EventCreationData};
|
||||
pub use sidebar::Sidebar;
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use route_handler::RouteHandler;
|
||||
@@ -1,7 +1,7 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use crate::components::Login;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::services::calendar_service::{UserInfo, CalendarEvent};
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
@@ -18,6 +18,10 @@ pub struct RouteHandlerProps {
|
||||
pub auth_token: Option<String>,
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_login: Callback<String>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
}
|
||||
|
||||
#[function_component(RouteHandler)]
|
||||
@@ -25,12 +29,16 @@ 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 on_event_context_menu = props.on_event_context_menu.clone();
|
||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||
|
||||
html! {
|
||||
<Switch<Route> render={move |route| {
|
||||
let auth_token = auth_token.clone();
|
||||
let user_info = user_info.clone();
|
||||
let on_login = on_login.clone();
|
||||
let on_event_context_menu = on_event_context_menu.clone();
|
||||
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||
|
||||
match route {
|
||||
Route::Home => {
|
||||
@@ -49,7 +57,13 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
}
|
||||
Route::Calendar => {
|
||||
if auth_token.is_some() {
|
||||
html! { <CalendarView user_info={user_info} /> }
|
||||
html! {
|
||||
<CalendarView
|
||||
user_info={user_info}
|
||||
on_event_context_menu={on_event_context_menu}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! { <Redirect<Route> to={Route::Login}/> }
|
||||
}
|
||||
@@ -62,10 +76,14 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarViewProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
}
|
||||
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use crate::services::{CalendarService, CalendarEvent};
|
||||
use crate::services::CalendarService;
|
||||
use crate::components::Calendar;
|
||||
use std::collections::HashMap;
|
||||
use chrono::{Local, NaiveDate, Datelike};
|
||||
@@ -79,6 +97,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
|
||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||
|
||||
|
||||
let today = Local::now().date_naive();
|
||||
let current_year = today.year();
|
||||
let current_month = today.month();
|
||||
@@ -212,12 +231,26 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
html! {
|
||||
<div class="calendar-error">
|
||||
<p>{format!("Error: {}", err)}</p>
|
||||
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
|
||||
<Calendar
|
||||
events={HashMap::new()}
|
||||
on_event_click={dummy_callback}
|
||||
refreshing_event_uid={(*refreshing_event).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
|
||||
<Calendar
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click}
|
||||
refreshing_event_uid={(*refreshing_event).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +528,136 @@ impl CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an event from the CalDAV server
|
||||
pub async fn delete_event(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
calendar_path: String,
|
||||
event_href: String
|
||||
) -> Result<(), 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!({
|
||||
"calendar_path": calendar_path,
|
||||
"event_href": event_href
|
||||
});
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/calendar/events/delete", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
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("X-CalDAV-Password", password)
|
||||
.map_err(|e| format!("Password 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!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new event on the CalDAV server
|
||||
pub async fn create_event(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
title: String,
|
||||
description: String,
|
||||
start_date: String,
|
||||
start_time: String,
|
||||
end_date: String,
|
||||
end_time: String,
|
||||
location: String,
|
||||
all_day: bool,
|
||||
calendar_path: Option<String>
|
||||
) -> Result<(), 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!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"start_date": start_date,
|
||||
"start_time": start_time,
|
||||
"end_date": end_date,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"all_day": all_day,
|
||||
"calendar_path": calendar_path
|
||||
});
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/calendar/events/create", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
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("X-CalDAV-Password", password)
|
||||
.map_err(|e| format!("Password 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!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a calendar from the CalDAV server
|
||||
pub async fn delete_calendar(
|
||||
&self,
|
||||
|
||||
118
styles.css
118
styles.css
@@ -1169,4 +1169,122 @@ body {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Event Creation Modal Styles */
|
||||
.create-event-modal {
|
||||
max-width: 600px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.create-event-modal .modal-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.create-event-modal .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.create-event-modal .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.create-event-modal .form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.create-event-modal .form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.create-event-modal .form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-event-modal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
line-height: 1.5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile adjustments for event creation modal */
|
||||
@media (max-width: 768px) {
|
||||
.create-event-modal .form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-event-modal .modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.create-event-modal .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user