Compare commits
5 Commits
5b0e84121b
...
d945c46e5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d945c46e5a | ||
|
|
1c4857ccad | ||
|
|
01a21cb869 | ||
|
|
b1b8e1e580 | ||
|
|
a3d1612dac |
@@ -6,10 +6,14 @@ dist = "dist"
|
||||
BACKEND_API_URL = "http://localhost:3000/api"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "Cargo.toml"]
|
||||
watch = ["src", "Cargo.toml", "styles.css", "index.html"]
|
||||
ignore = ["backend/"]
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
open = false
|
||||
open = false
|
||||
|
||||
[[copy]]
|
||||
from = "styles.css"
|
||||
to = "dist/"
|
||||
BIN
backend/calendar.db
Normal file
BIN
backend/calendar.db
Normal file
Binary file not shown.
@@ -53,6 +53,9 @@ pub struct CalendarEvent {
|
||||
/// All-day event flag
|
||||
pub all_day: bool,
|
||||
|
||||
/// Reminders/alarms for this event
|
||||
pub reminders: Vec<EventReminder>,
|
||||
|
||||
/// ETag from CalDAV server for conflict detection
|
||||
pub etag: Option<String>,
|
||||
|
||||
@@ -88,6 +91,27 @@ impl Default for EventClass {
|
||||
}
|
||||
}
|
||||
|
||||
/// Event reminder/alarm information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EventReminder {
|
||||
/// How long before the event to trigger the reminder (in minutes)
|
||||
pub minutes_before: i32,
|
||||
|
||||
/// Type of reminder action
|
||||
pub action: ReminderAction,
|
||||
|
||||
/// Optional description for the reminder
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Reminder action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ReminderAction {
|
||||
Display,
|
||||
Email,
|
||||
Audio,
|
||||
}
|
||||
|
||||
/// CalDAV client for fetching and parsing calendar events
|
||||
pub struct CalDAVClient {
|
||||
config: crate::config::CalDAVConfig,
|
||||
@@ -168,6 +192,17 @@ impl CalDAVClient {
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Fetch a single calendar event by UID from the CalDAV server
|
||||
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
|
||||
// First fetch all events and find the one with matching UID
|
||||
let events = self.fetch_events(calendar_path).await?;
|
||||
|
||||
// Find event with matching UID
|
||||
let event = events.into_iter().find(|e| e.uid == uid);
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Extract calendar data sections from CalDAV XML response
|
||||
fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> {
|
||||
let mut sections = Vec::new();
|
||||
@@ -244,8 +279,8 @@ impl CalDAVClient {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the event
|
||||
for property in event.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default());
|
||||
for property in &event.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
||||
}
|
||||
|
||||
// Required UID field
|
||||
@@ -325,11 +360,82 @@ impl CalDAVClient {
|
||||
last_modified,
|
||||
recurrence_rule: properties.get("RRULE").cloned(),
|
||||
all_day,
|
||||
reminders: self.parse_alarms(&event)?,
|
||||
etag: None, // Set by caller
|
||||
href: None, // Set by caller
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse VALARM components from an iCal event
|
||||
fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<EventReminder>, CalDAVError> {
|
||||
let mut reminders = Vec::new();
|
||||
|
||||
for alarm in &event.alarms {
|
||||
if let Ok(reminder) = self.parse_single_alarm(alarm) {
|
||||
reminders.push(reminder);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(reminders)
|
||||
}
|
||||
|
||||
/// Parse a single VALARM component into an EventReminder
|
||||
fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, CalDAVError> {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the alarm
|
||||
for property in &alarm.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
||||
}
|
||||
|
||||
// Parse ACTION (required)
|
||||
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
||||
Some(ref action_str) if action_str == "DISPLAY" => ReminderAction::Display,
|
||||
Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email,
|
||||
Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio,
|
||||
_ => ReminderAction::Display, // Default
|
||||
};
|
||||
|
||||
// Parse TRIGGER (required)
|
||||
let minutes_before = if let Some(trigger) = properties.get("TRIGGER") {
|
||||
self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes
|
||||
} else {
|
||||
15 // Default 15 minutes
|
||||
};
|
||||
|
||||
// Get description
|
||||
let description = properties.get("DESCRIPTION").cloned();
|
||||
|
||||
Ok(EventReminder {
|
||||
minutes_before,
|
||||
action,
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a TRIGGER duration string into minutes before event
|
||||
fn parse_trigger_duration(&self, trigger: &str) -> Option<i32> {
|
||||
// Basic parsing of ISO 8601 duration or relative time
|
||||
// Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before)
|
||||
|
||||
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
||||
// Parse "-PT15M" format (minutes)
|
||||
let minutes_str = &trigger[3..trigger.len()-1];
|
||||
minutes_str.parse::<i32>().ok()
|
||||
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
||||
// Parse "-PT1H" format (hours)
|
||||
let hours_str = &trigger[3..trigger.len()-1];
|
||||
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
||||
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
||||
// Parse "-P1D" format (days)
|
||||
let days_str = &trigger[2..trigger.len()-1];
|
||||
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
||||
} else {
|
||||
// Try to parse as raw minutes
|
||||
trigger.parse::<i32>().ok().map(|m| m.abs())
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover available calendar collections on the server
|
||||
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
||||
// First, try to discover user calendars if we have a calendar path in config
|
||||
|
||||
61
backend/src/debug_caldav.rs
Normal file
61
backend/src/debug_caldav.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
|
||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = CalDAVConfig::from_env()?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
println!("=== DEBUG: CalDAV Fetch ===");
|
||||
|
||||
// Discover calendars
|
||||
let calendars = client.discover_calendars().await?;
|
||||
println!("Found {} calendars: {:?}", calendars.len(), calendars);
|
||||
|
||||
if let Some(calendar_path) = calendars.first() {
|
||||
println!("Fetching events from: {}", calendar_path);
|
||||
|
||||
// Make the raw REPORT request
|
||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT"/>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>"#;
|
||||
|
||||
let url = format!("{}{}", client.config.server_url.trim_end_matches('/'), calendar_path);
|
||||
println!("Request URL: {}", url);
|
||||
|
||||
let response = client.http_client
|
||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", client.config.get_basic_auth()))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "1")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(report_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
println!("Response status: {}", response.status());
|
||||
let body = response.text().await?;
|
||||
println!("Response body length: {}", body.len());
|
||||
println!("First 500 chars of response: {}", &body[..std::cmp::min(500, body.len())]);
|
||||
|
||||
// Try to parse it
|
||||
let events = client.parse_calendar_response(&body)?;
|
||||
println!("Parsed {} events", events.len());
|
||||
|
||||
for (i, event) in events.iter().enumerate() {
|
||||
println!("Event {}: {}", i+1, event.summary.as_deref().unwrap_or("No title"));
|
||||
println!(" Start: {}", event.start);
|
||||
println!(" UID: {}", event.uid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{State, Query},
|
||||
extract::{State, Query, Path},
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
@@ -73,6 +73,52 @@ pub async fn get_calendar_events(
|
||||
Ok(Json(filtered_events))
|
||||
}
|
||||
|
||||
pub async fn refresh_event(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Path(uid): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
||||
// Verify authentication (extract token from Authorization header)
|
||||
let _token = if let Some(auth_header) = headers.get("authorization") {
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
|
||||
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
auth_str.strip_prefix("Bearer ").unwrap().to_string()
|
||||
} else {
|
||||
return Err(ApiError::Unauthorized("Invalid authorization format".to_string()));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::Unauthorized("Missing authorization header".to_string()));
|
||||
};
|
||||
|
||||
// TODO: Validate JWT token here
|
||||
|
||||
// Load CalDAV configuration
|
||||
let config = CalDAVConfig::from_env()
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?;
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Discover calendars if needed
|
||||
let calendar_paths = client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Ok(Json(None)); // No calendars found
|
||||
}
|
||||
|
||||
// Fetch the specific event by UID from the first calendar
|
||||
let calendar_path = &calendar_paths[0];
|
||||
let event = client.fetch_event_by_uid(calendar_path, &uid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?;
|
||||
|
||||
Ok(Json(event))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<RegisterRequest>,
|
||||
|
||||
@@ -53,6 +53,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/api/auth/login", post(handlers::login))
|
||||
.route("/api/auth/verify", get(handlers::verify_token))
|
||||
.route("/api/calendar/events", get(handlers::get_calendar_events))
|
||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
431
index.html
431
index.html
@@ -4,436 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<title>Calendar App</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.app-header nav a:hover {
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Authentication Forms */
|
||||
.login-container, .register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.login-form, .register-form {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-form h2, .register-form h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-button, .register-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover, .register-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.login-button:disabled, .register-button:disabled {
|
||||
background: #ccc;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* Calendar View */
|
||||
.calendar-view {
|
||||
height: calc(100vh - 140px); /* Full height minus header and padding */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-loading, .calendar-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.calendar-loading p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.calendar-error p {
|
||||
font-size: 1.2rem;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Calendar Component */
|
||||
.calendar {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.month-year {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
flex: 1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 0.75rem;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.calendar-day.current-month {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.calendar-day.prev-month,
|
||||
.calendar-day.next-month {
|
||||
background: #fafafa;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background: #e3f2fd;
|
||||
border: 2px solid #2196f3;
|
||||
}
|
||||
|
||||
.calendar-day.has-events {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.calendar-day.today.has-events {
|
||||
background: #e1f5fe;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-day.today .day-number {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.event-indicators {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.event-box:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
background: #ff9800;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.more-events {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.calendar-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.month-year {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 70px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calendar-view {
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.calendar-day {
|
||||
min-height: 60px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-form, .register-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
61
src/app.rs
61
src/app.rs
@@ -2,7 +2,7 @@ use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use crate::components::{Login, Register, Calendar};
|
||||
use crate::services::CalendarService;
|
||||
use crate::services::{CalendarService, CalendarEvent};
|
||||
use std::collections::HashMap;
|
||||
use chrono::{Local, NaiveDate, Datelike};
|
||||
|
||||
@@ -107,9 +107,10 @@ pub fn App() -> Html {
|
||||
|
||||
#[function_component]
|
||||
fn CalendarView() -> Html {
|
||||
let events = use_state(|| HashMap::<NaiveDate, Vec<String>>::new());
|
||||
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let refreshing_event = use_state(|| None::<String>);
|
||||
|
||||
// Get current auth token
|
||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||
@@ -118,6 +119,57 @@ fn CalendarView() -> Html {
|
||||
let current_year = today.year();
|
||||
let current_month = today.month();
|
||||
|
||||
// Event refresh callback
|
||||
let on_event_click = {
|
||||
let events = events.clone();
|
||||
let refreshing_event = refreshing_event.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
|
||||
Callback::from(move |event: CalendarEvent| {
|
||||
if let Some(token) = auth_token.clone() {
|
||||
let events = events.clone();
|
||||
let refreshing_event = refreshing_event.clone();
|
||||
let uid = event.uid.clone();
|
||||
|
||||
refreshing_event.set(Some(uid.clone()));
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
match calendar_service.refresh_event(&token, &uid).await {
|
||||
Ok(Some(refreshed_event)) => {
|
||||
// Update the event in the existing events map
|
||||
let mut updated_events = (*events).clone();
|
||||
for (_, day_events) in updated_events.iter_mut() {
|
||||
for existing_event in day_events.iter_mut() {
|
||||
if existing_event.uid == uid {
|
||||
*existing_event = refreshed_event.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
events.set(updated_events);
|
||||
}
|
||||
Ok(None) => {
|
||||
// Event was deleted, remove it from the map
|
||||
let mut updated_events = (*events).clone();
|
||||
for (_, day_events) in updated_events.iter_mut() {
|
||||
day_events.retain(|e| e.uid != uid);
|
||||
}
|
||||
events.set(updated_events);
|
||||
}
|
||||
Err(_err) => {
|
||||
// Log error but don't show it to user - keep using cached event
|
||||
// Silently fall back to cached event data
|
||||
}
|
||||
}
|
||||
|
||||
refreshing_event.set(None);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Fetch events when component mounts
|
||||
{
|
||||
let events = events.clone();
|
||||
@@ -165,15 +217,16 @@ fn CalendarView() -> Html {
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
let dummy_callback = Callback::from(|_: CalendarEvent| {});
|
||||
html! {
|
||||
<div class="calendar-error">
|
||||
<p>{format!("Error: {}", err)}</p>
|
||||
<Calendar events={HashMap::new()} />
|
||||
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<Calendar events={(*events).clone()} />
|
||||
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use crate::services::calendar_service::CalendarEvent;
|
||||
use crate::components::EventModal;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
pub events: HashMap<NaiveDate, Vec<String>>,
|
||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
||||
pub on_event_click: Callback<CalendarEvent>,
|
||||
#[prop_or_default]
|
||||
pub refreshing_event_uid: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let today = Local::now().date_naive();
|
||||
let current_month = use_state(|| today);
|
||||
let selected_day = use_state(|| today);
|
||||
let selected_event = use_state(|| None::<CalendarEvent>);
|
||||
|
||||
let first_day_of_month = current_month.with_day(1).unwrap();
|
||||
let days_in_month = get_days_in_month(*current_month);
|
||||
@@ -71,18 +78,27 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
(1..=days_in_month).map(|day| {
|
||||
let date = current_month.with_day(day).unwrap();
|
||||
let is_today = date == today;
|
||||
let is_selected = date == *selected_day;
|
||||
let events = props.events.get(&date).cloned().unwrap_or_default();
|
||||
|
||||
let mut classes = vec!["calendar-day", "current-month"];
|
||||
if is_today {
|
||||
classes.push("today");
|
||||
}
|
||||
if is_selected {
|
||||
classes.push("selected");
|
||||
}
|
||||
if !events.is_empty() {
|
||||
classes.push("has-events");
|
||||
}
|
||||
|
||||
let selected_day_clone = selected_day.clone();
|
||||
let on_click = Callback::from(move |_| {
|
||||
selected_day_clone.set(date);
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class={classes!(classes)}>
|
||||
<div class={classes!(classes)} onclick={on_click}>
|
||||
<div class="day-number">{day}</div>
|
||||
{
|
||||
if !events.is_empty() {
|
||||
@@ -90,13 +106,29 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
<div class="event-indicators">
|
||||
{
|
||||
events.iter().take(2).map(|event| {
|
||||
let event_clone = event.clone();
|
||||
let selected_event_clone = selected_event.clone();
|
||||
let on_event_click = props.on_event_click.clone();
|
||||
let event_click = Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation(); // Prevent day selection
|
||||
on_event_click.emit(event_clone.clone());
|
||||
selected_event_clone.set(Some(event_clone.clone()));
|
||||
});
|
||||
|
||||
let title = event.get_title();
|
||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
|
||||
html! {
|
||||
<div class="event-box" title={event.clone()}>
|
||||
<div class={class_name}
|
||||
title={title.clone()}
|
||||
onclick={event_click}>
|
||||
{
|
||||
if event.len() > 15 {
|
||||
format!("{}...", &event[..12])
|
||||
if is_refreshing {
|
||||
"🔄 Refreshing...".to_string()
|
||||
} else if title.len() > 15 {
|
||||
format!("{}...", &title[..12])
|
||||
} else {
|
||||
event.clone()
|
||||
title
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -123,6 +155,17 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
|
||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||
</div>
|
||||
|
||||
// Event details modal
|
||||
<EventModal
|
||||
event={(*selected_event).clone()}
|
||||
on_close={{
|
||||
let selected_event_clone = selected_event.clone();
|
||||
Callback::from(move |_| {
|
||||
selected_event_clone.set(None);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -193,4 +236,5 @@ fn get_month_name(month: u32) -> &'static str {
|
||||
12 => "December",
|
||||
_ => "Invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
289
src/components/event_modal.rs
Normal file
289
src/components/event_modal.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventModalProps {
|
||||
pub event: Option<CalendarEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn EventModal(props: &EventModalProps) -> Html {
|
||||
let close_modal = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(ref event) = props.event {
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={backdrop_click}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{"Event Details"}</h3>
|
||||
<button class="modal-close" onclick={close_modal}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="event-detail">
|
||||
<strong>{"Title:"}</strong>
|
||||
<span>{event.get_title()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref description) = event.description {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Description:"}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Start:"}</strong>
|
||||
<span>{format_datetime(&event.start, event.all_day)}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref end) = event.end {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"End:"}</strong>
|
||||
<span>{format_datetime(end, event.all_day)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"All Day:"}</strong>
|
||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref location) = event.location {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Location:"}</strong>
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Status:"}</strong>
|
||||
<span>{event.get_status_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Privacy:"}</strong>
|
||||
<span>{event.get_class_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Priority:"}</strong>
|
||||
<span>{event.get_priority_display()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref organizer) = event.organizer {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Organizer:"}</strong>
|
||||
<span>{organizer}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.attendees.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Attendees:"}</strong>
|
||||
<span>{event.attendees.join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.categories.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Categories:"}</strong>
|
||||
<span>{event.categories.join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref recurrence) = event.recurrence_rule {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{format_recurrence_rule(recurrence)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{"No"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.reminders.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{format_reminders(&event.reminders)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{"None"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref created) = event.created {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Created:"}</strong>
|
||||
<span>{format_datetime(created, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref modified) = event.last_modified {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Last Modified:"}</strong>
|
||||
<span>{format_datetime(modified, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||
if all_day {
|
||||
dt.format("%B %d, %Y").to_string()
|
||||
} else {
|
||||
dt.format("%B %d, %Y at %I:%M %p").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_recurrence_rule(rrule: &str) -> String {
|
||||
// Basic parsing of RRULE to display user-friendly text
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
"Daily".to_string()
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
"Weekly".to_string()
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
"Monthly".to_string()
|
||||
} else if rrule.contains("FREQ=YEARLY") {
|
||||
"Yearly".to_string()
|
||||
} else {
|
||||
// Show the raw rule if we can't parse it
|
||||
format!("Custom ({})", rrule)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reminders(reminders: &[EventReminder]) -> String {
|
||||
if reminders.is_empty() {
|
||||
return "None".to_string();
|
||||
}
|
||||
|
||||
let formatted_reminders: Vec<String> = reminders
|
||||
.iter()
|
||||
.map(|reminder| {
|
||||
let time_text = if reminder.minutes_before == 0 {
|
||||
"At event time".to_string()
|
||||
} else if reminder.minutes_before < 60 {
|
||||
format!("{} minutes before", reminder.minutes_before)
|
||||
} else if reminder.minutes_before == 60 {
|
||||
"1 hour before".to_string()
|
||||
} else if reminder.minutes_before % 60 == 0 {
|
||||
format!("{} hours before", reminder.minutes_before / 60)
|
||||
} else if reminder.minutes_before < 1440 {
|
||||
let hours = reminder.minutes_before / 60;
|
||||
let minutes = reminder.minutes_before % 60;
|
||||
format!("{}h {}m before", hours, minutes)
|
||||
} else if reminder.minutes_before == 1440 {
|
||||
"1 day before".to_string()
|
||||
} else if reminder.minutes_before % 1440 == 0 {
|
||||
format!("{} days before", reminder.minutes_before / 1440)
|
||||
} else {
|
||||
let days = reminder.minutes_before / 1440;
|
||||
let remaining_minutes = reminder.minutes_before % 1440;
|
||||
let hours = remaining_minutes / 60;
|
||||
let minutes = remaining_minutes % 60;
|
||||
if hours > 0 {
|
||||
format!("{}d {}h before", days, hours)
|
||||
} else if minutes > 0 {
|
||||
format!("{}d {}m before", days, minutes)
|
||||
} else {
|
||||
format!("{} days before", days)
|
||||
}
|
||||
};
|
||||
|
||||
let action_text = match reminder.action {
|
||||
ReminderAction::Display => "notification",
|
||||
ReminderAction::Email => "email",
|
||||
ReminderAction::Audio => "sound",
|
||||
};
|
||||
|
||||
format!("{} ({})", time_text, action_text)
|
||||
})
|
||||
.collect();
|
||||
|
||||
formatted_reminders.join(", ")
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod calendar;
|
||||
pub mod event_modal;
|
||||
|
||||
pub use login::Login;
|
||||
pub use register::Register;
|
||||
pub use calendar::Calendar;
|
||||
pub use calendar::Calendar;
|
||||
pub use event_modal::EventModal;
|
||||
@@ -5,7 +5,27 @@ use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EventReminder {
|
||||
pub minutes_before: i32,
|
||||
pub action: ReminderAction,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ReminderAction {
|
||||
Display,
|
||||
Email,
|
||||
Audio,
|
||||
}
|
||||
|
||||
impl Default for ReminderAction {
|
||||
fn default() -> Self {
|
||||
ReminderAction::Display
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CalendarEvent {
|
||||
pub uid: String,
|
||||
pub summary: Option<String>,
|
||||
@@ -13,8 +33,45 @@ pub struct CalendarEvent {
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub status: String,
|
||||
pub status: EventStatus,
|
||||
pub class: EventClass,
|
||||
pub priority: Option<u8>,
|
||||
pub organizer: Option<String>,
|
||||
pub attendees: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub last_modified: Option<DateTime<Utc>>,
|
||||
pub recurrence_rule: Option<String>,
|
||||
pub all_day: bool,
|
||||
pub reminders: Vec<EventReminder>,
|
||||
pub etag: Option<String>,
|
||||
pub href: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventStatus {
|
||||
Tentative,
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for EventStatus {
|
||||
fn default() -> Self {
|
||||
EventStatus::Confirmed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventClass {
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
impl Default for EventClass {
|
||||
fn default() -> Self {
|
||||
EventClass::Public
|
||||
}
|
||||
}
|
||||
|
||||
impl CalendarEvent {
|
||||
@@ -31,6 +88,38 @@ impl CalendarEvent {
|
||||
pub fn get_title(&self) -> String {
|
||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
||||
}
|
||||
|
||||
/// Get display string for status
|
||||
pub fn get_status_display(&self) -> &'static str {
|
||||
match self.status {
|
||||
EventStatus::Tentative => "Tentative",
|
||||
EventStatus::Confirmed => "Confirmed",
|
||||
EventStatus::Cancelled => "Cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display string for class
|
||||
pub fn get_class_display(&self) -> &'static str {
|
||||
match self.class {
|
||||
EventClass::Public => "Public",
|
||||
EventClass::Private => "Private",
|
||||
EventClass::Confidential => "Confidential",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display string for priority
|
||||
pub fn get_priority_display(&self) -> String {
|
||||
match self.priority {
|
||||
None => "Not set".to_string(),
|
||||
Some(0) => "Undefined".to_string(),
|
||||
Some(1) => "High".to_string(),
|
||||
Some(p) if p <= 4 => "High".to_string(),
|
||||
Some(5) => "Medium".to_string(),
|
||||
Some(p) if p <= 8 => "Low".to_string(),
|
||||
Some(9) => "Low".to_string(),
|
||||
Some(p) => format!("Priority {}", p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CalendarService {
|
||||
@@ -91,18 +180,56 @@ impl CalendarService {
|
||||
}
|
||||
|
||||
/// Convert events to a HashMap grouped by date for calendar display
|
||||
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<String>> {
|
||||
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
|
||||
let mut grouped = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
let date = event.get_date();
|
||||
let title = event.get_title();
|
||||
|
||||
grouped.entry(date)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(title);
|
||||
.push(event);
|
||||
}
|
||||
|
||||
grouped
|
||||
}
|
||||
|
||||
/// Refresh a single event by UID from the CalDAV server
|
||||
pub async fn refresh_event(&self, token: &str, uid: &str) -> Result<Option<CalendarEvent>, 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 url = format!("{}/calendar/events/{}", self.base_url, uid);
|
||||
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!("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() {
|
||||
let event: Option<CalendarEvent> = serde_json::from_str(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod calendar_service;
|
||||
|
||||
pub use calendar_service::CalendarService;
|
||||
pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction};
|
||||
589
styles.css
Normal file
589
styles.css
Normal file
@@ -0,0 +1,589 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.app-header nav a:hover {
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Authentication Forms */
|
||||
.login-container, .register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.login-form, .register-form {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-form h2, .register-form h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-button, .register-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover, .register-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.login-button:disabled, .register-button:disabled {
|
||||
background: #ccc;
|
||||
transform: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.auth-links a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
/* Calendar View */
|
||||
.calendar-view {
|
||||
height: calc(100vh - 140px); /* Full height minus header and padding */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-loading, .calendar-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.calendar-loading p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.calendar-error p {
|
||||
font-size: 1.2rem;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Calendar Component */
|
||||
.calendar {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.month-year {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
flex: 1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 0.75rem;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.calendar-day.current-month {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.calendar-day.prev-month,
|
||||
.calendar-day.next-month {
|
||||
background: #fafafa;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background: #e3f2fd;
|
||||
border: 2px solid #2196f3;
|
||||
}
|
||||
|
||||
.calendar-day.has-events {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.calendar-day.today.has-events {
|
||||
background: #e1f5fe;
|
||||
}
|
||||
|
||||
.calendar-day.selected {
|
||||
background: #e8f5e8;
|
||||
border: 2px solid #4caf50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.calendar-day.selected.has-events {
|
||||
background: #f1f8e9;
|
||||
}
|
||||
|
||||
.calendar-day.selected.today {
|
||||
background: #e0f2f1;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
|
||||
.calendar-day.selected .day-number {
|
||||
color: #2e7d32;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-day.today .day-number {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.event-indicators {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.event-box {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.event-box:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.event-box.refreshing {
|
||||
background: #ff9800;
|
||||
animation: pulse 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
background: #ff9800;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.more-events {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Event Modal Styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
animation: modalAppear 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.8rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.event-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.event-detail strong {
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.event-detail span {
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.calendar-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.month-year {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 70px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calendar-view {
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.calendar-day {
|
||||
min-height: 60px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile adjustments for modal */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.modal-header, .modal-body {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.event-detail {
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.event-detail strong {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.event-detail span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-form, .register-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
1
test_backend_url.js
Normal file
1
test_backend_url.js
Normal file
@@ -0,0 +1 @@
|
||||
console.log("Backend URL test");
|
||||
Reference in New Issue
Block a user