Compare commits
13 Commits
feature/mo
...
bugfix/wee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
970b0a07da | ||
|
|
e2e5813b54 | ||
|
|
73567c185c | ||
| 0587762bbb | |||
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
| 4d2aad404b | |||
|
|
0453763c98 | ||
|
|
03c0011445 | ||
|
|
79f287ed61 | ||
|
|
e55e6bf4dd | ||
| 1fa3bf44b6 |
@@ -38,4 +38,4 @@ calendar.db
|
|||||||
**/tests/
|
**/tests/
|
||||||
|
|
||||||
# Migrations (not needed for builds)
|
# Migrations (not needed for builds)
|
||||||
migrations/
|
migrations/
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,3 +22,9 @@ dist/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
calendar.db
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@@ -47,6 +47,9 @@ FROM rust:alpine AS backend-builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||||
|
|
||||||
|
# Install sqlx-cli for migrations
|
||||||
|
RUN cargo install sqlx-cli --no-default-features --features sqlite
|
||||||
|
|
||||||
# Copy shared models
|
# Copy shared models
|
||||||
COPY calendar-models ./calendar-models
|
COPY calendar-models ./calendar-models
|
||||||
|
|
||||||
@@ -76,19 +79,29 @@ RUN cargo build --release --bin backend
|
|||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||||
|
|
||||||
# Copy frontend files to temporary location
|
# Copy frontend files to temporary location
|
||||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||||
|
|
||||||
# Copy backend binary (built in workspace root)
|
# Copy backend binary and sqlx-cli
|
||||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||||
|
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||||
|
|
||||||
# Create startup script to copy frontend files to shared volume
|
# Copy migrations for database setup
|
||||||
RUN mkdir -p /srv/www
|
COPY --from=backend-builder /app/backend/migrations /migrations
|
||||||
|
|
||||||
|
# Create startup script to copy frontend files, run migrations, and start backend
|
||||||
|
RUN mkdir -p /srv/www /db
|
||||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
|
||||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
||||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||||
chmod +x /usr/local/bin/start.sh
|
chmod +x /usr/local/bin/start.sh
|
||||||
|
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -29,6 +29,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
||||||
- **Timezone Aware**: Proper local time display with UTC storage
|
- **Timezone Aware**: Proper local time display with UTC storage
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- **Persistent Preferences**: Settings sync across devices and sessions
|
||||||
|
- **Remember Me**: Optional server/username remembering for convenience
|
||||||
|
- **Session Management**: Secure session tokens with automatic expiry
|
||||||
|
- **Cross-Device Sync**: User preferences stored in database, not just browser
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Frontend (Yew WebAssembly)
|
### Frontend (Yew WebAssembly)
|
||||||
@@ -40,7 +46,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
### Backend (Axum)
|
### Backend (Axum)
|
||||||
- **Framework**: Axum async web framework with CORS support
|
- **Framework**: Axum async web framework with CORS support
|
||||||
- **Authentication**: JWT token management and validation
|
- **Authentication**: SQLite-backed session management with JWT tokens
|
||||||
|
- **Database**: SQLite for user preferences and session storage
|
||||||
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
||||||
- **API Design**: RESTful endpoints following calendar operation patterns
|
- **API Design**: RESTful endpoints following calendar operation patterns
|
||||||
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
||||||
@@ -54,12 +61,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Docker Deployment (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run the calendar is using Docker Compose:
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd calendar
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the application**:
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access the application** at `http://localhost`
|
||||||
|
|
||||||
|
The Docker setup includes:
|
||||||
|
- **Automatic database migrations** on startup
|
||||||
|
- **Persistent data storage** in `./data/db/` volume
|
||||||
|
- **Frontend served via Caddy** on port 80
|
||||||
|
- **Backend API** accessible on port 3000
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
- Rust (latest stable version)
|
- Rust (latest stable version)
|
||||||
- Trunk (`cargo install trunk`)
|
- Trunk (`cargo install trunk`)
|
||||||
|
|
||||||
### Development Setup
|
#### Local Development
|
||||||
|
|
||||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||||
```bash
|
```bash
|
||||||
@@ -73,6 +104,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
3. **Access the application** at `http://localhost:8080`
|
3. **Access the application** at `http://localhost:8080`
|
||||||
|
|
||||||
|
#### Database Setup
|
||||||
|
|
||||||
|
For local development, run the database migrations:
|
||||||
|
```bash
|
||||||
|
# Install sqlx-cli if not already installed
|
||||||
|
cargo install sqlx-cli --features sqlite
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
|
||||||
|
```
|
||||||
|
|
||||||
### Building for Production
|
### Building for Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ base64 = "0.21"
|
|||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
|
|
||||||
|
# Database dependencies
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||||
|
tokio-rusqlite = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
|||||||
8
backend/migrations/001_create_users_table.sql
Normal file
8
backend/migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
server_url TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(username, server_url)
|
||||||
|
);
|
||||||
16
backend/migrations/002_create_sessions_table.sql
Normal file
16
backend/migrations/002_create_sessions_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Create sessions table
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_accessed TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster token lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
|
|
||||||
|
-- Index for cleanup of expired sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Create user preferences table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
calendar_selected_date TEXT,
|
||||||
|
calendar_time_increment INTEGER,
|
||||||
|
calendar_view_mode TEXT,
|
||||||
|
calendar_theme TEXT,
|
||||||
|
calendar_colors TEXT, -- JSON string for calendar color mappings
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
2
backend/migrations/004_add_style_preference.sql
Normal file
2
backend/migrations/004_add_style_preference.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add calendar style preference to user preferences
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||||
|
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthService {
|
pub struct AuthService {
|
||||||
jwt_secret: String,
|
jwt_secret: String,
|
||||||
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
pub fn new(jwt_secret: String) -> Self {
|
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||||
Self { jwt_secret }
|
Self { jwt_secret, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate user directly against CalDAV server
|
/// Authenticate user directly against CalDAV server
|
||||||
@@ -31,36 +34,74 @@ impl AuthService {
|
|||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
|
|
||||||
// Create CalDAV config with provided credentials
|
// Create CalDAV config with provided credentials
|
||||||
let caldav_config = CalDAVConfig {
|
let caldav_config = CalDAVConfig::new(
|
||||||
server_url: request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
username: request.username.clone(),
|
request.username.clone(),
|
||||||
password: request.password.clone(),
|
request.password.clone(),
|
||||||
calendar_path: None,
|
);
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
println!(
|
||||||
// Authentication successful, generate JWT token
|
"✅ Authentication successful! Found {} calendars",
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
calendars.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find or create user in database
|
||||||
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
let user = user_repo
|
||||||
|
.find_or_create(&request.username, &request.server_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||||
|
|
||||||
|
// Create session in database
|
||||||
|
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
session_repo
|
||||||
|
.create(&session)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||||
|
|
||||||
|
// Get or create user preferences
|
||||||
|
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||||
|
let preferences = prefs_repo
|
||||||
|
.get_or_create(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
Ok(AuthResponse {
|
Ok(AuthResponse {
|
||||||
token,
|
token: jwt_token,
|
||||||
|
session_token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
server_url: request.server_url,
|
server_url: request.server_url,
|
||||||
|
preferences: UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,16 +112,18 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
Ok(CalDAVConfig::new(
|
||||||
server_url: claims.server_url,
|
claims.server_url,
|
||||||
username: claims.username,
|
claims.username,
|
||||||
password: password.to_string(),
|
password.to_string(),
|
||||||
calendar_path: None,
|
))
|
||||||
tasks_path: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||||
@@ -97,8 +140,11 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -135,4 +181,33 @@ impl AuthService {
|
|||||||
|
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Validate session token
|
||||||
|
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
let session = session_repo
|
||||||
|
.find_by_token(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
|
||||||
|
|
||||||
|
if session.is_expired() {
|
||||||
|
return Err(ApiError::Unauthorized("Session expired".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user by deleting session
|
||||||
|
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
session_repo
|
||||||
|
.delete(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,132 +1,97 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
/// This struct holds all the necessary information to connect to a CalDAV server,
|
||||||
/// including server URL, credentials, and optional collection paths.
|
/// including server URL, credentials, and optional collection paths.
|
||||||
///
|
///
|
||||||
/// # Security Note
|
/// # Security Note
|
||||||
///
|
///
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
/// The password field contains sensitive information and should be handled carefully.
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
/// This struct implements `Debug` but in production, consider implementing a custom
|
||||||
/// `Debug` that masks the password field.
|
/// `Debug` that masks the password field.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
|
/// let config = CalDAVConfig {
|
||||||
/// // Load configuration from environment variables
|
/// server_url: "https://caldav.example.com".to_string(),
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
/// username: "user@example.com".to_string(),
|
||||||
///
|
/// password: "password".to_string(),
|
||||||
|
/// calendar_path: None,
|
||||||
|
/// tasks_path: None,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
/// // Use the configuration for HTTP requests
|
/// // Use the configuration for HTTP requests
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CalDAVConfig {
|
pub struct CalDAVConfig {
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
/// Username for authentication with the CalDAV server
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
/// Password for authentication with the CalDAV server
|
||||||
///
|
///
|
||||||
/// **Security Note**: This contains sensitive information
|
/// **Security Note**: This contains sensitive information
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
/// Optional path to the calendar collection on the server
|
||||||
///
|
///
|
||||||
/// If not provided, the client will need to discover available calendars
|
/// If not provided, the client will discover available calendars
|
||||||
/// through CalDAV PROPFIND requests
|
/// through CalDAV PROPFIND requests
|
||||||
pub calendar_path: Option<String>,
|
pub calendar_path: Option<String>,
|
||||||
|
|
||||||
/// Optional path to the tasks/todo collection on the server
|
|
||||||
///
|
|
||||||
/// Some CalDAV servers store tasks separately from calendar events
|
|
||||||
pub tasks_path: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalDAVConfig {
|
impl CalDAVConfig {
|
||||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
/// Creates a new CalDAVConfig with the given credentials.
|
||||||
///
|
///
|
||||||
/// This method will attempt to load a `.env` file from the current directory
|
/// # Arguments
|
||||||
/// and then read the following required environment variables:
|
///
|
||||||
///
|
/// * `server_url` - The base URL of the CalDAV server
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
/// * `username` - Username for authentication
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
/// * `password` - Password for authentication
|
||||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
///
|
||||||
///
|
|
||||||
/// Optional environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
|
||||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
|
||||||
/// is not set or cannot be read.
|
|
||||||
///
|
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig::new(
|
||||||
/// match CalDAVConfig::from_env() {
|
/// "https://caldav.example.com".to_string(),
|
||||||
/// Ok(config) => {
|
/// "user@example.com".to_string(),
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
/// "password".to_string()
|
||||||
/// }
|
/// );
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
Self {
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
let server_url = env::var("CALDAV_SERVER_URL")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
|
||||||
|
|
||||||
let username = env::var("CALDAV_USERNAME")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
|
||||||
|
|
||||||
let password = env::var("CALDAV_PASSWORD")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
|
||||||
|
|
||||||
// Optional paths - it's fine if these are not set
|
|
||||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
|
||||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
|
||||||
server_url,
|
server_url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
calendar_path,
|
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||||
tasks_path,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||||
///
|
///
|
||||||
/// This method combines the username and password in the format
|
/// This method combines the username and password in the format
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
/// `username:password` and encodes it using Base64, which is the
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
/// standard format for the `Authorization: Basic` HTTP header.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A Base64-encoded string that can be used directly in the
|
/// A Base64-encoded string that can be used directly in the
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
///
|
||||||
/// let config = CalDAVConfig {
|
/// let config = CalDAVConfig {
|
||||||
/// server_url: "https://example.com".to_string(),
|
/// server_url: "https://example.com".to_string(),
|
||||||
/// username: "user".to_string(),
|
/// username: "user".to_string(),
|
||||||
@@ -134,7 +99,7 @@ impl CalDAVConfig {
|
|||||||
/// calendar_path: None,
|
/// calendar_path: None,
|
||||||
/// tasks_path: None,
|
/// tasks_path: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// let auth_value = config.get_basic_auth();
|
/// let auth_value = config.get_basic_auth();
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
/// let auth_header = format!("Basic {}", auth_value);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -148,15 +113,15 @@ impl CalDAVConfig {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
/// A required environment variable is missing or cannot be read.
|
/// A required environment variable is missing or cannot be read.
|
||||||
///
|
///
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
/// or `CALDAV_PASSWORD`) is not set.
|
||||||
#[error("Missing environment variable: {0}")]
|
#[error("Missing environment variable: {0}")]
|
||||||
MissingVar(String),
|
MissingVar(String),
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
/// The configuration contains invalid or malformed values.
|
||||||
///
|
///
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
/// This could include malformed URLs, invalid authentication credentials,
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
/// or other configuration issues that prevent proper CalDAV operation.
|
||||||
#[error("Invalid configuration: {0}")]
|
#[error("Invalid configuration: {0}")]
|
||||||
@@ -174,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -183,18 +147,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
/// Integration test that authenticates with the actual Baikal CalDAV server
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file with:
|
/// This test requires a valid .env file with:
|
||||||
/// - CALDAV_SERVER_URL
|
/// - CALDAV_SERVER_URL
|
||||||
/// - CALDAV_USERNAME
|
/// - CALDAV_USERNAME
|
||||||
/// - CALDAV_PASSWORD
|
/// - CALDAV_PASSWORD
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
/// Run with: `cargo test test_baikal_auth`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_baikal_auth() {
|
async fn test_baikal_auth() {
|
||||||
// Load config from .env
|
// Use test config - update these values to test with real server
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
|
|
||||||
@@ -204,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -222,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,14 +202,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
/// Test making a PROPFIND request to discover calendars
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
/// Run with: `cargo test test_propfind_calendars`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_propfind_calendars() {
|
async fn test_propfind_calendars() {
|
||||||
let config = CalDAVConfig::from_env()
|
// Use test config - update these values to test with real server
|
||||||
.expect("Failed to load CalDAV config from environment");
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -255,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -267,7 +247,7 @@ mod tests {
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("PROPFIND Response status: {}", status);
|
println!("PROPFIND Response status: {}", status);
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
let body = response.text().await.expect("Failed to read response body");
|
||||||
println!("PROPFIND Response body: {}", body);
|
println!("PROPFIND Response body: {}", body);
|
||||||
|
|
||||||
@@ -279,8 +259,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
309
backend/src/db.rs
Normal file
309
backend/src/db.rs
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||||
|
use sqlx::{FromRow, Result};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Database connection pool wrapper
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
pool: Arc<SqlitePool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// Create a new database connection pool
|
||||||
|
pub async fn new(database_url: &str) -> Result<Self> {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
pool: Arc::new(pool),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the connection pool
|
||||||
|
pub fn pool(&self) -> &SqlitePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User model representing a CalDAV user
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String, // UUID as string for SQLite
|
||||||
|
pub username: String,
|
||||||
|
pub server_url: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
/// Create a new user with generated UUID
|
||||||
|
pub fn new(username: String, server_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
username,
|
||||||
|
server_url,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session model for user sessions
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String, // UUID as string
|
||||||
|
pub user_id: String, // Foreign key to User
|
||||||
|
pub token: String, // Session token
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub last_accessed: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Create a new session for a user
|
||||||
|
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
user_id,
|
||||||
|
token,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + chrono::Duration::hours(expires_in_hours),
|
||||||
|
last_accessed: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the session has expired
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User preferences model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct UserPreferences {
|
||||||
|
pub user_id: String,
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>, // JSON string
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserPreferences {
|
||||||
|
/// Create default preferences for a new user
|
||||||
|
pub fn default_for_user(user_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id,
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: Some(15),
|
||||||
|
calendar_view_mode: Some("month".to_string()),
|
||||||
|
calendar_theme: Some("light".to_string()),
|
||||||
|
calendar_style: Some("default".to_string()),
|
||||||
|
calendar_colors: None,
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for User operations
|
||||||
|
pub struct UserRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UserRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find or create a user by username and server URL
|
||||||
|
pub async fn find_or_create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
server_url: &str,
|
||||||
|
) -> Result<User> {
|
||||||
|
// Try to find existing user
|
||||||
|
let existing = sqlx::query_as::<_, User>(
|
||||||
|
"SELECT * FROM users WHERE username = ? AND server_url = ?",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(server_url)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(user) = existing {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
let user = User::new(username.to_string(), server_url.to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&user.username)
|
||||||
|
.bind(&user.server_url)
|
||||||
|
.bind(&user.created_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user by ID
|
||||||
|
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for Session operations
|
||||||
|
pub struct SessionRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SessionRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session
|
||||||
|
pub async fn create(&self, session: &Session) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&session.id)
|
||||||
|
.bind(&session.user_id)
|
||||||
|
.bind(&session.token)
|
||||||
|
.bind(&session.created_at)
|
||||||
|
.bind(&session.expires_at)
|
||||||
|
.bind(&session.last_accessed)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a session by token and update last_accessed
|
||||||
|
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
|
||||||
|
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
if !s.is_expired() {
|
||||||
|
// Update last_accessed time
|
||||||
|
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(&s.id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a session (logout)
|
||||||
|
pub async fn delete(&self, token: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired sessions
|
||||||
|
pub async fn cleanup_expired(&self) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for UserPreferences operations
|
||||||
|
pub struct PreferencesRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PreferencesRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user preferences, creating defaults if not exist
|
||||||
|
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
|
||||||
|
let existing = sqlx::query_as::<_, UserPreferences>(
|
||||||
|
"SELECT * FROM user_preferences WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(prefs) = existing {
|
||||||
|
Ok(prefs)
|
||||||
|
} else {
|
||||||
|
// Create default preferences
|
||||||
|
let prefs = UserPreferences::default_for_user(user_id.to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_preferences
|
||||||
|
(user_id, calendar_selected_date, calendar_time_increment,
|
||||||
|
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&prefs.user_id)
|
||||||
|
.bind(&prefs.calendar_selected_date)
|
||||||
|
.bind(&prefs.calendar_time_increment)
|
||||||
|
.bind(&prefs.calendar_view_mode)
|
||||||
|
.bind(&prefs.calendar_theme)
|
||||||
|
.bind(&prefs.calendar_style)
|
||||||
|
.bind(&prefs.calendar_colors)
|
||||||
|
.bind(&prefs.updated_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user preferences
|
||||||
|
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE user_preferences
|
||||||
|
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||||
|
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
||||||
|
calendar_colors = ?, updated_at = ?
|
||||||
|
WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&prefs.calendar_selected_date)
|
||||||
|
.bind(&prefs.calendar_time_increment)
|
||||||
|
.bind(&prefs.calendar_view_mode)
|
||||||
|
.bind(&prefs.calendar_theme)
|
||||||
|
.bind(&prefs.calendar_style)
|
||||||
|
.bind(&prefs.calendar_colors)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(&prefs.user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
|
|||||||
use crate::config::CalDAVConfig;
|
use crate::config::CalDAVConfig;
|
||||||
|
|
||||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = CalDAVConfig::from_env()?;
|
// Use debug/test configuration
|
||||||
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"debug_user".to_string(),
|
||||||
|
"debug_password".to_string()
|
||||||
|
);
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
println!("=== DEBUG: CalDAV Fetch ===");
|
println!("=== DEBUG: CalDAV Fetch ===");
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod calendar;
|
mod calendar;
|
||||||
mod events;
|
mod events;
|
||||||
|
mod preferences;
|
||||||
mod series;
|
mod series;
|
||||||
|
|
||||||
pub use auth::{login, verify_token, get_user_info};
|
pub use auth::{get_user_info, login, verify_token};
|
||||||
pub use calendar::{create_calendar, delete_calendar};
|
pub use calendar::{create_calendar, delete_calendar};
|
||||||
pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
|
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||||
pub use series::{create_event_series, update_event_series, delete_event_series};
|
pub use preferences::{get_preferences, logout, update_preferences};
|
||||||
|
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
use crate::config::CalDAVConfig;
|
use crate::{
|
||||||
|
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let auth_header = headers.get("authorization")
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
let auth_str = auth_header.to_str()
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||||
|
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Authorization header must be Bearer token".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let password_header = headers.get("x-caldav-password")
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
password_header.to_str()
|
password_header
|
||||||
|
.to_str()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
}
|
}
|
||||||
@@ -40,39 +44,13 @@ pub async fn login(
|
|||||||
println!(" Server URL: {}", request.server_url);
|
println!(" Server URL: {}", request.server_url);
|
||||||
println!(" Username: {}", request.username);
|
println!(" Username: {}", request.username);
|
||||||
println!(" Password length: {}", request.password.len());
|
println!(" Password length: {}", request.password.len());
|
||||||
|
|
||||||
// Basic validation
|
// Use the auth service login method which now handles database, sessions, and preferences
|
||||||
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
let response = state.auth_service.login(request).await?;
|
||||||
return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string()));
|
|
||||||
}
|
println!("✅ Login successful with session management");
|
||||||
|
|
||||||
println!("✅ Input validation passed");
|
Ok(Json(response))
|
||||||
|
|
||||||
// Create a token using the auth service
|
|
||||||
println!("📝 Created CalDAV config");
|
|
||||||
|
|
||||||
// First verify the credentials are valid by attempting to discover calendars
|
|
||||||
let config = CalDAVConfig {
|
|
||||||
server_url: request.server_url.clone(),
|
|
||||||
username: request.username.clone(),
|
|
||||||
password: request.password.clone(),
|
|
||||||
calendar_path: None,
|
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
let client = CalDAVClient::new(config);
|
|
||||||
client.discover_calendars()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
|
||||||
|
|
||||||
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
|
|
||||||
|
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token,
|
|
||||||
username: request.username,
|
|
||||||
server_url: request.server_url,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify_token(
|
pub async fn verify_token(
|
||||||
@@ -81,7 +59,7 @@ pub async fn verify_token(
|
|||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,26 +69,33 @@ pub async fn get_user_info(
|
|||||||
) -> Result<Json<UserInfo>, ApiError> {
|
) -> Result<Json<UserInfo>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config.clone());
|
let client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
|
calendar_paths.len()
|
||||||
CalendarInfo {
|
);
|
||||||
|
|
||||||
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| CalendarInfo {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
display_name: extract_calendar_name(path),
|
display_name: extract_calendar_name(path),
|
||||||
color: generate_calendar_color(path),
|
color: generate_calendar_color(path),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(UserInfo {
|
Ok(Json(UserInfo {
|
||||||
username: config.username,
|
username: config.username,
|
||||||
server_url: config.server_url,
|
server_url: config.server_url,
|
||||||
@@ -125,15 +110,14 @@ fn generate_calendar_color(path: &str) -> String {
|
|||||||
for byte in path.bytes() {
|
for byte in path.bytes() {
|
||||||
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a set of pleasant colors
|
// Define a set of pleasant colors
|
||||||
let colors = [
|
let colors = [
|
||||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
"#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1",
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||||
"#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626",
|
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||||
"#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
colors[(hash as usize) % colors.len()].to_string()
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,4 +140,4 @@ fn extract_calendar_name(path: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -20,22 +22,36 @@ pub async fn create_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.name.trim().is_empty() {
|
if request.name.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar name is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Create calendar on CalDAV server
|
// Create calendar on CalDAV server
|
||||||
match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await {
|
match client
|
||||||
|
.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => Ok(Json(CreateCalendarResponse {
|
Ok(_) => Ok(Json(CreateCalendarResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Calendar created successfully".to_string(),
|
message: "Calendar created successfully".to_string(),
|
||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to create calendar: {}", e);
|
eprintln!("Failed to create calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to create calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to create calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,11 +66,15 @@ pub async fn delete_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.path.trim().is_empty() {
|
if request.path.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Delete calendar on CalDAV server
|
// Delete calendar on CalDAV server
|
||||||
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to delete calendar: {}", e);
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to delete calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Query, Path},
|
extract::{Path, Query, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use chrono::Datelike;
|
|
||||||
|
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
|
|
||||||
use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
|
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
||||||
|
UpdateEventRequest, UpdateEventResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -28,20 +36,23 @@ pub async fn get_calendar_events(
|
|||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
println!("🔑 API call with password length: {}", password.len());
|
println!("🔑 API call with password length: {}", password.len());
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars if needed
|
// Discover calendars if needed
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Ok(Json(vec![])); // No calendars found
|
return Ok(Json(vec![])); // No calendars found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch events from all calendars
|
// Fetch events from all calendars
|
||||||
let mut all_events = Vec::new();
|
let mut all_events = Vec::new();
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
@@ -54,12 +65,15 @@ pub async fn get_calendar_events(
|
|||||||
all_events.extend(events);
|
all_events.extend(events);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
// Continue with other calendars instead of failing completely
|
// Continue with other calendars instead of failing completely
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If year and month are specified, filter events
|
// If year and month are specified, filter events
|
||||||
if let (Some(year), Some(month)) = (params.year, params.month) {
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
||||||
all_events.retain(|event| {
|
all_events.retain(|event| {
|
||||||
@@ -68,7 +82,7 @@ pub async fn get_calendar_events(
|
|||||||
event_year == year && event_month == month
|
event_year == year && event_month == month
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("📅 Returning {} events", all_events.len());
|
println!("📅 Returning {} events", all_events.len());
|
||||||
Ok(Json(all_events))
|
Ok(Json(all_events))
|
||||||
}
|
}
|
||||||
@@ -80,16 +94,19 @@ pub async fn refresh_event(
|
|||||||
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
// Search for the event by UID across all calendars
|
// Search for the event by UID across all calendars
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
||||||
@@ -97,18 +114,25 @@ pub async fn refresh_event(
|
|||||||
return Ok(Json(Some(event)));
|
return Ok(Json(Some(event)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(None))
|
Ok(Json(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
async fn fetch_event_by_href(
|
||||||
|
client: &CalDAVClient,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||||
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
||||||
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
||||||
let events = client.fetch_events(calendar_path).await?;
|
let events = client.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
||||||
println!("🔍 Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>());
|
println!(
|
||||||
|
"🔍 Available events with hrefs: {:?}",
|
||||||
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
// First try to match by exact href
|
// First try to match by exact href
|
||||||
for event in &events {
|
for event in &events {
|
||||||
if let Some(stored_href) = &event.href {
|
if let Some(stored_href) = &event.href {
|
||||||
@@ -118,22 +142,25 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try to match by UID extracted from href filename
|
// Fallback: try to match by UID extracted from href filename
|
||||||
let filename = event_href.split('/').last().unwrap_or(event_href);
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||||
let uid_from_href = filename.trim_end_matches(".ics");
|
let uid_from_href = filename.trim_end_matches(".ics");
|
||||||
|
|
||||||
println!("🔍 Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href);
|
println!(
|
||||||
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
||||||
|
filename, uid_from_href
|
||||||
|
);
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == uid_from_href {
|
if event.uid == uid_from_href {
|
||||||
println!("✅ Found matching event by UID: {}", event.uid);
|
println!("✅ Found matching event by UID: {}", event.uid);
|
||||||
return Ok(Some(event));
|
return Ok(Some(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("❌ No matching event found for href: {}", event_href);
|
println!("❌ No matching event found for href: {}", event_href);
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,41 +173,63 @@ pub async fn delete_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Handle different delete actions for recurring events
|
// Handle different delete actions for recurring events
|
||||||
match request.delete_action.as_str() {
|
match request.delete_action.as_str() {
|
||||||
"delete_this" => {
|
"delete_this" => {
|
||||||
if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let exception_utc = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_utc);
|
||||||
|
|
||||||
println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid);
|
println!(
|
||||||
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
||||||
|
updated_event.uid
|
||||||
|
);
|
||||||
|
|
||||||
// Update the event with the new EXDATE
|
// Update the event with the new EXDATE
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(
|
||||||
|
&request.calendar_path,
|
||||||
|
&updated_event,
|
||||||
|
&request.event_href,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with EXDATE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully updated recurring event with EXDATE");
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Single occurrence deleted successfully".to_string(),
|
message: "Single occurrence deleted successfully".to_string(),
|
||||||
@@ -191,13 +240,16 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
// Non-recurring event - delete the entire event
|
// Non-recurring event - delete the entire event
|
||||||
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully deleted non-recurring event");
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
@@ -206,70 +258,99 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_following" => {
|
"delete_following" => {
|
||||||
// For "this and following" deletion, we need to:
|
// For "this and following" deletion, we need to:
|
||||||
// 1. Fetch the recurring event
|
// 1. Fetch the recurring event
|
||||||
// 2. Modify the RRULE to end before this occurrence
|
// 2. Modify the RRULE to end before this occurrence
|
||||||
// 3. Update the event
|
// 3. Update the event
|
||||||
|
|
||||||
if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(mut event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let until_date = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
||||||
|
occurrence_date
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modify the RRULE to add an UNTIL clause
|
// Modify the RRULE to add an UNTIL clause
|
||||||
if let Some(rrule) = &event.rrule {
|
if let Some(rrule) = &event.rrule {
|
||||||
// Remove existing UNTIL if present and add new one
|
// Remove existing UNTIL if present and add new one
|
||||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
let parts: Vec<&str> = rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.split(';')
|
||||||
}).collect();
|
.filter(|part| {
|
||||||
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||||
let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ"));
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let new_rrule = format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
until_date.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
event.rrule = Some(new_rrule);
|
event.rrule = Some(new_rrule);
|
||||||
|
|
||||||
// Update the event with the modified RRULE
|
// Update the event with the modified RRULE
|
||||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with modified RRULE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "This and following occurrences deleted successfully".to_string(),
|
message: "This and following occurrences deleted successfully"
|
||||||
|
.to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
// No RRULE, just delete the single event
|
// No RRULE, just delete the single event
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Occurrence date is required for following deletion".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_series" | _ => {
|
"delete_series" | _ => {
|
||||||
// Delete the entire event/series
|
// Delete the entire event/series
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
@@ -283,9 +364,11 @@ pub async fn create_event(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateEventRequest>,
|
Json(request): Json<CreateEventRequest>,
|
||||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
println!(
|
||||||
request.title, request.all_day, request.calendar_path);
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||||
|
request.title, request.all_day, request.calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
@@ -294,13 +377,17 @@ pub async fn create_event(
|
|||||||
if request.title.trim().is_empty() {
|
if request.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Determine which calendar to use
|
// Determine which calendar to use
|
||||||
@@ -308,31 +395,41 @@ pub async fn create_event(
|
|||||||
path
|
path
|
||||||
} else {
|
} else {
|
||||||
// Use the first available calendar
|
// Use the first available calendar
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
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)
|
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)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique UID for the event
|
// Generate a unique UID for the event
|
||||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = match request.status.to_lowercase().as_str() {
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
@@ -352,7 +449,8 @@ pub async fn create_event(
|
|||||||
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.attendees
|
request
|
||||||
|
.attendees
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -363,7 +461,8 @@ pub async fn create_event(
|
|||||||
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.categories
|
request
|
||||||
|
.categories
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -399,10 +498,11 @@ pub async fn create_event(
|
|||||||
"WEEKLY" => {
|
"WEEKLY" => {
|
||||||
// Handle weekly recurrence with optional BYDAY parameter
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
let mut rrule = "FREQ=WEEKLY".to_string();
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
if request.recurrence_days.len() == 7 {
|
if request.recurrence_days.len() == 7 {
|
||||||
let selected_days: Vec<&str> = request.recurrence_days
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| {
|
.filter_map(|(i, &selected)| {
|
||||||
@@ -416,20 +516,20 @@ pub async fn create_event(
|
|||||||
5 => "FR", // Friday
|
5 => "FR", // Friday
|
||||||
6 => "SA", // Saturday
|
6 => "SA", // Saturday
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !selected_days.is_empty() {
|
if !selected_days.is_empty() {
|
||||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(rrule)
|
Some(rrule)
|
||||||
},
|
}
|
||||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -439,15 +539,27 @@ pub async fn create_event(
|
|||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant)
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.status = Some(status);
|
event.status = Some(status);
|
||||||
event.class = Some(class);
|
event.class = Some(class);
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
event.organizer = if request.organizer.trim().is_empty() {
|
event.organizer = if request.organizer.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(CalendarUser {
|
Some(CalendarUser {
|
||||||
cal_address: request.organizer,
|
cal_address: request.organizer,
|
||||||
common_name: None,
|
common_name: None,
|
||||||
@@ -456,41 +568,53 @@ pub async fn create_event(
|
|||||||
language: None,
|
language: None,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
event.attendees = attendees.into_iter().map(|email| Attendee {
|
event.attendees = attendees
|
||||||
cal_address: email,
|
.into_iter()
|
||||||
common_name: None,
|
.map(|email| Attendee {
|
||||||
role: None,
|
cal_address: email,
|
||||||
part_stat: None,
|
common_name: None,
|
||||||
rsvp: None,
|
role: None,
|
||||||
cu_type: None,
|
part_stat: None,
|
||||||
member: Vec::new(),
|
rsvp: None,
|
||||||
delegated_to: Vec::new(),
|
cu_type: None,
|
||||||
delegated_from: Vec::new(),
|
member: Vec::new(),
|
||||||
sent_by: None,
|
delegated_to: Vec::new(),
|
||||||
dir_entry_ref: None,
|
delegated_from: Vec::new(),
|
||||||
language: None,
|
sent_by: None,
|
||||||
}).collect();
|
dir_entry_ref: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.categories = categories;
|
event.categories = categories;
|
||||||
event.rrule = rrule;
|
event.rrule = rrule;
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
|
event.alarms = alarms
|
||||||
action: AlarmAction::Display,
|
.into_iter()
|
||||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
|
.map(|reminder| VAlarm {
|
||||||
duration: None,
|
action: AlarmAction::Display,
|
||||||
repeat: None,
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
||||||
description: reminder.description,
|
-reminder.minutes_before as i64,
|
||||||
summary: None,
|
)),
|
||||||
attendees: Vec::new(),
|
duration: None,
|
||||||
attach: Vec::new(),
|
repeat: None,
|
||||||
}).collect();
|
description: reminder.description,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.calendar_path = Some(calendar_path.clone());
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
let event_href = client.create_event(&calendar_path, &event)
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CreateEventResponse {
|
Ok(Json(CreateEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -505,7 +629,7 @@ pub async fn update_event(
|
|||||||
Json(request): Json<UpdateEventRequest>,
|
Json(request): Json<UpdateEventRequest>,
|
||||||
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
||||||
// Handle update request
|
// Handle update request
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
@@ -514,37 +638,45 @@ pub async fn update_event(
|
|||||||
if request.uid.trim().is_empty() {
|
if request.uid.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.trim().is_empty() {
|
if request.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Find the event across all calendars (or in the specified calendar)
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
vec![path.clone()]
|
vec![path.clone()]
|
||||||
} else {
|
} else {
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
||||||
|
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
match client.fetch_events(calendar_path).await {
|
match client.fetch_events(calendar_path).await {
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == request.uid {
|
if event.uid == request.uid {
|
||||||
// Use the actual href from the event, or generate one if missing
|
// Use the actual href from the event, or generate one if missing
|
||||||
let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid));
|
let event_href = event
|
||||||
|
.href
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
||||||
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
found_event = Some((event, calendar_path.clone(), event_href));
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
break;
|
break;
|
||||||
@@ -553,9 +685,12 @@ pub async fn update_event(
|
|||||||
if found_event.is_some() {
|
if found_event.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,23 +700,38 @@ pub async fn update_event(
|
|||||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
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)
|
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)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title)
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
|
|
||||||
// Parse and update status
|
// Parse and update status
|
||||||
@@ -601,11 +751,15 @@ pub async fn update_event(
|
|||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href);
|
println!(
|
||||||
client.update_event(&calendar_path, &event, &event_href)
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
|
event.uid, calendar_path, event_href
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.update_event(&calendar_path, &event, &event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Successfully updated event {}", event.uid);
|
println!("✅ Successfully updated event {}", event.uid);
|
||||||
|
|
||||||
Ok(Json(UpdateEventResponse {
|
Ok(Json(UpdateEventResponse {
|
||||||
@@ -614,27 +768,32 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
fn parse_event_datetime(
|
||||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use midnight UTC
|
// For all-day events, use midnight UTC
|
||||||
let datetime = date.and_hms_opt(0, 0, 0)
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
} else {
|
} else {
|
||||||
// Parse the time
|
// Parse the time
|
||||||
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
// Combine date and time
|
// Combine date and time
|
||||||
let datetime = NaiveDateTime::new(date, time);
|
let datetime = NaiveDateTime::new(date, time);
|
||||||
|
|
||||||
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
backend/src/handlers/preferences.rs
Normal file
128
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::PreferencesRepository,
|
||||||
|
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get user preferences
|
||||||
|
pub async fn get_preferences(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Validate session and get user ID
|
||||||
|
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||||
|
|
||||||
|
// Get preferences from database
|
||||||
|
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||||
|
let preferences = prefs_repo
|
||||||
|
.get_or_create(&user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user preferences
|
||||||
|
pub async fn update_preferences(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<UpdatePreferencesRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Validate session and get user ID
|
||||||
|
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||||
|
|
||||||
|
// Update preferences in database
|
||||||
|
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||||
|
|
||||||
|
let mut preferences = prefs_repo
|
||||||
|
.get_or_create(&user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
|
// Update only provided fields
|
||||||
|
if request.calendar_selected_date.is_some() {
|
||||||
|
preferences.calendar_selected_date = request.calendar_selected_date;
|
||||||
|
}
|
||||||
|
if request.calendar_time_increment.is_some() {
|
||||||
|
preferences.calendar_time_increment = request.calendar_time_increment;
|
||||||
|
}
|
||||||
|
if request.calendar_view_mode.is_some() {
|
||||||
|
preferences.calendar_view_mode = request.calendar_view_mode;
|
||||||
|
}
|
||||||
|
if request.calendar_theme.is_some() {
|
||||||
|
preferences.calendar_theme = request.calendar_theme;
|
||||||
|
}
|
||||||
|
if request.calendar_style.is_some() {
|
||||||
|
preferences.calendar_style = request.calendar_style;
|
||||||
|
}
|
||||||
|
if request.calendar_colors.is_some() {
|
||||||
|
preferences.calendar_colors = request.calendar_colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs_repo
|
||||||
|
.update(&preferences)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
state.auth_service.logout(session_token).await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Logged out successfully"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,33 +3,43 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod models;
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth_service: AuthService,
|
pub auth_service: AuthService,
|
||||||
|
pub db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
println!("🚀 Starting Calendar Backend Server");
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||||
|
|
||||||
|
let db = Database::new(&database_url).await?;
|
||||||
|
println!("✅ Database initialized");
|
||||||
|
|
||||||
// Create auth service
|
// Create auth service
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
let auth_service = AuthService::new(jwt_secret);
|
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||||
|
|
||||||
let app_state = AppState { auth_service };
|
let app_state = AppState { auth_service, db };
|
||||||
|
|
||||||
// Build our application with routes
|
// Build our application with routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -46,9 +56,22 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(handlers::delete_event_series))
|
post(handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(handlers::delete_event_series),
|
||||||
|
)
|
||||||
|
// User preferences endpoints
|
||||||
|
.route("/api/preferences", get(handlers::get_preferences))
|
||||||
|
.route("/api/preferences", post(handlers::update_preferences))
|
||||||
|
.route("/api/auth/logout", post(handlers::logout))
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -60,7 +83,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Start server
|
// Start server
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("📡 Server listening on http://0.0.0.0:3000");
|
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -76,4 +99,4 @@ async fn health_check() -> Json<serde_json::Value> {
|
|||||||
"service": "calendar-backend",
|
"service": "calendar-backend",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ use calendar_backend::*;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
run_server().await
|
run_server().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,30 @@ pub struct CalDAVLoginRequest {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdatePreferencesRequest {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -76,21 +98,21 @@ pub struct DeleteEventResponse {
|
|||||||
pub struct CreateEventRequest {
|
pub struct CreateEventRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,24 +125,24 @@ pub struct CreateEventResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventRequest {
|
pub struct UpdateEventRequest {
|
||||||
pub uid: String, // Event UID to identify which event to update
|
pub uid: String, // Event UID to identify which event to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -139,22 +161,22 @@ pub struct UpdateEventResponse {
|
|||||||
pub struct CreateEventSeriesRequest {
|
pub struct CreateEventSeriesRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -173,33 +195,33 @@ pub struct CreateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventSeriesRequest {
|
pub struct UpdateEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to update
|
pub series_uid: String, // Series UID to identify which series to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub recurrence_count: Option<u32>, // Number of occurrences
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
|
||||||
// Update scope control
|
// Update scope control
|
||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
}
|
}
|
||||||
@@ -214,12 +236,12 @@ pub struct UpdateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeleteEventSeriesRequest {
|
pub struct DeleteEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to delete
|
pub series_uid: String, // Series UID to identify which series to delete
|
||||||
pub calendar_path: String,
|
pub calendar_path: String,
|
||||||
pub event_href: String,
|
pub event_href: String,
|
||||||
|
|
||||||
// Delete scope control
|
// Delete scope control
|
||||||
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,4 +296,4 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
use calendar_backend::AppState;
|
|
||||||
use calendar_backend::auth::AuthService;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
use calendar_backend::auth::AuthService;
|
||||||
|
use calendar_backend::AppState;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
/// Test utilities for integration testing
|
/// Test utilities for integration testing
|
||||||
mod test_utils {
|
mod test_utils {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub struct TestServer {
|
pub struct TestServer {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestServer {
|
impl TestServer {
|
||||||
pub async fn start() -> Self {
|
pub async fn start() -> Self {
|
||||||
// Create auth service
|
// Create auth service
|
||||||
@@ -33,19 +33,55 @@ mod test_utils {
|
|||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||||
.route("/api/auth/verify", get(calendar_backend::handlers::verify_token))
|
.route(
|
||||||
.route("/api/user/info", get(calendar_backend::handlers::get_user_info))
|
"/api/auth/verify",
|
||||||
.route("/api/calendar/create", post(calendar_backend::handlers::create_calendar))
|
get(calendar_backend::handlers::verify_token),
|
||||||
.route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar))
|
)
|
||||||
.route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events))
|
.route(
|
||||||
.route("/api/calendar/events/create", post(calendar_backend::handlers::create_event))
|
"/api/user/info",
|
||||||
.route("/api/calendar/events/update", post(calendar_backend::handlers::update_event))
|
get(calendar_backend::handlers::get_user_info),
|
||||||
.route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event))
|
)
|
||||||
.route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event))
|
.route(
|
||||||
|
"/api/calendar/create",
|
||||||
|
post(calendar_backend::handlers::create_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/delete",
|
||||||
|
post(calendar_backend::handlers::delete_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events",
|
||||||
|
get(calendar_backend::handlers::get_calendar_events),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/create",
|
||||||
|
post(calendar_backend::handlers::create_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/update",
|
||||||
|
post(calendar_backend::handlers::update_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/:uid",
|
||||||
|
get(calendar_backend::handlers::refresh_event),
|
||||||
|
)
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series))
|
post(calendar_backend::handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(calendar_backend::handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -58,39 +94,47 @@ mod test_utils {
|
|||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for server to start
|
// Wait for server to start
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
TestServer { base_url, client }
|
TestServer { base_url, client }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self) -> String {
|
pub async fn login(&self) -> String {
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()),
|
"username": "test".to_string(),
|
||||||
"password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()),
|
"password": "test".to_string(),
|
||||||
"server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string())
|
"server_url": "https://example.com".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = self.client
|
let response = self
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", self.base_url))
|
.post(&format!("{}/api/auth/login", self.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to send login request");
|
.expect("Failed to send login request");
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
login_response["token"].as_str().expect("Login response should contain token").to_string()
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> &'static str {
|
async fn root() -> &'static str {
|
||||||
"Calendar Backend API v0.1.0"
|
"Calendar Backend API v0.1.0"
|
||||||
}
|
}
|
||||||
@@ -106,26 +150,27 @@ mod test_utils {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::test_utils::*;
|
use super::test_utils::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
/// Test the health endpoint
|
/// Test the health endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_endpoint() {
|
async fn test_health_endpoint() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/health", server.base_url))
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let health_response: serde_json::Value = response.json().await.unwrap();
|
let health_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert_eq!(health_response["status"], "healthy");
|
assert_eq!(health_response["status"], "healthy");
|
||||||
assert_eq!(health_response["service"], "calendar-backend");
|
assert_eq!(health_response["service"], "calendar-backend");
|
||||||
|
|
||||||
println!("✓ Health endpoint test passed");
|
println!("✓ Health endpoint test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,33 +178,42 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_auth_login() {
|
async fn test_auth_login() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// Load credentials from .env
|
// Use test credentials
|
||||||
dotenvy::dotenv().ok();
|
let username = "test".to_string();
|
||||||
let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let server_url = "https://example.com".to_string();
|
||||||
|
|
||||||
let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string());
|
|
||||||
|
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"server_url": server_url
|
"server_url": server_url
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", server.base_url))
|
.post(&format!("{}/api/auth/login", server.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(login_response["token"].is_string(), "Login response should contain a token");
|
assert!(
|
||||||
assert!(login_response["username"].is_string(), "Login response should contain username");
|
login_response["token"].is_string(),
|
||||||
|
"Login response should contain a token"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
login_response["username"].is_string(),
|
||||||
|
"Login response should contain username"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Authentication login test passed");
|
println!("✓ Authentication login test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,52 +221,57 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_auth_verify() {
|
async fn test_auth_verify() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/auth/verify", server.base_url))
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let verify_response: serde_json::Value = response.json().await.unwrap();
|
let verify_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
||||||
|
|
||||||
println!("✓ Authentication verify test passed");
|
println!("✓ Authentication verify test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test user info endpoint
|
/// Test user info endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_user_info() {
|
async fn test_user_info() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server discovery fails, which can happen
|
// Note: This might fail if CalDAV server discovery fails, which can happen
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let user_info: serde_json::Value = response.json().await.unwrap();
|
let user_info: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(user_info["username"].is_string());
|
assert!(user_info["username"].is_string());
|
||||||
println!("✓ User info test passed");
|
println!("✓ User info test passed");
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ User info test skipped (CalDAV server issues): {}", response.status());
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,48 +279,59 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_calendar_events() {
|
async fn test_get_calendar_events() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events?year=2024&month=12",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Get events failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Get events failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let events: serde_json::Value = response.json().await.unwrap();
|
let events: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(events.is_array());
|
assert!(events.is_array());
|
||||||
|
|
||||||
println!("✓ Get calendar events test passed (found {} events)", events.as_array().unwrap().len());
|
println!(
|
||||||
|
"✓ Get calendar events test passed (found {} events)",
|
||||||
|
events.as_array().unwrap().len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test event creation endpoint
|
/// Test event creation endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_event() {
|
async fn test_create_event() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Event",
|
"title": "Integration Test Event",
|
||||||
"description": "Created by integration test",
|
"description": "Created by integration test",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test Location",
|
"location": "Test Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -275,8 +345,9 @@ mod tests {
|
|||||||
"recurrence": "none",
|
"recurrence": "none",
|
||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -284,10 +355,10 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Create event response status: {}", status);
|
println!("Create event response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let create_response: serde_json::Value = response.json().await.unwrap();
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
@@ -302,47 +373,58 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_refresh_event() {
|
async fn test_refresh_event() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
||||||
let test_uid = "test-event-uid";
|
let test_uid = "test-event-uid";
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events/{}",
|
||||||
|
server.base_url, test_uid
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||||
assert!(response.status() == 200 || response.status() == 404,
|
assert!(
|
||||||
"Refresh event failed with unexpected status: {}", response.status());
|
response.status() == 200 || response.status() == 404,
|
||||||
|
"Refresh event failed with unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Refresh event endpoint test passed");
|
println!("✓ Refresh event endpoint test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test invalid authentication
|
/// Test invalid authentication
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_invalid_auth() {
|
async fn test_invalid_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", "Bearer invalid-token")
|
.header("Authorization", "Bearer invalid-token")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Accept both 400 and 401 as valid responses for invalid tokens
|
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||||
assert!(response.status() == 401 || response.status() == 400,
|
assert!(
|
||||||
"Expected 401 or 400 for invalid token, got {}", response.status());
|
response.status() == 401 || response.status() == 400,
|
||||||
|
"Expected 401 or 400 for invalid token, got {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
println!("✓ Invalid authentication test passed");
|
println!("✓ Invalid authentication test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,13 +432,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_missing_auth() {
|
async fn test_missing_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 401);
|
assert_eq!(response.status(), 401);
|
||||||
println!("✓ Missing authentication test passed");
|
println!("✓ Missing authentication test passed");
|
||||||
}
|
}
|
||||||
@@ -367,20 +450,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_event_series() {
|
async fn test_create_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Series",
|
"title": "Integration Test Series",
|
||||||
"description": "Created by integration test for series",
|
"description": "Created by integration test for series",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test Series Location",
|
"location": "Test Series Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -397,19 +480,23 @@ mod tests {
|
|||||||
"recurrence_count": 4,
|
"recurrence_count": 4,
|
||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&create_payload)
|
.json(&create_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Create series response status: {}", status);
|
println!("Create series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let create_response: serde_json::Value = response.json().await.unwrap();
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
@@ -422,24 +509,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test event series update endpoint
|
/// Test event series update endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_event_series() {
|
async fn test_update_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let update_payload = json!({
|
let update_payload = json!({
|
||||||
"series_uid": "test-series-uid",
|
"series_uid": "test-series-uid",
|
||||||
"title": "Updated Series Title",
|
"title": "Updated Series Title",
|
||||||
"description": "Updated by integration test",
|
"description": "Updated by integration test",
|
||||||
"start_date": "2024-12-26",
|
"start_date": "2024-12-26",
|
||||||
"start_time": "14:00",
|
"start_time": "14:00",
|
||||||
"end_date": "2024-12-26",
|
"end_date": "2024-12-26",
|
||||||
"end_time": "15:00",
|
"end_time": "15:00",
|
||||||
"location": "Updated Location",
|
"location": "Updated Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -457,27 +544,36 @@ mod tests {
|
|||||||
"update_scope": "all_in_series",
|
"update_scope": "all_in_series",
|
||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&update_payload)
|
.json(&update_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Update series response status: {}", status);
|
println!("Update series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let update_response: serde_json::Value = response.json().await.unwrap();
|
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(update_response["success"].as_bool().unwrap_or(false));
|
assert!(update_response["success"].as_bool().unwrap_or(false));
|
||||||
assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid");
|
assert_eq!(
|
||||||
|
update_response["series_uid"].as_str().unwrap(),
|
||||||
|
"test-series-uid"
|
||||||
|
);
|
||||||
println!("✓ Update event series test passed");
|
println!("✓ Update event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Update event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -487,40 +583,46 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_delete_event_series() {
|
async fn test_delete_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let delete_payload = json!({
|
let delete_payload = json!({
|
||||||
"series_uid": "test-series-to-delete",
|
"series_uid": "test-series-to-delete",
|
||||||
"calendar_path": "/calendars/test/default/",
|
"calendar_path": "/calendars/test/default/",
|
||||||
"event_href": "test-series.ics",
|
"event_href": "test-series.ics",
|
||||||
"delete_scope": "all_in_series"
|
"delete_scope": "all_in_series"
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/delete", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/delete",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&delete_payload)
|
.json(&delete_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Delete series response status: {}", status);
|
println!("Delete series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let delete_response: serde_json::Value = response.json().await.unwrap();
|
let delete_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||||
println!("✓ Delete event series test passed");
|
println!("✓ Delete event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Delete event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Delete event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -530,17 +632,17 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_invalid_update_scope() {
|
async fn test_invalid_update_scope() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let invalid_payload = json!({
|
let invalid_payload = json!({
|
||||||
"series_uid": "test-series-uid",
|
"series_uid": "test-series-uid",
|
||||||
"title": "Test Title",
|
"title": "Test Title",
|
||||||
"description": "Test",
|
"description": "Test",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test",
|
"location": "Test",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -554,16 +656,24 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false],
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
"update_scope": "invalid_scope" // This should cause a 400 error
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&invalid_payload)
|
.json(&invalid_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for invalid update scope");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for invalid update scope"
|
||||||
|
);
|
||||||
println!("✓ Invalid update scope test passed");
|
println!("✓ Invalid update scope test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,16 +681,16 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_non_recurring_series_rejection() {
|
async fn test_non_recurring_series_rejection() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let non_recurring_payload = json!({
|
let non_recurring_payload = json!({
|
||||||
"title": "Non-recurring Event",
|
"title": "Non-recurring Event",
|
||||||
"description": "This should be rejected",
|
"description": "This should be rejected",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test",
|
"location": "Test",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -593,16 +703,24 @@ mod tests {
|
|||||||
"recurrence": "none", // This should cause rejection
|
"recurrence": "none", // This should cause rejection
|
||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&non_recurring_payload)
|
.json(&non_recurring_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for non-recurring event in series endpoint"
|
||||||
|
);
|
||||||
println!("✓ Non-recurring series rejection test passed");
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Common types and enums used across calendar components
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== ENUMS AND COMMON TYPES ====================
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
@@ -22,7 +22,7 @@ pub enum EventClass {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum TimeTransparency {
|
pub enum TimeTransparency {
|
||||||
Opaque, // OPAQUE - time is not available
|
Opaque, // OPAQUE - time is not available
|
||||||
Transparent, // TRANSPARENT - time is available
|
Transparent, // TRANSPARENT - time is available
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -64,11 +64,11 @@ pub enum AlarmAction {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CalendarUser {
|
pub struct CalendarUser {
|
||||||
pub cal_address: String, // Calendar user address (usually email)
|
pub cal_address: String, // Calendar user address (usually email)
|
||||||
pub common_name: Option<String>, // CN parameter - display name
|
pub common_name: Option<String>, // CN parameter - display name
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -78,130 +78,130 @@ pub struct Attendee {
|
|||||||
pub role: Option<AttendeeRole>, // ROLE parameter
|
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||||
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||||
pub rsvp: Option<bool>, // RSVP parameter
|
pub rsvp: Option<bool>, // RSVP parameter
|
||||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||||
pub member: Vec<String>, // MEMBER parameter
|
pub member: Vec<String>, // MEMBER parameter
|
||||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VAlarm {
|
pub struct VAlarm {
|
||||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||||
pub duration: Option<Duration>, // Duration (DURATION)
|
pub duration: Option<Duration>, // Duration (DURATION)
|
||||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||||
pub summary: Option<String>, // Summary for EMAIL
|
pub summary: Option<String>, // Summary for EMAIL
|
||||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum AlarmTrigger {
|
pub enum AlarmTrigger {
|
||||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||||
Duration(Duration), // Duration relative to start/end
|
Duration(Duration), // Duration relative to start/end
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Attachment {
|
pub struct Attachment {
|
||||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||||
pub encoding: Option<String>, // ENCODING parameter
|
pub encoding: Option<String>, // ENCODING parameter
|
||||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||||
pub uri: Option<String>, // URI reference
|
pub uri: Option<String>, // URI reference
|
||||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct GeographicPosition {
|
pub struct GeographicPosition {
|
||||||
pub latitude: f64, // Latitude in decimal degrees
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
pub longitude: f64, // Longitude in decimal degrees
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VTimeZone {
|
pub struct VTimeZone {
|
||||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||||
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||||
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct TimeZoneComponent {
|
pub struct TimeZoneComponent {
|
||||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||||
pub tzoffset_to: String, // UTC offset for this component
|
pub tzoffset_to: String, // UTC offset for this component
|
||||||
pub tzoffset_from: String, // UTC offset before this component
|
pub tzoffset_from: String, // UTC offset before this component
|
||||||
pub rrule: Option<String>, // Recurrence rule
|
pub rrule: Option<String>, // Recurrence rule
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||||
pub tzname: Vec<String>, // Time zone names
|
pub tzname: Vec<String>, // Time zone names
|
||||||
pub comment: Vec<String>, // Comments
|
pub comment: Vec<String>, // Comments
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VJournal {
|
pub struct VJournal {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional properties
|
// Optional properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<String>, // Status (STATUS)
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VFreeBusy {
|
pub struct VFreeBusy {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional date-time properties
|
// Optional date-time properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
|
||||||
// People
|
// People
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Free/busy time
|
// Free/busy time
|
||||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
pub comment: Vec<String>, // Comments (COMMENT)
|
pub comment: Vec<String>, // Comments (COMMENT)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct FreeBusyTime {
|
pub struct FreeBusyTime {
|
||||||
pub fb_type: FreeBusyType, // Free/busy type
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
pub periods: Vec<Period>, // Time periods
|
pub periods: Vec<Period>, // Time periods
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Period {
|
pub struct Period {
|
||||||
pub start: DateTime<Utc>, // Period start
|
pub start: DateTime<Utc>, // Period start
|
||||||
pub end: Option<DateTime<Utc>>, // Period end
|
pub end: Option<DateTime<Utc>>, // Period end
|
||||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! RFC 5545 Compliant Calendar Models
|
//! RFC 5545 Compliant Calendar Models
|
||||||
//!
|
//!
|
||||||
//! This crate provides shared data structures for calendar applications
|
//! This crate provides shared data structures for calendar applications
|
||||||
//! that comply with RFC 5545 (iCalendar) specification.
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
pub mod vevent;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
pub use vevent::*;
|
pub use vevent::*;
|
||||||
pub use common::*;
|
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
pub location: Option<String>, // Location (LOCATION)
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<EventStatus>, // Status (STATUS)
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
// Categorization and relationships
|
// Categorization and relationships
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
pub comment: Option<String>, // Comment (COMMENT)
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
// Geographical
|
// Geographical
|
||||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
// CalDAV specific (for implementation)
|
// CalDAV specific (for implementation)
|
||||||
pub etag: Option<String>, // ETag for CalDAV
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
pub href: Option<String>, // Href for CalDAV
|
pub href: Option<String>, // Href for CalDAV
|
||||||
pub calendar_path: Option<String>, // Calendar path
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
pub all_day: bool, // All-day event flag
|
pub all_day: bool, // All-day event flag
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
@@ -129,7 +129,9 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get display title (summary or "Untitled Event")
|
/// Helper method to get display title (summary or "Untitled Event")
|
||||||
pub fn get_title(&self) -> String {
|
pub fn get_title(&self) -> String {
|
||||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
self.summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled Event".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
@@ -151,7 +153,7 @@ impl VEvent {
|
|||||||
pub fn get_status_display(&self) -> &'static str {
|
pub fn get_status_display(&self) -> &'static str {
|
||||||
match &self.status {
|
match &self.status {
|
||||||
Some(EventStatus::Tentative) => "Tentative",
|
Some(EventStatus::Tentative) => "Tentative",
|
||||||
Some(EventStatus::Confirmed) => "Confirmed",
|
Some(EventStatus::Confirmed) => "Confirmed",
|
||||||
Some(EventStatus::Cancelled) => "Cancelled",
|
Some(EventStatus::Cancelled) => "Cancelled",
|
||||||
None => "Confirmed", // Default
|
None => "Confirmed", // Default
|
||||||
}
|
}
|
||||||
@@ -180,4 +182,4 @@ impl VEvent {
|
|||||||
Some(p) => format!("Priority {}", p),
|
Some(p) => format!("Priority {}", p),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
calendar.db
BIN
calendar.db
Binary file not shown.
@@ -1,17 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
calendar-backend:
|
calendar-backend:
|
||||||
build: .
|
build: .
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/site_dist:/srv/www
|
- ./data/site_dist:/srv/www
|
||||||
|
- ./data/db:/db
|
||||||
|
|
||||||
calendar-frontend:
|
calendar-frontend:
|
||||||
image: caddy
|
image: caddy
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
- BACKEND_API_URL=http://localhost:3000/api
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
|
|||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlTextAreaElement",
|
"HtmlTextAreaElement",
|
||||||
|
"HtmlLinkElement",
|
||||||
|
"HtmlHeadElement",
|
||||||
"Event",
|
"Event",
|
||||||
"MouseEvent",
|
"MouseEvent",
|
||||||
"InputEvent",
|
"InputEvent",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base data-trunk-public-url />
|
<base data-trunk-public-url />
|
||||||
<link data-trunk rel="css" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
|
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -34,14 +45,14 @@ impl AuthService {
|
|||||||
let base_url = option_env!("BACKEND_API_URL")
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
.unwrap_or("http://localhost:3000/api")
|
.unwrap_or("http://localhost:3000/api")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
Self { base_url }
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
@@ -49,9 +60,9 @@ impl AuthService {
|
|||||||
body: &T,
|
body: &T,
|
||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +73,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
@@ -92,4 +107,4 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarProps {
|
pub struct CalendarProps {
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
|
||||||
pub on_event_click: Callback<VEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -25,7 +22,17 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,12 @@ pub struct CalendarProps {
|
|||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
// Track the currently selected date (the actual day the user has selected)
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
let selected_date = use_state(|| {
|
let selected_date = use_state(|| {
|
||||||
// Try to load saved selected date from localStorage
|
// Try to load saved selected date from localStorage
|
||||||
@@ -55,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the display date (what to show in the view)
|
// Track the display date (what to show in the view)
|
||||||
let current_date = use_state(|| {
|
let current_date = use_state(|| match props.view {
|
||||||
match props.view {
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
ViewMode::Week => *selected_date,
|
||||||
ViewMode::Week => *selected_date,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let selected_event = use_state(|| None::<VEvent>);
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
// State for create event modal
|
// State for create event modal
|
||||||
let show_create_modal = use_state(|| false);
|
let show_create_modal = use_state(|| false);
|
||||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
// State for time increment snapping (15 or 30 minutes)
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
let time_increment = use_state(|| {
|
let time_increment = use_state(|| {
|
||||||
// Try to load saved time increment from localStorage
|
// Try to load saved time increment from localStorage
|
||||||
@@ -82,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
15
|
15
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
|
||||||
|
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.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()
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_year = date.year();
|
||||||
|
let current_month = date.month();
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
current_year,
|
||||||
|
current_month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.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()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
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) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
{
|
{
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
@@ -98,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let on_prev = {
|
let on_prev = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -110,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let prev_month = *current_date - Duration::days(1);
|
let prev_month = *current_date - Duration::days(1);
|
||||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
(first_of_prev, first_of_prev)
|
(first_of_prev, first_of_prev)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to previous week
|
// Go to previous week
|
||||||
let prev_week = *selected_date - Duration::weeks(1);
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
(prev_week, prev_week)
|
(prev_week, prev_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_next = {
|
let on_next = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let next_month = if current_date.month() == 12 {
|
let next_month = if current_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
(next_month, next_month)
|
(next_month, next_month)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to next week
|
// Go to next week
|
||||||
let next_week = *selected_date + Duration::weeks(1);
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
(next_week, next_week)
|
(next_week, next_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let first_of_today = today.with_day(1).unwrap();
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
(today, first_of_today) // Select today, but display the month
|
(today, first_of_today) // Select today, but display the month
|
||||||
},
|
}
|
||||||
ViewMode::Week => (today, today), // Select and display today
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle time increment toggle
|
// Handle time increment toggle
|
||||||
let on_time_increment_toggle = {
|
let on_time_increment_toggle = {
|
||||||
let time_increment = time_increment.clone();
|
let time_increment = time_increment.clone();
|
||||||
@@ -179,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_time_increment", next);
|
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-create event
|
// Handle drag-to-create event
|
||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
// For drag-to-create, we don't need the temporary event approach
|
move |(_date, start_datetime, end_datetime): (
|
||||||
// Instead, just pass the local times directly via initial_time props
|
NaiveDate,
|
||||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
chrono::NaiveDateTime,
|
||||||
show_create_modal.set(true);
|
chrono::NaiveDateTime,
|
||||||
})
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-move event
|
// Handle drag-to-move event
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
|
Callback::from(
|
||||||
if let Some(callback) = &on_event_update_request {
|
move |(
|
||||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
|
event,
|
||||||
}
|
new_start,
|
||||||
})
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
view_mode={props.view.clone()}
|
view_mode={props.view.clone()}
|
||||||
on_prev={on_prev}
|
on_prev={on_prev}
|
||||||
@@ -213,9 +419,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={Some(*time_increment)}
|
time_increment={Some(*time_increment)}
|
||||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
match props.view {
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let on_day_select = {
|
let on_day_select = {
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -224,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<MonthView
|
<MonthView
|
||||||
current_month={*current_date}
|
current_month={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<WeekView
|
<WeekView
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -257,11 +476,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={*time_increment}
|
time_increment={*time_increment}
|
||||||
/>
|
/>
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event details modal
|
// Event details modal
|
||||||
<EventModal
|
<EventModal
|
||||||
event={(*selected_event).clone()}
|
event={(*selected_event).clone()}
|
||||||
on_close={{
|
on_close={{
|
||||||
let selected_event_clone = selected_event.clone();
|
let selected_event_clone = selected_event.clone();
|
||||||
@@ -270,7 +490,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// Create event modal
|
// Create event modal
|
||||||
<CreateEventModal
|
<CreateEventModal
|
||||||
is_open={*show_create_modal}
|
is_open={*show_create_modal}
|
||||||
@@ -294,7 +514,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
Callback::from(move |event_data: EventCreationData| {
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
show_create_modal.set(false);
|
show_create_modal.set(false);
|
||||||
create_event_data.set(None);
|
create_event_data.set(None);
|
||||||
|
|
||||||
// Emit the create event request to parent
|
// Emit the create event request to parent
|
||||||
if let Some(callback) = &on_create_event_request {
|
if let Some(callback) = &on_create_event_request {
|
||||||
callback.emit(event_data);
|
callback.emit(event_data);
|
||||||
@@ -313,4 +533,4 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarContextMenuProps {
|
pub struct CalendarContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct CalendarContextMenuProps {
|
|||||||
#[function_component(CalendarContextMenu)]
|
#[function_component(CalendarContextMenu)]
|
||||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -33,9 +33,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||||
@@ -44,4 +44,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{NaiveDate, Datelike};
|
|
||||||
use crate::components::ViewMode;
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarHeaderProps {
|
pub struct CalendarHeaderProps {
|
||||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
|||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
@@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
|||||||
fn get_month_name(month: u32) -> &'static str {
|
fn get_month_name(month: u32) -> &'static str {
|
||||||
match month {
|
match month {
|
||||||
1 => "January",
|
1 => "January",
|
||||||
2 => "February",
|
2 => "February",
|
||||||
3 => "March",
|
3 => "March",
|
||||||
4 => "April",
|
4 => "April",
|
||||||
5 => "May",
|
5 => "May",
|
||||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
|||||||
10 => "October",
|
10 => "October",
|
||||||
11 => "November",
|
11 => "November",
|
||||||
12 => "December",
|
12 => "December",
|
||||||
_ => "Invalid"
|
_ => "Invalid",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarListItemProps {
|
pub struct CalendarListItemProps {
|
||||||
pub calendar: CalendarInfo,
|
pub calendar: CalendarInfo,
|
||||||
pub color_picker_open: bool,
|
pub color_picker_open: bool,
|
||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||||
<span class="calendar-color"
|
<span class="calendar-color"
|
||||||
style={format!("background-color: {}", props.calendar.color)}
|
style={format!("background-color: {}", props.calendar.color)}
|
||||||
onclick={on_color_click}>
|
onclick={on_color_click}>
|
||||||
{
|
{
|
||||||
@@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
let color_str = color.clone();
|
let color_str = color.clone();
|
||||||
let cal_path = props.calendar.path.clone();
|
let cal_path = props.calendar.path.clone();
|
||||||
let on_color_change = props.on_color_change.clone();
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_selected = props.calendar.color == *color;
|
let is_selected = props.calendar.color == *color;
|
||||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={class_name}
|
<div class={class_name}
|
||||||
style={format!("background-color: {}", color)}
|
style={format!("background-color: {}", color)}
|
||||||
@@ -72,4 +72,4 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ContextMenuProps {
|
pub struct ContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct ContextMenuProps {
|
|||||||
#[function_component(ContextMenu)]
|
#[function_component(ContextMenu)]
|
||||||
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
// Close menu when clicking outside (handled by parent component)
|
// Close menu when clicking outside (handled by parent component)
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
@@ -35,9 +35,9 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||||
@@ -45,4 +45,4 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_creating = is_creating.clone();
|
let is_creating = is_creating.clone();
|
||||||
let on_create = props.on_create.clone();
|
let on_create = props.on_create.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let name = (*calendar_name).trim();
|
let name = (*calendar_name).trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
error_message.set(Some("Calendar name is required".to_string()));
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if name.len() > 100 {
|
if name.len() > 100 {
|
||||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_message.set(None);
|
error_message.set(None);
|
||||||
is_creating.set(true);
|
is_creating.set(true);
|
||||||
|
|
||||||
let desc = if (*description).trim().is_empty() {
|
let desc = if (*description).trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((*description).clone())
|
Some((*description).clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
{"×"}
|
{"×"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-body" onsubmit={on_submit}>
|
<form class="modal-body" onsubmit={on_submit}>
|
||||||
{
|
{
|
||||||
if let Some(ref error) = *error_message {
|
if let Some(ref error) = *error_message {
|
||||||
@@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-name">{"Calendar Name *"}</label>
|
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||||
<input
|
<input
|
||||||
id="calendar-name"
|
id="calendar-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={(*calendar_name).clone()}
|
value={(*calendar_name).clone()}
|
||||||
@@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-description">{"Description"}</label>
|
<label for="calendar-description">{"Description"}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{"Calendar Color"}</label>
|
<label>{"Calendar Color"}</label>
|
||||||
<div class="color-grid">
|
<div class="color-grid">
|
||||||
@@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
selected_color.set(Some(color.clone()));
|
selected_color.set(Some(color.clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let class_name = if is_selected {
|
let class_name = if is_selected {
|
||||||
"color-option selected"
|
"color-option selected"
|
||||||
} else {
|
} else {
|
||||||
"color-option"
|
"color-option"
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
@@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cancel-button"
|
class="cancel-button"
|
||||||
onclick={props.on_close.reform(|_| ())}
|
onclick={props.on_close.reform(|_| ())}
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="create-button"
|
class="create-button"
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
@@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum DeleteAction {
|
pub enum DeleteAction {
|
||||||
@@ -30,7 +30,7 @@ pub struct EventContextMenuProps {
|
|||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if the event is recurring
|
// Check if the event is recurring
|
||||||
let is_recurring = props.event.as_ref()
|
let is_recurring = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
@@ -64,9 +66,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
@@ -117,4 +119,4 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
@@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
on_close.emit(());
|
on_close.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let backdrop_click = {
|
let backdrop_click = {
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
@@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
<strong>{"Title:"}</strong>
|
<strong>{"Title:"}</strong>
|
||||||
<span>{event.get_title()}</span>
|
<span>{event.get_title()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref description) = event.description {
|
if let Some(ref description) = event.description {
|
||||||
html! {
|
html! {
|
||||||
@@ -52,12 +52,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Start:"}</strong>
|
<strong>{"Start:"}</strong>
|
||||||
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref end) = event.dtend {
|
if let Some(ref end) = event.dtend {
|
||||||
html! {
|
html! {
|
||||||
@@ -70,12 +70,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"All Day:"}</strong>
|
<strong>{"All Day:"}</strong>
|
||||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref location) = event.location {
|
if let Some(ref location) = event.location {
|
||||||
html! {
|
html! {
|
||||||
@@ -88,22 +88,22 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Status:"}</strong>
|
<strong>{"Status:"}</strong>
|
||||||
<span>{event.get_status_display()}</span>
|
<span>{event.get_status_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Privacy:"}</strong>
|
<strong>{"Privacy:"}</strong>
|
||||||
<span>{event.get_class_display()}</span>
|
<span>{event.get_class_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Priority:"}</strong>
|
<strong>{"Priority:"}</strong>
|
||||||
<span>{event.get_priority_display()}</span>
|
<span>{event.get_priority_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref organizer) = event.organizer {
|
if let Some(ref organizer) = event.organizer {
|
||||||
html! {
|
html! {
|
||||||
@@ -116,7 +116,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.attendees.is_empty() {
|
if !event.attendees.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -129,7 +129,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -142,7 +142,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref recurrence) = event.rrule {
|
if let Some(ref recurrence) = event.rrule {
|
||||||
html! {
|
html! {
|
||||||
@@ -160,7 +160,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.alarms.is_empty() {
|
if !event.alarms.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref created) = event.created {
|
if let Some(ref created) = event.created {
|
||||||
html! {
|
html! {
|
||||||
@@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref modified) = event.last_modified {
|
if let Some(ref modified) = event.last_modified {
|
||||||
html! {
|
html! {
|
||||||
@@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct LoginProps {
|
pub struct LoginProps {
|
||||||
@@ -9,11 +9,20 @@ pub struct LoginProps {
|
|||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Login(props: &LoginProps) -> Html {
|
pub fn Login(props: &LoginProps) -> Html {
|
||||||
let server_url = use_state(String::new);
|
// Load remembered values from LocalStorage on mount
|
||||||
let username = use_state(String::new);
|
let server_url = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||||
|
});
|
||||||
|
let username = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||||
|
});
|
||||||
let password = use_state(String::new);
|
let password = use_state(String::new);
|
||||||
let error_message = use_state(|| Option::<String>::None);
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
let is_loading = use_state(|| false);
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
|
// Remember checkboxes state - default to checked
|
||||||
|
let remember_server = use_state(|| true);
|
||||||
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
let server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
@@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
password.set(target.value());
|
password.set(target.value());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_remember_server_change = {
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_server.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_server_url");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remember_username_change = {
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_username.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_username", (*username).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_username");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_submit = {
|
let on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
@@ -53,7 +94,7 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let server_url = (*server_url).clone();
|
let server_url = (*server_url).clone();
|
||||||
let username = (*username).clone();
|
let username = (*username).clone();
|
||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||||
Ok((token, credentials)) => {
|
Ok((token, session_token, credentials, preferences)) => {
|
||||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
// Store token and credentials in local storage
|
// Store token and credentials in local storage
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||||
|
error_message
|
||||||
|
.set(Some("Failed to store session token".to_string()));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store preferences from database
|
||||||
|
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||||
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_server_url_change}
|
onchange={on_server_url_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
/>
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
/>
|
||||||
|
<label for="remember_server">{"Remember server"}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_username_change}
|
onchange={on_username_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
/>
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
/>
|
||||||
|
<label for="remember_username">{"Remember username"}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -172,21 +243,25 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
/// Perform login using the CalDAV auth service
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||||
|
|
||||||
let auth_service = AuthService::new();
|
let auth_service = AuthService::new();
|
||||||
let request = CalDAVLoginRequest {
|
let request = CalDAVLoginRequest {
|
||||||
server_url: server_url.clone(),
|
server_url: server_url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone()
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
|
|
||||||
match auth_service.login(request).await {
|
match auth_service.login(request).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||||
@@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
"username": username,
|
"username": username,
|
||||||
"password": password
|
"password": password
|
||||||
});
|
});
|
||||||
Ok((response.token, credentials.to_string()))
|
|
||||||
},
|
// Extract preferences as JSON
|
||||||
|
let preferences = serde_json::json!({
|
||||||
|
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||||
|
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||||
|
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||||
|
"calendar_theme": response.preferences.calendar_theme,
|
||||||
|
"calendar_colors": response.preferences.calendar_colors,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
Err(err)
|
Err(err)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_header;
|
|
||||||
pub mod month_view;
|
|
||||||
pub mod week_view;
|
|
||||||
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 calendar_context_menu;
|
||||||
pub mod create_event_modal;
|
pub mod calendar_header;
|
||||||
pub mod sidebar;
|
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
pub mod route_handler;
|
pub mod context_menu;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
pub mod create_event_modal;
|
||||||
|
pub mod event_context_menu;
|
||||||
|
pub mod event_modal;
|
||||||
|
pub mod login;
|
||||||
|
pub mod month_view;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
|
pub mod route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_header::CalendarHeader;
|
|
||||||
pub use month_view::MonthView;
|
|
||||||
pub use week_view::WeekView;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use context_menu::ContextMenu;
|
|
||||||
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
|
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use context_menu::ContextMenu;
|
||||||
|
pub use create_calendar_modal::CreateCalendarModal;
|
||||||
|
pub use create_event_modal::{
|
||||||
|
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
||||||
|
};
|
||||||
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
|
pub use event_modal::EventModal;
|
||||||
|
pub use login::Login;
|
||||||
|
pub use month_view::MonthView;
|
||||||
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::window;
|
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
use crate::services::calendar_service::UserInfo;
|
use web_sys::window;
|
||||||
use crate::models::ical::VEvent;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct MonthViewProps {
|
pub struct MonthViewProps {
|
||||||
@@ -52,30 +52,33 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let calculate_max_events = calculate_max_events.clone();
|
let calculate_max_events = calculate_max_events.clone();
|
||||||
use_effect_with((), move |_| {
|
use_effect_with((), move |_| {
|
||||||
let calculate_max_events_clone = calculate_max_events.clone();
|
let calculate_max_events_clone = calculate_max_events.clone();
|
||||||
|
|
||||||
// Initial calculation with a slight delay to ensure DOM is ready
|
// Initial calculation with a slight delay to ensure DOM is ready
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let timeout_closure = Closure::wrap(Box::new(move || {
|
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events_clone();
|
calculate_max_events_clone();
|
||||||
}) as Box<dyn FnMut()>);
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
timeout_closure.as_ref().unchecked_ref(),
|
timeout_closure.as_ref().unchecked_ref(),
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
timeout_closure.forget();
|
timeout_closure.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup resize listener
|
// Setup resize listener
|
||||||
let resize_closure = Closure::wrap(Box::new(move || {
|
let resize_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events();
|
calculate_max_events();
|
||||||
}) as Box<dyn Fn()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
resize_closure.forget(); // Keep the closure alive
|
resize_closure.forget(); // Keep the closure alive
|
||||||
}
|
}
|
||||||
|
|
||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
<div class="weekday-header">{"Thu"}</div>
|
<div class="weekday-header">{"Thu"}</div>
|
||||||
<div class="weekday-header">{"Fri"}</div>
|
<div class="weekday-header">{"Fri"}</div>
|
||||||
<div class="weekday-header">{"Sat"}</div>
|
<div class="weekday-header">{"Sat"}</div>
|
||||||
|
|
||||||
// Days from previous month (grayed out)
|
// Days from previous month (grayed out)
|
||||||
{
|
{
|
||||||
days_from_prev_month.iter().map(|day| {
|
days_from_prev_month.iter().map(|day| {
|
||||||
@@ -112,7 +118,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days of the current month
|
// Days of the current month
|
||||||
{
|
{
|
||||||
(1..=days_in_month).map(|day| {
|
(1..=days_in_month).map(|day| {
|
||||||
@@ -120,16 +126,16 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let is_today = date == props.today;
|
let is_today = date == props.today;
|
||||||
let is_selected = props.selected_date == Some(date);
|
let is_selected = props.selected_date == Some(date);
|
||||||
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Calculate visible events and overflow
|
// Calculate visible events and overflow
|
||||||
let max_events = *max_events_per_day as usize;
|
let max_events = *max_events_per_day as usize;
|
||||||
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||||
let hidden_count = day_events.len().saturating_sub(max_events);
|
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!(
|
class={classes!(
|
||||||
"calendar-day",
|
"calendar-day",
|
||||||
if is_today { Some("today") } else { None },
|
if is_today { Some("today") } else { None },
|
||||||
if is_selected { Some("selected") } else { None }
|
if is_selected { Some("selected") } else { None }
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +168,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
visible_events.iter().map(|event| {
|
visible_events.iter().map(|event| {
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
let onclick = {
|
let onclick = {
|
||||||
let on_event_click = props.on_event_click.clone();
|
let on_event_click = props.on_event_click.clone();
|
||||||
let event = (*event).clone();
|
let event = (*event).clone();
|
||||||
@@ -170,7 +176,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
on_event_click.emit(event.clone());
|
on_event_click.emit(event.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let oncontextmenu = {
|
let oncontextmenu = {
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
if let Some(callback) = &props.on_event_context_menu {
|
||||||
let callback = callback.clone();
|
let callback = callback.clone();
|
||||||
@@ -183,9 +189,9 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||||
style={format!("background-color: {}", event_color)}
|
style={format!("background-color: {}", event_color)}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -212,7 +218,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let total_slots = 42; // 6 rows x 7 days
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
(1..=remaining_slots).map(|day| {
|
} else {
|
||||||
html! {
|
0
|
||||||
<div class="calendar-day next-month">{day}</div>
|
};
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
(1..=remaining_slots)
|
||||||
|
.map(|day| {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-day next-month">{day}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
NaiveDate::from_ymd_opt(
|
NaiveDate::from_ymd_opt(
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
if date.month() == 12 {
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
date.year() + 1
|
||||||
1
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
@@ -252,7 +272,7 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
Weekday::Fri => 5,
|
Weekday::Fri => 5,
|
||||||
Weekday::Sat => 6,
|
Weekday::Sat => 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
if days_before == 0 {
|
if days_before == 0 {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
@@ -261,8 +281,8 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let prev_month_days = get_days_in_month(prev_month);
|
let prev_month_days = get_days_in_month(prev_month);
|
||||||
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum RecurringEditAction {
|
pub enum RecurringEditAction {
|
||||||
@@ -25,29 +25,34 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
let on_this_event = {
|
let on_this_event = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::ThisEvent);
|
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_future_events = {
|
let on_future_events = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::FutureEvents);
|
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_all_events = {
|
let on_all_events = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::AllEvents);
|
on_choice.emit(RecurringEditAction::AllEvents);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_cancel = {
|
let on_cancel = {
|
||||||
let on_cancel = props.on_cancel.clone();
|
let on_cancel = props.on_cancel.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
@@ -64,18 +69,18 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||||
<p>{"How would you like to apply this change?"}</p>
|
<p>{"How would you like to apply this change?"}</p>
|
||||||
|
|
||||||
<div class="recurring-edit-options">
|
<div class="recurring-edit-options">
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||||
<div class="option-title">{"This event only"}</div>
|
<div class="option-title">{"This event only"}</div>
|
||||||
<div class="option-description">{"Change only this occurrence"}</div>
|
<div class="option-description">{"Change only this occurrence"}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||||
<div class="option-title">{"This and future events"}</div>
|
<div class="option-title">{"This and future events"}</div>
|
||||||
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||||
<div class="option-title">{"All events in series"}</div>
|
<div class="option-title">{"All events in series"}</div>
|
||||||
<div class="option-description">{"Change all occurrences in the series"}</div>
|
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||||
@@ -90,4 +95,4 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use crate::components::{Login, ViewMode};
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let on_create_event_request = props.on_create_event_request.clone();
|
let on_create_event_request = props.on_create_event_request.clone();
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
let context_menus_open = props.context_menus_open;
|
let context_menus_open = props.context_menus_open;
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<Switch<Route> render={move |route| {
|
<Switch<Route> render={move |route| {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
@@ -56,7 +66,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let on_create_event_request = on_create_event_request.clone();
|
let on_create_event_request = on_create_event_request.clone();
|
||||||
let on_event_update_request = on_event_update_request.clone();
|
let on_event_update_request = on_event_update_request.clone();
|
||||||
let context_menus_open = context_menus_open;
|
let context_menus_open = context_menus_open;
|
||||||
|
|
||||||
match route {
|
match route {
|
||||||
Route::Home => {
|
Route::Home => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
@@ -74,16 +84,16 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
}
|
}
|
||||||
Route::Calendar => {
|
Route::Calendar => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
html! {
|
html! {
|
||||||
<CalendarView
|
<CalendarView
|
||||||
user_info={user_info}
|
user_info={user_info}
|
||||||
on_event_context_menu={on_event_context_menu}
|
on_event_context_menu={on_event_context_menu}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
view={view}
|
view={view}
|
||||||
on_create_event_request={on_create_event_request}
|
on_create_event_request={on_create_event_request}
|
||||||
on_event_update_request={on_event_update_request}
|
on_event_update_request={on_event_update_request}
|
||||||
context_menus_open={context_menus_open}
|
context_menus_open={context_menus_open}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use crate::services::CalendarService;
|
|
||||||
use crate::components::Calendar;
|
use crate::components::Calendar;
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
|
||||||
|
|
||||||
#[function_component(CalendarView)]
|
#[function_component(CalendarView)]
|
||||||
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
|
||||||
let loading = use_state(|| true);
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
let refreshing_event = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
let on_event_click = {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
Callback::from(move |event: VEvent| {
|
|
||||||
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();
|
|
||||||
|
|
||||||
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()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
|
||||||
Ok(Some(refreshed_event)) => {
|
|
||||||
let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed_vevent.rrule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
|
|
||||||
|
|
||||||
for occurrence in new_occurrences {
|
|
||||||
let date = occurrence.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(occurrence);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let date = refreshed_vevent.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_vevent);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
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) => {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing_event.set(None);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((), move |_| {
|
|
||||||
if let Some(token) = auth_token {
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.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()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(vevents) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
|
||||||
events.set(grouped_events);
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error.set(Some(format!("Failed to load events: {}", err)));
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
loading.set(false);
|
|
||||||
error.set(Some("No authentication token found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
{
|
<Calendar
|
||||||
if *loading {
|
user_info={props.user_info.clone()}
|
||||||
html! {
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
<div class="calendar-loading">
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
<p>{"Loading calendar events..."}</p>
|
view={props.view.clone()}
|
||||||
</div>
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
}
|
on_event_update_request={props.on_event_update_request.clone()}
|
||||||
} else if let Some(err) = (*error).clone() {
|
context_menus_open={props.context_menus_open}
|
||||||
let dummy_callback = Callback::from(|_: VEvent| {});
|
/>
|
||||||
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()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
</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()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::CalendarListItem;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::components::CalendarListItem;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -32,13 +32,18 @@ pub enum Theme {
|
|||||||
Mint,
|
Mint,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Style {
|
||||||
|
Default,
|
||||||
|
Google,
|
||||||
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
|
||||||
pub fn value(&self) -> &'static str {
|
pub fn value(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Theme::Default => "default",
|
Theme::Default => "default",
|
||||||
Theme::Ocean => "ocean",
|
Theme::Ocean => "ocean",
|
||||||
Theme::Forest => "forest",
|
Theme::Forest => "forest",
|
||||||
Theme::Sunset => "sunset",
|
Theme::Sunset => "sunset",
|
||||||
Theme::Purple => "purple",
|
Theme::Purple => "purple",
|
||||||
Theme::Dark => "dark",
|
Theme::Dark => "dark",
|
||||||
@@ -46,7 +51,7 @@ impl Theme {
|
|||||||
Theme::Mint => "mint",
|
Theme::Mint => "mint",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_value(value: &str) -> Self {
|
pub fn from_value(value: &str) -> Self {
|
||||||
match value {
|
match value {
|
||||||
"ocean" => Theme::Ocean,
|
"ocean" => Theme::Ocean,
|
||||||
@@ -61,6 +66,30 @@ impl Theme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
pub fn value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Style::Default => "default",
|
||||||
|
Style::Google => "google",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_value(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"google" => Style::Google,
|
||||||
|
_ => Style::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn stylesheet_path(&self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
||||||
|
Style::Google => Some("google.css"), // Trunk copies to root level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ViewMode {
|
impl Default for ViewMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ViewMode::Month
|
ViewMode::Month
|
||||||
@@ -81,6 +110,8 @@ pub struct SidebarProps {
|
|||||||
pub on_view_change: Callback<ViewMode>,
|
pub on_view_change: Callback<ViewMode>,
|
||||||
pub current_theme: Theme,
|
pub current_theme: Theme,
|
||||||
pub on_theme_change: Callback<Theme>,
|
pub on_theme_change: Callback<Theme>,
|
||||||
|
pub current_style: Style,
|
||||||
|
pub on_style_change: Callback<Style>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(Sidebar)]
|
#[function_component(Sidebar)]
|
||||||
@@ -112,6 +143,18 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_style_change = {
|
||||||
|
let on_style_change = props.on_style_change.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
let value = select.value();
|
||||||
|
let new_style = Style::from_value(&value);
|
||||||
|
on_style_change.emit(new_style);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -167,15 +210,16 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Create Calendar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||||
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||||
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="theme-selector">
|
<div class="theme-selector">
|
||||||
|
<label>{"Theme:"}</label>
|
||||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||||
@@ -187,9 +231,17 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="style-selector">
|
||||||
|
<label>{"Style:"}</label>
|
||||||
|
<select class="style-selector-dropdown" onchange={on_style_change}>
|
||||||
|
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
||||||
|
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct WeekViewProps {
|
pub struct WeekViewProps {
|
||||||
@@ -25,7 +25,17 @@ pub struct WeekViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
NaiveDateTime,
|
||||||
|
NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -47,20 +57,18 @@ struct DragState {
|
|||||||
start_date: NaiveDate,
|
start_date: NaiveDate,
|
||||||
start_y: f64,
|
start_y: f64,
|
||||||
current_y: f64,
|
current_y: f64,
|
||||||
offset_y: f64, // For event moves, this is the offset from the event's top
|
offset_y: f64, // For event moves, this is the offset from the event's top
|
||||||
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(WeekView)]
|
#[function_component(WeekView)]
|
||||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||||
let start_of_week = get_start_of_week(props.current_date);
|
let start_of_week = get_start_of_week(props.current_date);
|
||||||
let week_days: Vec<NaiveDate> = (0..7)
|
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
||||||
.map(|i| start_of_week + Duration::days(i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Drag state for event creation
|
// Drag state for event creation
|
||||||
let drag_state = use_state(|| None::<DragState>);
|
let drag_state = use_state(|| None::<DragState>);
|
||||||
|
|
||||||
// State for recurring event edit modal
|
// State for recurring event edit modal
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
struct PendingRecurringEdit {
|
struct PendingRecurringEdit {
|
||||||
@@ -68,15 +76,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
new_start: NaiveDateTime,
|
new_start: NaiveDateTime,
|
||||||
new_end: NaiveDateTime,
|
new_end: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,21 +96,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate time labels - 24 hours plus the final midnight boundary
|
// Generate time labels - 24 hours plus the final midnight boundary
|
||||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
let mut time_labels: Vec<String> = (0..24)
|
||||||
if hour == 0 {
|
.map(|hour| {
|
||||||
"12 AM".to_string()
|
if hour == 0 {
|
||||||
} else if hour < 12 {
|
"12 AM".to_string()
|
||||||
format!("{} AM", hour)
|
} else if hour < 12 {
|
||||||
} else if hour == 12 {
|
format!("{} AM", hour)
|
||||||
"12 PM".to_string()
|
} else if hour == 12 {
|
||||||
} else {
|
"12 PM".to_string()
|
||||||
format!("{} PM", hour - 12)
|
} else {
|
||||||
}
|
format!("{} PM", hour - 12)
|
||||||
}).collect();
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Add the final midnight boundary to show where the day ends
|
// Add the final midnight boundary to show where the day ends
|
||||||
time_labels.push("12 AM".to_string());
|
time_labels.push("12 AM".to_string());
|
||||||
|
|
||||||
|
|
||||||
// Handlers for recurring event modification modal
|
// Handlers for recurring event modification modal
|
||||||
let on_recurring_choice = {
|
let on_recurring_choice = {
|
||||||
@@ -135,35 +147,35 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Extract occurrence date for backend processing
|
// Extract occurrence date for backend processing
|
||||||
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Send single request to backend with "this_only" scope
|
// Send single request to backend with "this_only" scope
|
||||||
// Backend will atomically:
|
// Backend will atomically:
|
||||||
// 1. Add EXDATE to original series (excludes this occurrence)
|
// 1. Add EXDATE to original series (excludes this occurrence)
|
||||||
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
edit.event.clone(), // Original event (series to modify)
|
edit.event.clone(), // Original event (series to modify)
|
||||||
edit.new_start, // Dragged start time for exception
|
edit.new_start, // Dragged start time for exception
|
||||||
edit.new_end, // Dragged end time for exception
|
edit.new_end, // Dragged end time for exception
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
None, // No until_date for this_only
|
None, // No until_date for this_only
|
||||||
Some("this_only".to_string()), // Update scope
|
Some("this_only".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::FutureEvents => {
|
RecurringEditAction::FutureEvents => {
|
||||||
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
||||||
//
|
//
|
||||||
// When a user chooses to modify "this and future events" for a recurring series,
|
// When a user chooses to modify "this and future events" for a recurring series,
|
||||||
// we implement a series split operation that:
|
// we implement a series split operation that:
|
||||||
//
|
//
|
||||||
// 1. **Terminates Original Series**: The existing series is updated with an UNTIL
|
// 1. **Terminates Original Series**: The existing series is updated with an UNTIL
|
||||||
// clause to stop before the occurrence being modified
|
// clause to stop before the occurrence being modified
|
||||||
// 2. **Creates New Series**: A new recurring series is created starting from the
|
// 2. **Creates New Series**: A new recurring series is created starting from the
|
||||||
// occurrence date with the user's modifications (new time, title, etc.)
|
// occurrence date with the user's modifications (new time, title, etc.)
|
||||||
//
|
//
|
||||||
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
||||||
// - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)
|
// - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)
|
||||||
// - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely
|
// - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely
|
||||||
//
|
//
|
||||||
// This approach ensures:
|
// This approach ensures:
|
||||||
@@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Find the original series event (not the occurrence)
|
// Find the original series event (not the occurrence)
|
||||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
||||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') {
|
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
||||||
|
{
|
||||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||||
// Check if suffix is numeric (timestamp), if so remove it
|
// Check if suffix is numeric (timestamp), if so remove it
|
||||||
if suffix.chars().all(|c| c.is_numeric()) {
|
if suffix.chars().all(|c| c.is_numeric()) {
|
||||||
@@ -188,9 +201,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
edit.event.uid.clone()
|
edit.event.uid.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 Looking for original series: '{}' from occurrence: '{}'",
|
||||||
|
base_uid, edit.event.uid
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
// Find the original series event by searching for the base UID
|
// Find the original series event by searching for the base UID
|
||||||
let mut original_series = None;
|
let mut original_series = None;
|
||||||
for events_list in events.values() {
|
for events_list in events.values() {
|
||||||
@@ -204,12 +223,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let original_series = match original_series {
|
let original_series = match original_series {
|
||||||
Some(series) => {
|
Some(series) => {
|
||||||
web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("✅ Found original series: '{}'", series.uid)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
series
|
series
|
||||||
},
|
}
|
||||||
None => {
|
None => {
|
||||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
||||||
let mut fallback_event = edit.event.clone();
|
let mut fallback_event = edit.event.clone();
|
||||||
@@ -218,55 +240,69 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
fallback_event
|
fallback_event
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
let until_date =
|
||||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
let until_datetime = until_date
|
||||||
|
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||||
|
let until_utc =
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||||
|
until_datetime,
|
||||||
|
chrono::Utc,
|
||||||
|
);
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
||||||
|
|
||||||
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
||||||
// This ensures the new series reflects the user's drag operation
|
// This ensures the new series reflects the user's drag operation
|
||||||
let new_start = edit.new_start; // The dragged start time
|
let new_start = edit.new_start; // The dragged start time
|
||||||
let new_end = edit.new_end; // The dragged end time
|
let new_end = edit.new_end; // The dragged end time
|
||||||
|
|
||||||
// Extract occurrence date from the dragged event for backend processing
|
// Extract occurrence date from the dragged event for backend processing
|
||||||
// Format: YYYY-MM-DD (e.g., "2025-08-22")
|
// Format: YYYY-MM-DD (e.g., "2025-08-22")
|
||||||
// This tells the backend which specific occurrence is being modified
|
// This tells the backend which specific occurrence is being modified
|
||||||
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Send single request to backend with "this_and_future" scope
|
// Send single request to backend with "this_and_future" scope
|
||||||
// Backend will atomically:
|
// Backend will atomically:
|
||||||
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
||||||
// 2. Create new series starting from occurrence_date with dragged times
|
// 2. Create new series starting from occurrence_date with dragged times
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
original_series, // Original event to terminate
|
original_series, // Original event to terminate
|
||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_utc), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::AllEvents => {
|
RecurringEditAction::AllEvents => {
|
||||||
// Modify the entire series
|
// Modify the entire series
|
||||||
let series_event = edit.event.clone();
|
let series_event = edit.event.clone();
|
||||||
|
|
||||||
if let Some(callback) = &on_event_update {
|
if let Some(callback) = &on_event_update {
|
||||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
callback.emit((
|
||||||
|
series_event,
|
||||||
|
edit.new_start,
|
||||||
|
edit.new_end,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some("all_in_series".to_string()),
|
||||||
|
None,
|
||||||
|
)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending_recurring_edit.set(None);
|
pending_recurring_edit.set(None);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_recurring_cancel = {
|
let on_recurring_cancel = {
|
||||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
@@ -283,7 +319,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
week_days.iter().map(|date| {
|
week_days.iter().map(|date| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
let weekday_name = get_weekday_name(date.weekday());
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||||
<div class="weekday-name">{weekday_name}</div>
|
<div class="weekday-name">{weekday_name}</div>
|
||||||
@@ -293,7 +329,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Scrollable content area with time grid
|
// Scrollable content area with time grid
|
||||||
<div class="week-content">
|
<div class="week-content">
|
||||||
<div class="time-grid">
|
<div class="time-grid">
|
||||||
@@ -310,18 +346,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Day columns
|
// Day columns
|
||||||
<div class="week-days-grid">
|
<div class="week-days-grid">
|
||||||
{
|
{
|
||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Drag event handlers
|
// Drag event handlers
|
||||||
let drag_state_clone = drag_state.clone();
|
let drag_state_clone = drag_state.clone();
|
||||||
let date_for_drag = *date;
|
let date_for_drag = *date;
|
||||||
|
|
||||||
let onmousedown = {
|
let onmousedown = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let context_menus_open = props.context_menus_open;
|
let context_menus_open = props.context_menus_open;
|
||||||
@@ -331,20 +367,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if context_menus_open {
|
if context_menus_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle left-click (button 0)
|
// Only handle left-click (button 0)
|
||||||
if e.button() != 0 {
|
if e.button() != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Y position relative to day column container
|
// Calculate Y position relative to day column container
|
||||||
// Use layer_y which gives coordinates relative to positioned ancestor
|
// Use layer_y which gives coordinates relative to positioned ancestor
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Snap to increment
|
// Snap to increment
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::CreateEvent,
|
drag_type: DragType::CreateEvent,
|
||||||
@@ -357,7 +393,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmousemove = {
|
let onmousemove = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
@@ -367,27 +403,27 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Use layer_y for consistent coordinate calculation
|
// Use layer_y for consistent coordinate calculation
|
||||||
let mouse_y = e.layer_y() as f64;
|
let mouse_y = e.layer_y() as f64;
|
||||||
let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 };
|
let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// For move operations, we now follow the mouse directly since we start at click position
|
// For move operations, we now follow the mouse directly since we start at click position
|
||||||
// For resize operations, we still use the mouse position directly
|
// For resize operations, we still use the mouse position directly
|
||||||
let adjusted_y = mouse_y;
|
let adjusted_y = mouse_y;
|
||||||
|
|
||||||
// Snap to increment
|
// Snap to increment
|
||||||
let snapped_y = snap_to_increment(adjusted_y, time_increment);
|
let snapped_y = snap_to_increment(adjusted_y, time_increment);
|
||||||
|
|
||||||
// Check if we've moved enough to constitute a real drag (5 pixels minimum)
|
// Check if we've moved enough to constitute a real drag (5 pixels minimum)
|
||||||
let movement_distance = (snapped_y - current_drag.start_y).abs();
|
let movement_distance = (snapped_y - current_drag.start_y).abs();
|
||||||
if movement_distance > 5.0 {
|
if movement_distance > 5.0 {
|
||||||
current_drag.has_moved = true;
|
current_drag.has_moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
current_drag.current_y = snapped_y;
|
current_drag.current_y = snapped_y;
|
||||||
drag_state.set(Some(current_drag));
|
drag_state.set(Some(current_drag));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmouseup = {
|
let onmouseup = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let on_create_event = props.on_create_event.clone();
|
let on_create_event = props.on_create_event.clone();
|
||||||
@@ -402,24 +438,24 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Calculate start and end times
|
// Calculate start and end times
|
||||||
let start_time = pixels_to_time(current_drag.start_y);
|
let start_time = pixels_to_time(current_drag.start_y);
|
||||||
let end_time = pixels_to_time(current_drag.current_y);
|
let end_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Ensure start is before end
|
// Ensure start is before end
|
||||||
let (actual_start, actual_end) = if start_time <= end_time {
|
let (actual_start, actual_end) = if start_time <= end_time {
|
||||||
(start_time, end_time)
|
(start_time, end_time)
|
||||||
} else {
|
} else {
|
||||||
(end_time, start_time)
|
(end_time, start_time)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure minimum duration (15 minutes)
|
// Ensure minimum duration (15 minutes)
|
||||||
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
||||||
actual_start + chrono::Duration::minutes(15)
|
actual_start + chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
actual_end
|
actual_end
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
||||||
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
||||||
|
|
||||||
if let Some(callback) = &on_create_event {
|
if let Some(callback) = &on_create_event {
|
||||||
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
||||||
}
|
}
|
||||||
@@ -430,17 +466,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
||||||
let new_start_time = pixels_to_time(event_top_position);
|
let new_start_time = pixels_to_time(event_top_position);
|
||||||
|
|
||||||
// Calculate duration from original event
|
// Calculate duration from original event
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
end.signed_duration_since(event.dtstart)
|
end.signed_duration_since(event.dtstart)
|
||||||
} else {
|
} else {
|
||||||
chrono::Duration::hours(1) // Default 1 hour
|
chrono::Duration::hours(1) // Default 1 hour
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
let new_end_datetime = new_start_datetime + original_duration;
|
let new_end_datetime = new_start_datetime + original_duration;
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -459,7 +495,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// Calculate new start time based on drag position
|
// Calculate new start time based on drag position
|
||||||
let new_start_time = pixels_to_time(current_drag.current_y);
|
let new_start_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Keep the original end time
|
// Keep the original end time
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end.with_timezone(&chrono::Local).naive_local()
|
||||||
@@ -467,16 +503,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// If no end time, use start time + 1 hour as default
|
// If no end time, use start time + 1 hour as default
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
|
|
||||||
// Ensure start is before end (minimum 15 minutes)
|
// Ensure start is before end (minimum 15 minutes)
|
||||||
let new_end_datetime = if new_start_datetime >= original_end {
|
let new_end_datetime = if new_start_datetime >= original_end {
|
||||||
new_start_datetime + chrono::Duration::minutes(15)
|
new_start_datetime + chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
original_end
|
original_end
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -495,19 +531,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Calculate new end time based on drag position
|
// Calculate new end time based on drag position
|
||||||
let new_end_time = pixels_to_time(current_drag.current_y);
|
let new_end_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Keep the original start time
|
// Keep the original start time
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
|
|
||||||
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
||||||
|
|
||||||
// Ensure end is after start (minimum 15 minutes)
|
// Ensure end is after start (minimum 15 minutes)
|
||||||
let new_start_datetime = if new_end_datetime <= original_start {
|
let new_start_datetime = if new_end_datetime <= original_start {
|
||||||
new_end_datetime - chrono::Duration::minutes(15)
|
new_end_datetime - chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
original_start
|
original_start
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -524,15 +560,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drag_state.set(None);
|
drag_state.set(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
{onmousemove}
|
{onmousemove}
|
||||||
@@ -549,26 +585,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
// Final boundary slot to complete the 24-hour visual grid - make it interactive like other slots
|
|
||||||
<div class="time-slot boundary-slot">
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Events positioned absolutely based on their actual times
|
// Events positioned absolutely based on their actual times
|
||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().filter_map(|event| {
|
day_events.iter().filter_map(|event| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||||
|
|
||||||
// Skip events that don't belong on this date or have invalid positioning
|
// Skip events that don't belong on this date or have invalid positioning
|
||||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
let onclick = {
|
let onclick = {
|
||||||
let on_event_click = props.on_event_click.clone();
|
let on_event_click = props.on_event_click.clone();
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
@@ -577,7 +608,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
on_event_click.emit(event.clone());
|
on_event_click.emit(event.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmousedown_event = {
|
let onmousedown_event = {
|
||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_drag = event.clone();
|
let event_for_drag = event.clone();
|
||||||
@@ -585,27 +616,27 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let _time_increment = props.time_increment;
|
let _time_increment = props.time_increment;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||||
|
|
||||||
// Only handle left-click (button 0) for moving
|
// Only handle left-click (button 0) for moving
|
||||||
if e.button() != 0 {
|
if e.button() != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate click position relative to event element
|
// Calculate click position relative to event element
|
||||||
let click_y_relative = e.layer_y() as f64;
|
let click_y_relative = e.layer_y() as f64;
|
||||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Get event's current position in day column coordinates
|
// Get event's current position in day column coordinates
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
let event_start_pixels = event_start_pixels as f64;
|
||||||
|
|
||||||
// Convert click position to day column coordinates
|
// Convert click position to day column coordinates
|
||||||
let click_y = event_start_pixels + click_y_relative;
|
let click_y = event_start_pixels + click_y_relative;
|
||||||
|
|
||||||
// Store the offset from the event's top where the user clicked
|
// Store the offset from the event's top where the user clicked
|
||||||
// This will be used to maintain the relative click position
|
// This will be used to maintain the relative click position
|
||||||
let offset_y = click_y_relative;
|
let offset_y = click_y_relative;
|
||||||
|
|
||||||
// Start drag tracking from where we clicked (in day column coordinates)
|
// Start drag tracking from where we clicked (in day column coordinates)
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
@@ -619,7 +650,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let oncontextmenu = {
|
let oncontextmenu = {
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
if let Some(callback) = &props.on_event_context_menu {
|
||||||
let callback = callback.clone();
|
let callback = callback.clone();
|
||||||
@@ -633,7 +664,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
||||||
callback.emit((e, event.clone()));
|
callback.emit((e, event.clone()));
|
||||||
@@ -642,7 +673,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format time display for the event
|
// Format time display for the event
|
||||||
let time_display = if event.all_day {
|
let time_display = if event.all_day {
|
||||||
"All Day".to_string()
|
"All Day".to_string()
|
||||||
@@ -650,20 +681,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
if let Some(end) = event.dtend {
|
if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end.with_timezone(&Local);
|
||||||
|
|
||||||
// Check if both times are in same AM/PM period to avoid redundancy
|
// Check if both times are in same AM/PM period to avoid redundancy
|
||||||
let start_is_am = local_start.hour() < 12;
|
let start_is_am = local_start.hour() < 12;
|
||||||
let end_is_am = local_end.hour() < 12;
|
let end_is_am = local_end.hour() < 12;
|
||||||
|
|
||||||
if start_is_am == end_is_am {
|
if start_is_am == end_is_am {
|
||||||
// Same AM/PM period - show "9:00 - 10:30 AM"
|
// Same AM/PM period - show "9:00 - 10:30 AM"
|
||||||
format!("{} - {}",
|
format!("{} - {}",
|
||||||
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
||||||
local_end.format("%I:%M %p")
|
local_end.format("%I:%M %p")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
||||||
format!("{} - {}",
|
format!("{} - {}",
|
||||||
local_start.format("%I:%M %p"),
|
local_start.format("%I:%M %p"),
|
||||||
local_end.format("%I:%M %p")
|
local_end.format("%I:%M %p")
|
||||||
)
|
)
|
||||||
@@ -673,22 +704,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
format!("{}", local_start.format("%I:%M %p"))
|
format!("{}", local_start.format("%I:%M %p"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this event is currently being dragged or resized
|
// Check if this event is currently being dragged or resized
|
||||||
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
||||||
match &drag.drag_type {
|
match &drag.drag_type {
|
||||||
DragType::MoveEvent(dragged_event) =>
|
DragType::MoveEvent(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
DragType::ResizeEventStart(dragged_event) =>
|
DragType::ResizeEventStart(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
DragType::ResizeEventEnd(dragged_event) =>
|
DragType::ResizeEventEnd(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_being_dragged {
|
if is_being_dragged {
|
||||||
// Hide the original event while being dragged
|
// Hide the original event while being dragged
|
||||||
Some(html! {})
|
Some(html! {})
|
||||||
@@ -701,11 +732,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: web_sys::MouseEvent| {
|
Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
e.stop_propagation();
|
e.stop_propagation();
|
||||||
|
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
|
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
|
||||||
@@ -718,7 +749,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let resize_end_handler = {
|
let resize_end_handler = {
|
||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_resize = event.clone();
|
let event_for_resize = event.clone();
|
||||||
@@ -726,11 +757,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: web_sys::MouseEvent| {
|
Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
e.stop_propagation();
|
e.stop_propagation();
|
||||||
|
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
|
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
|
||||||
@@ -743,18 +774,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(html! {
|
Some(html! {
|
||||||
<div
|
<div
|
||||||
class={classes!(
|
class={classes!(
|
||||||
"week-event",
|
"week-event",
|
||||||
if is_refreshing { Some("refreshing") } else { None },
|
if is_refreshing { Some("refreshing") } else { None },
|
||||||
if is_all_day { Some("all-day") } else { None }
|
if is_all_day { Some("all-day") } else { None }
|
||||||
)}
|
)}
|
||||||
style={format!(
|
style={format!(
|
||||||
"background-color: {}; top: {}px; height: {}px;",
|
"background-color: {}; top: {}px; height: {}px;",
|
||||||
event_color,
|
event_color,
|
||||||
start_pixels,
|
start_pixels,
|
||||||
duration_pixels
|
duration_pixels
|
||||||
)}
|
)}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -764,7 +795,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Top resize handle
|
// Top resize handle
|
||||||
{if !is_all_day {
|
{if !is_all_day {
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="resize-handle resize-handle-top"
|
class="resize-handle resize-handle-top"
|
||||||
onmousedown={resize_start_handler}
|
onmousedown={resize_start_handler}
|
||||||
/>
|
/>
|
||||||
@@ -772,7 +803,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Event content
|
// Event content
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
@@ -782,11 +813,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Bottom resize handle
|
// Bottom resize handle
|
||||||
{if !is_all_day {
|
{if !is_all_day {
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="resize-handle resize-handle-bottom"
|
class="resize-handle resize-handle-bottom"
|
||||||
onmousedown={resize_end_handler}
|
onmousedown={resize_end_handler}
|
||||||
/>
|
/>
|
||||||
@@ -800,7 +831,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Temporary event box during drag
|
// Temporary event box during drag
|
||||||
{
|
{
|
||||||
if let Some(drag) = (*drag_state).clone() {
|
if let Some(drag) = (*drag_state).clone() {
|
||||||
@@ -810,11 +841,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let start_y = drag.start_y.min(drag.current_y);
|
let start_y = drag.start_y.min(drag.current_y);
|
||||||
let end_y = drag.start_y.max(drag.current_y);
|
let end_y = drag.start_y.max(drag.current_y);
|
||||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
||||||
|
|
||||||
// Convert pixels to times for display
|
// Convert pixels to times for display
|
||||||
let start_time = pixels_to_time(start_y);
|
let start_time = pixels_to_time(start_y);
|
||||||
let end_time = pixels_to_time(end_y);
|
let end_time = pixels_to_time(end_y);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box"
|
class="temp-event-box"
|
||||||
@@ -837,9 +868,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
||||||
let new_end_time = new_start_time + original_duration;
|
let new_end_time = new_start_time + original_duration;
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box moving-event"
|
class="temp-event-box moving-event"
|
||||||
@@ -858,17 +889,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
||||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
||||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||||
|
|
||||||
let new_start_pixels = drag.current_y;
|
let new_start_pixels = drag.current_y;
|
||||||
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
|
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
@@ -883,15 +914,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Show the event being resized from the end
|
// Show the event being resized from the end
|
||||||
let new_end_time = pixels_to_time(drag.current_y);
|
let new_end_time = pixels_to_time(drag.current_y);
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
||||||
|
|
||||||
let new_end_pixels = drag.current_y;
|
let new_end_pixels = drag.current_y;
|
||||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
@@ -917,10 +948,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Recurring event modification modal
|
// Recurring event modification modal
|
||||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||||
<RecurringEditModal
|
<RecurringEditModal
|
||||||
show={true}
|
show={true}
|
||||||
event={edit.event}
|
event={edit.event}
|
||||||
new_start={edit.new_start}
|
new_start={edit.new_start}
|
||||||
@@ -975,46 +1006,49 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|||||||
let total_minutes = pixels; // 1px = 1 minute
|
let total_minutes = pixels; // 1px = 1 minute
|
||||||
let hours = (total_minutes / 60.0) as u32;
|
let hours = (total_minutes / 60.0) as u32;
|
||||||
let minutes = (total_minutes % 60.0) as u32;
|
let minutes = (total_minutes % 60.0) as u32;
|
||||||
|
|
||||||
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
||||||
if total_minutes >= 1440.0 {
|
if total_minutes >= 1440.0 {
|
||||||
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to valid time range for within-day times
|
// Clamp to valid time range for within-day times
|
||||||
let hours = hours.min(23);
|
let hours = hours.min(23);
|
||||||
let minutes = minutes.min(59);
|
let minutes = minutes.min(59);
|
||||||
|
|
||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
let event_date = local_start.date_naive();
|
let event_date = local_start.date_naive();
|
||||||
|
|
||||||
|
// Position events based on when they appear in local time, not their original date
|
||||||
|
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
||||||
|
// but should still display on Sunday's column since that's when the user sees it
|
||||||
|
let should_display_here = event_date == date ||
|
||||||
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
||||||
|
|
||||||
// Only position events that are on this specific date
|
if !should_display_here {
|
||||||
if event_date != date {
|
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle all-day events - they appear at the top
|
// Handle all-day events - they appear at the top
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate start position in pixels from midnight
|
// Calculate start position in pixels from midnight
|
||||||
let start_hour = local_start.hour() as f32;
|
let start_hour = local_start.hour() as f32;
|
||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||||
|
|
||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end.with_timezone(&Local);
|
||||||
let end_date = local_end.date_naive();
|
let end_date = local_end.date_naive();
|
||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
// Handle events that span multiple days by capping at midnight
|
||||||
if end_date > date {
|
if end_date > date {
|
||||||
// Event continues past midnight, cap at 24:00 (1440px)
|
// Event continues past midnight, cap at 24:00 (1440px)
|
||||||
@@ -1028,6 +1062,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
} else {
|
} else {
|
||||||
60.0 // Default 1 hour if no end time
|
60.0 // Default 1 hour if no end time
|
||||||
};
|
};
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::env;
|
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
|
||||||
///
|
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
|
||||||
/// including server URL, credentials, and optional collection paths.
|
|
||||||
///
|
|
||||||
/// # Security Note
|
|
||||||
///
|
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
|
||||||
/// `Debug` that masks the password field.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// // Load configuration from environment variables
|
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
|
||||||
///
|
|
||||||
/// // Use the configuration for HTTP requests
|
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CalDAVConfig {
|
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
|
||||||
pub server_url: String,
|
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
|
||||||
pub username: String,
|
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
|
||||||
///
|
|
||||||
/// **Security Note**: This contains sensitive information
|
|
||||||
pub password: String,
|
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
|
||||||
///
|
|
||||||
/// If not provided, the client will need to discover available calendars
|
|
||||||
/// through CalDAV PROPFIND requests
|
|
||||||
pub calendar_path: Option<String>,
|
|
||||||
|
|
||||||
/// Optional path to the tasks/todo collection on the server
|
|
||||||
///
|
|
||||||
/// Some CalDAV servers store tasks separately from calendar events
|
|
||||||
pub tasks_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalDAVConfig {
|
|
||||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
|
||||||
///
|
|
||||||
/// This method will attempt to load a `.env` file from the current directory
|
|
||||||
/// and then read the following required environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
|
||||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
|
||||||
///
|
|
||||||
/// Optional environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
|
||||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
|
||||||
/// is not set or cannot be read.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// match CalDAVConfig::from_env() {
|
|
||||||
/// Ok(config) => {
|
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
|
||||||
/// }
|
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
let server_url = env::var("CALDAV_SERVER_URL")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
|
||||||
|
|
||||||
let username = env::var("CALDAV_USERNAME")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
|
||||||
|
|
||||||
let password = env::var("CALDAV_PASSWORD")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
|
||||||
|
|
||||||
// Optional paths - it's fine if these are not set
|
|
||||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
|
||||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
|
||||||
server_url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
calendar_path,
|
|
||||||
tasks_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
|
||||||
///
|
|
||||||
/// This method combines the username and password in the format
|
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A Base64-encoded string that can be used directly in the
|
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// let config = CalDAVConfig {
|
|
||||||
/// server_url: "https://example.com".to_string(),
|
|
||||||
/// username: "user".to_string(),
|
|
||||||
/// password: "pass".to_string(),
|
|
||||||
/// calendar_path: None,
|
|
||||||
/// tasks_path: None,
|
|
||||||
/// };
|
|
||||||
///
|
|
||||||
/// let auth_value = config.get_basic_auth();
|
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
|
||||||
/// ```
|
|
||||||
pub fn get_basic_auth(&self) -> String {
|
|
||||||
let credentials = format!("{}:{}", self.username, self.password);
|
|
||||||
BASE64_STANDARD.encode(&credentials)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that can occur when loading or using CalDAV configuration.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ConfigError {
|
|
||||||
/// A required environment variable is missing or cannot be read.
|
|
||||||
///
|
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
|
||||||
#[error("Missing environment variable: {0}")]
|
|
||||||
MissingVar(String),
|
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
|
||||||
///
|
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
|
||||||
#[error("Invalid configuration: {0}")]
|
|
||||||
Invalid(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_basic_auth_encoding() {
|
|
||||||
let config = CalDAVConfig {
|
|
||||||
server_url: "https://example.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
password: "testpass".to_string(),
|
|
||||||
calendar_path: None,
|
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
|
||||||
let expected = BASE64_STANDARD.encode("testuser:testpass");
|
|
||||||
assert_eq!(auth, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
|
||||||
///
|
|
||||||
/// This test requires a valid .env file with:
|
|
||||||
/// - CALDAV_SERVER_URL
|
|
||||||
/// - CALDAV_USERNAME
|
|
||||||
/// - CALDAV_PASSWORD
|
|
||||||
///
|
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_baikal_auth() {
|
|
||||||
// Load config from .env
|
|
||||||
let config = CalDAVConfig::from_env()
|
|
||||||
.expect("Failed to load CalDAV config from environment");
|
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
|
||||||
|
|
||||||
// Create HTTP client
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Make a simple OPTIONS request to test authentication
|
|
||||||
let response = client
|
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to send request to CalDAV server");
|
|
||||||
|
|
||||||
println!("Response status: {}", response.status());
|
|
||||||
println!("Response headers: {:#?}", response.headers());
|
|
||||||
|
|
||||||
// Check if we got a successful response or at least not a 401 Unauthorized
|
|
||||||
assert!(
|
|
||||||
response.status().is_success() || response.status() != 401,
|
|
||||||
"Authentication failed with status: {}. Check your credentials in .env",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
|
||||||
assert!(
|
|
||||||
response.headers().contains_key("dav") ||
|
|
||||||
response.headers().contains_key("DAV") ||
|
|
||||||
response.status().is_success(),
|
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
|
||||||
);
|
|
||||||
|
|
||||||
println!("✓ Authentication test passed!");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
|
||||||
///
|
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
|
||||||
///
|
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_propfind_calendars() {
|
|
||||||
let config = CalDAVConfig::from_env()
|
|
||||||
.expect("Failed to load CalDAV config from environment");
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// CalDAV PROPFIND request to discover calendars
|
|
||||||
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<d:prop>
|
|
||||||
<d:resourcetype />
|
|
||||||
<d:displayname />
|
|
||||||
<c:calendar-description />
|
|
||||||
<c:supported-calendar-component-set />
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>"#;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
|
||||||
.header("Content-Type", "application/xml")
|
|
||||||
.header("Depth", "1")
|
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
|
||||||
.body(propfind_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to send PROPFIND request");
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
println!("PROPFIND Response status: {}", status);
|
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
|
||||||
println!("PROPFIND Response body: {}", body);
|
|
||||||
|
|
||||||
// We should get a 207 Multi-Status for PROPFIND
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
reqwest::StatusCode::from_u16(207).unwrap(),
|
|
||||||
"PROPFIND should return 207 Multi-Status"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
@@ -9,4 +8,4 @@ use app::App;
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
yew::Renderer::<App>::new().render();
|
yew::Renderer::<App>::new().render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Re-export from shared calendar-models library for backward compatibility
|
// Re-export from shared calendar-models library for backward compatibility
|
||||||
pub use calendar_models::*;
|
pub use calendar_models::*;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// RFC 5545 Compliant iCalendar Models
|
// RFC 5545 Compliant iCalendar Models
|
||||||
pub mod ical;
|
pub mod ical;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
// pub use ical::VEvent;
|
// pub use ical::VEvent;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
|
pub mod preferences;
|
||||||
|
|
||||||
pub use calendar_service::CalendarService;
|
pub use calendar_service::CalendarService;
|
||||||
|
|||||||
180
frontend/src/services/preferences.rs
Normal file
180
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferences {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct UpdatePreferencesRequest {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct PreferencesService {
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl PreferencesService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
|
.unwrap_or("http://localhost:3000/api")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Self { base_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load preferences from LocalStorage (cached from login)
|
||||||
|
pub fn load_cached() -> Option<UserPreferences> {
|
||||||
|
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
|
||||||
|
serde_json::from_str(&prefs_json).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a single preference field and sync with backend
|
||||||
|
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
|
||||||
|
// Get session token
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
// Load current preferences
|
||||||
|
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: None,
|
||||||
|
calendar_view_mode: None,
|
||||||
|
calendar_theme: None,
|
||||||
|
calendar_colors: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the specific field
|
||||||
|
match field {
|
||||||
|
"calendar_selected_date" => {
|
||||||
|
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_time_increment" => {
|
||||||
|
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
|
||||||
|
}
|
||||||
|
"calendar_view_mode" => {
|
||||||
|
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_theme" => {
|
||||||
|
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_colors" => {
|
||||||
|
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
_ => return Err(format!("Unknown preference field: {}", field)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to LocalStorage cache
|
||||||
|
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||||
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync with backend
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date.clone(),
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
||||||
|
calendar_theme: preferences.calendar_theme.clone(),
|
||||||
|
calendar_colors: preferences.calendar_colors.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.sync_preferences(&session_token, &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync all preferences with backend
|
||||||
|
async fn sync_preferences(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
request: &UpdatePreferencesRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let json_body = serde_json::to_string(request)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
|
||||||
|
|
||||||
|
let url = format!("{}/preferences", self.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("X-Session-Token", session_token)
|
||||||
|
.map_err(|e| format!("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))?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Failed to update preferences: {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate preferences from LocalStorage to backend (on first login after update)
|
||||||
|
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
|
||||||
|
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
|
||||||
|
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
||||||
|
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
||||||
|
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only migrate if we have some preferences to migrate
|
||||||
|
if request.calendar_selected_date.is_some()
|
||||||
|
|| request.calendar_time_increment.is_some()
|
||||||
|
|| request.calendar_view_mode.is_some()
|
||||||
|
|| request.calendar_theme.is_some()
|
||||||
|
|| request.calendar_colors.is_some()
|
||||||
|
{
|
||||||
|
self.sync_preferences(&session_token, &request).await?;
|
||||||
|
|
||||||
|
// Clear old LocalStorage entries after successful migration
|
||||||
|
let _ = LocalStorage::delete("calendar_selected_date");
|
||||||
|
let _ = LocalStorage::delete("calendar_time_increment");
|
||||||
|
let _ = LocalStorage::delete("calendar_view_mode");
|
||||||
|
let _ = LocalStorage::delete("calendar_theme");
|
||||||
|
let _ = LocalStorage::delete("calendar_colors");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,120 @@
|
|||||||
|
/* Base Styles - Always Loaded */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* CSS Variables for Style System */
|
||||||
|
--border-radius-small: 4px;
|
||||||
|
--border-radius-medium: 8px;
|
||||||
|
--border-radius-large: 12px;
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 8px 25px rgba(0,0,0,0.15);
|
||||||
|
--border-light: 1px solid #e9ecef;
|
||||||
|
--border-medium: 1px solid #dee2e6;
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Layout */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 280px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Basic Form Elements */
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Data Attributes for Color Schemes */
|
||||||
|
[data-theme="default"] {
|
||||||
|
--primary-color: #667eea;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="ocean"] {
|
||||||
|
--primary-color: #006994;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #006994 0%, #0891b2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="forest"] {
|
||||||
|
--primary-color: #065f46;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #065f46 0%, #047857 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="sunset"] {
|
||||||
|
--primary-color: #ea580c;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #ea580c 0%, #dc2626 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="purple"] {
|
||||||
|
--primary-color: #7c3aed;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--primary-color: #374151;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="rose"] {
|
||||||
|
--primary-color: #e11d48;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #e11d48 0%, #f43f5e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="mint"] {
|
||||||
|
--primary-color: #10b981;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
}* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
@@ -289,6 +400,30 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remember-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button, .register-button {
|
.login-button, .register-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -550,12 +685,13 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-height: 0; /* Allow flex item to shrink below content size */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-grid {
|
.time-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr;
|
grid-template-columns: 80px 1fr;
|
||||||
min-height: 100%;
|
min-height: 1530px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time Labels */
|
/* Time Labels */
|
||||||
@@ -565,6 +701,7 @@ body {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
min-height: 1440px; /* Match the time slots height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-label {
|
.time-label {
|
||||||
@@ -590,12 +727,13 @@ body {
|
|||||||
.week-days-grid {
|
.week-days-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-column {
|
.week-day-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||||
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
|
min-height: 1440px; /* 24 time slots × 60px = 1440px total */
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-column:last-child {
|
.week-day-column:last-child {
|
||||||
@@ -2990,6 +3128,50 @@ body {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Style Selector Styles */
|
||||||
|
.style-selector {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown option {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme Definitions */
|
/* Theme Definitions */
|
||||||
:root {
|
:root {
|
||||||
/* Default Theme */
|
/* Default Theme */
|
||||||
|
|||||||
3501
frontend/styles.css.backup
Normal file
3501
frontend/styles.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/styles/base.css
Normal file
51
frontend/styles/base.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/* Base Styles - Always Loaded */
|
||||||
|
* {
|
||||||
|
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: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Layout */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 280px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Basic Form Elements */
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
3501
frontend/styles/default.css
Normal file
3501
frontend/styles/default.css
Normal file
File diff suppressed because it is too large
Load Diff
645
frontend/styles/google.css
Normal file
645
frontend/styles/google.css
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
/* Google Calendar-inspired styles */
|
||||||
|
|
||||||
|
/* Override CSS Variables for Google Calendar Style */
|
||||||
|
:root {
|
||||||
|
/* Google-style spacing */
|
||||||
|
--spacing-xs: 2px;
|
||||||
|
--spacing-sm: 4px;
|
||||||
|
--spacing-md: 8px;
|
||||||
|
--spacing-lg: 12px;
|
||||||
|
--spacing-xl: 16px;
|
||||||
|
|
||||||
|
/* Google-style borders and radius */
|
||||||
|
--border-radius-small: 2px;
|
||||||
|
--border-radius-medium: 4px;
|
||||||
|
--border-radius-large: 8px;
|
||||||
|
--border-light: 1px solid #dadce0;
|
||||||
|
--border-medium: 1px solid #dadce0;
|
||||||
|
|
||||||
|
/* Google-style shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
|
||||||
|
--shadow-md: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15);
|
||||||
|
--shadow-lg: 0 4px 6px 0 rgba(60,64,67,.3), 0 8px 25px 5px rgba(60,64,67,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style sidebar - override all theme variants */
|
||||||
|
body .app-sidebar,
|
||||||
|
[data-theme] .app-sidebar,
|
||||||
|
[data-theme="default"] .app-sidebar,
|
||||||
|
[data-theme="ocean"] .app-sidebar,
|
||||||
|
[data-theme="forest"] .app-sidebar,
|
||||||
|
[data-theme="sunset"] .app-sidebar,
|
||||||
|
[data-theme="purple"] .app-sidebar,
|
||||||
|
[data-theme="dark"] .app-sidebar,
|
||||||
|
[data-theme="rose"] .app-sidebar,
|
||||||
|
[data-theme="mint"] .app-sidebar {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border-right: 1px solid #dadce0 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
box-shadow: 2px 0 8px rgba(60,64,67,.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .sidebar-header,
|
||||||
|
[data-theme] .sidebar-header {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 1px solid #dadce0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .sidebar-header h1,
|
||||||
|
[data-theme] .sidebar-header h1 {
|
||||||
|
font-size: 20px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .user-info,
|
||||||
|
[data-theme] .user-info {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .user-info .username,
|
||||||
|
[data-theme] .user-info .username {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .user-info .server-url,
|
||||||
|
[data-theme] .user-info .server-url {
|
||||||
|
color: #5f6368 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style buttons */
|
||||||
|
.create-calendar-button {
|
||||||
|
background: #1a73e8 !important;
|
||||||
|
color: white !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
|
||||||
|
transition: box-shadow 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-button:hover {
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #1a73e8 !important;
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: background-color 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style navigation and sidebar text */
|
||||||
|
body .sidebar-nav .nav-link,
|
||||||
|
[data-theme] .sidebar-nav .nav-link {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .sidebar-nav .nav-link:hover,
|
||||||
|
[data-theme] .sidebar-nav .nav-link:hover {
|
||||||
|
color: #1a73e8 !important;
|
||||||
|
background: #f1f3f4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar list styling */
|
||||||
|
body .calendar-list h3,
|
||||||
|
[data-theme] .calendar-list h3 {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .calendar-list .calendar-name,
|
||||||
|
[data-theme] .calendar-list .calendar-name {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .no-calendars,
|
||||||
|
[data-theme] .no-calendars {
|
||||||
|
color: #5f6368 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form labels and text */
|
||||||
|
body .sidebar-footer label,
|
||||||
|
[data-theme] .sidebar-footer label,
|
||||||
|
body .view-selector label,
|
||||||
|
[data-theme] .view-selector label,
|
||||||
|
body .theme-selector label,
|
||||||
|
[data-theme] .theme-selector label,
|
||||||
|
body .style-selector label,
|
||||||
|
[data-theme] .style-selector label {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style selectors */
|
||||||
|
body .view-selector-dropdown,
|
||||||
|
body .theme-selector-dropdown,
|
||||||
|
body .style-selector-dropdown,
|
||||||
|
[data-theme] .view-selector-dropdown,
|
||||||
|
[data-theme] .theme-selector-dropdown,
|
||||||
|
[data-theme] .style-selector-dropdown {
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
background: white !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector-dropdown:focus,
|
||||||
|
.theme-selector-dropdown:focus,
|
||||||
|
.style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a73e8;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar list items */
|
||||||
|
.calendar-list h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3c4043;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-list ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-list .calendar-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-list .calendar-item:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-list .calendar-name {
|
||||||
|
color: #3c4043;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style main content area */
|
||||||
|
body .app-main,
|
||||||
|
[data-theme] .app-main {
|
||||||
|
background: #ffffff !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar header elements */
|
||||||
|
body .calendar-header,
|
||||||
|
[data-theme] .calendar-header {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .calendar-header h2,
|
||||||
|
body .calendar-header h3,
|
||||||
|
body .month-header,
|
||||||
|
body .week-header,
|
||||||
|
[data-theme] .calendar-header h2,
|
||||||
|
[data-theme] .calendar-header h3,
|
||||||
|
[data-theme] .month-header,
|
||||||
|
[data-theme] .week-header {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month name and title - aggressive override */
|
||||||
|
body h1,
|
||||||
|
body h2,
|
||||||
|
body h3,
|
||||||
|
body .month-title,
|
||||||
|
body .calendar-title,
|
||||||
|
body .current-month,
|
||||||
|
body .month-year,
|
||||||
|
body .header-title,
|
||||||
|
[data-theme] h1,
|
||||||
|
[data-theme] h2,
|
||||||
|
[data-theme] h3,
|
||||||
|
[data-theme] .month-title,
|
||||||
|
[data-theme] .calendar-title,
|
||||||
|
[data-theme] .current-month,
|
||||||
|
[data-theme] .month-year,
|
||||||
|
[data-theme] .header-title {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation arrows and buttons - aggressive override */
|
||||||
|
body button,
|
||||||
|
body .nav-button,
|
||||||
|
body .calendar-nav-button,
|
||||||
|
body .prev-button,
|
||||||
|
body .next-button,
|
||||||
|
body .arrow-button,
|
||||||
|
body .navigation-arrow,
|
||||||
|
body [class*="arrow"],
|
||||||
|
body [class*="nav"],
|
||||||
|
body [class*="button"],
|
||||||
|
[data-theme] button,
|
||||||
|
[data-theme] .nav-button,
|
||||||
|
[data-theme] .calendar-nav-button,
|
||||||
|
[data-theme] .prev-button,
|
||||||
|
[data-theme] .next-button,
|
||||||
|
[data-theme] .arrow-button,
|
||||||
|
[data-theme] .navigation-arrow,
|
||||||
|
[data-theme] [class*="arrow"],
|
||||||
|
[data-theme] [class*="nav"],
|
||||||
|
[data-theme] [class*="button"] {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body button:hover,
|
||||||
|
body .nav-button:hover,
|
||||||
|
body .calendar-nav-button:hover,
|
||||||
|
body .prev-button:hover,
|
||||||
|
body .next-button:hover,
|
||||||
|
body .arrow-button:hover,
|
||||||
|
[data-theme] button:hover,
|
||||||
|
[data-theme] .nav-button:hover,
|
||||||
|
[data-theme] .calendar-nav-button:hover,
|
||||||
|
[data-theme] .prev-button:hover,
|
||||||
|
[data-theme] .next-button:hover,
|
||||||
|
[data-theme] .arrow-button:hover {
|
||||||
|
background: #e8f0fe !important;
|
||||||
|
color: #1a73e8 !important;
|
||||||
|
border-color: #1a73e8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar controls and date display */
|
||||||
|
body .calendar-controls,
|
||||||
|
body .current-date,
|
||||||
|
body .date-display,
|
||||||
|
[data-theme] .calendar-controls,
|
||||||
|
[data-theme] .current-date,
|
||||||
|
[data-theme] .date-display {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ultimate nuclear approach - override EVERYTHING */
|
||||||
|
html body .app-main,
|
||||||
|
html body .app-main *,
|
||||||
|
html body .main-content,
|
||||||
|
html body .main-content *,
|
||||||
|
html body .calendar-container,
|
||||||
|
html body .calendar-container *,
|
||||||
|
html [data-theme] .app-main,
|
||||||
|
html [data-theme] .app-main *,
|
||||||
|
html [data-theme] .main-content,
|
||||||
|
html [data-theme] .main-content *,
|
||||||
|
html [data-theme] .calendar-container,
|
||||||
|
html [data-theme] .calendar-container *,
|
||||||
|
html [data-theme="default"] .app-main *,
|
||||||
|
html [data-theme="ocean"] .app-main *,
|
||||||
|
html [data-theme="forest"] .app-main *,
|
||||||
|
html [data-theme="sunset"] .app-main *,
|
||||||
|
html [data-theme="purple"] .app-main *,
|
||||||
|
html [data-theme="dark"] .app-main *,
|
||||||
|
html [data-theme="rose"] .app-main *,
|
||||||
|
html [data-theme="mint"] .app-main * {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force all text elements */
|
||||||
|
html body .app-main h1,
|
||||||
|
html body .app-main h2,
|
||||||
|
html body .app-main h3,
|
||||||
|
html body .app-main h4,
|
||||||
|
html body .app-main h5,
|
||||||
|
html body .app-main h6,
|
||||||
|
html body .app-main p,
|
||||||
|
html body .app-main span,
|
||||||
|
html body .app-main div,
|
||||||
|
html body .app-main button,
|
||||||
|
html [data-theme] .app-main h1,
|
||||||
|
html [data-theme] .app-main h2,
|
||||||
|
html [data-theme] .app-main h3,
|
||||||
|
html [data-theme] .app-main h4,
|
||||||
|
html [data-theme] .app-main h5,
|
||||||
|
html [data-theme] .app-main h6,
|
||||||
|
html [data-theme] .app-main p,
|
||||||
|
html [data-theme] .app-main span,
|
||||||
|
html [data-theme] .app-main div,
|
||||||
|
html [data-theme] .app-main button {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exception for buttons - make them stand out */
|
||||||
|
body .app-main button,
|
||||||
|
body .main-content button,
|
||||||
|
[data-theme] .app-main button,
|
||||||
|
[data-theme] .main-content button {
|
||||||
|
color: #3c4043 !important;
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar grid - more aggressive styling */
|
||||||
|
html body .calendar-grid,
|
||||||
|
html [data-theme] .calendar-grid,
|
||||||
|
body .calendar-container,
|
||||||
|
[data-theme] .calendar-container {
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .calendar-header,
|
||||||
|
html [data-theme] .calendar-header {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
border-bottom: 1px solid #dadce0 !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .month-header,
|
||||||
|
html body .week-header,
|
||||||
|
html [data-theme] .month-header,
|
||||||
|
html [data-theme] .week-header {
|
||||||
|
font-size: 22px !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar cells - complete overhaul */
|
||||||
|
html body .calendar-day,
|
||||||
|
html [data-theme] .calendar-day,
|
||||||
|
body .day-cell,
|
||||||
|
[data-theme] .day-cell {
|
||||||
|
border: 1px solid #e8eaed !important;
|
||||||
|
background: white !important;
|
||||||
|
transition: background-color 0.15s ease !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
min-height: 120px !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .calendar-day:hover,
|
||||||
|
html [data-theme] .calendar-day:hover,
|
||||||
|
body .day-cell:hover,
|
||||||
|
[data-theme] .day-cell:hover {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #dadce0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .calendar-day.today,
|
||||||
|
html [data-theme] .calendar-day.today,
|
||||||
|
body .day-cell.today,
|
||||||
|
[data-theme] .day-cell.today {
|
||||||
|
background: #e8f0fe !important;
|
||||||
|
border-color: #1a73e8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .calendar-day.other-month,
|
||||||
|
html [data-theme] .calendar-day.other-month,
|
||||||
|
body .day-cell.other-month,
|
||||||
|
[data-theme] .day-cell.other-month {
|
||||||
|
background: #fafafa !important;
|
||||||
|
color: #9aa0a6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .day-number,
|
||||||
|
html [data-theme] .day-number,
|
||||||
|
body .date-number,
|
||||||
|
[data-theme] .date-number {
|
||||||
|
font-size: 14px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: #3c4043 !important;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day headers (Mon, Tue, Wed, etc.) */
|
||||||
|
html body .day-header,
|
||||||
|
html [data-theme] .day-header,
|
||||||
|
body .weekday-header,
|
||||||
|
[data-theme] .weekday-header {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
color: #5f6368 !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
letter-spacing: 0.8px !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
border-bottom: 1px solid #dadce0 !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google Calendar-style events - complete redesign */
|
||||||
|
html body .app-main .event,
|
||||||
|
html [data-theme] .app-main .event,
|
||||||
|
html body .calendar-container .event,
|
||||||
|
html [data-theme] .calendar-container .event,
|
||||||
|
body .event,
|
||||||
|
[data-theme] .event {
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 400 !important;
|
||||||
|
margin: 1px 0 2px 0 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
border: none !important;
|
||||||
|
color: white !important;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||||
|
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease !important;
|
||||||
|
display: block !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .app-main .event *,
|
||||||
|
html [data-theme] .app-main .event *,
|
||||||
|
html body .calendar-container .event *,
|
||||||
|
html [data-theme] .calendar-container .event *,
|
||||||
|
body .event *,
|
||||||
|
[data-theme] .event * {
|
||||||
|
color: white !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .app-main .event:hover,
|
||||||
|
html [data-theme] .app-main .event:hover,
|
||||||
|
body .event:hover,
|
||||||
|
[data-theme] .event:hover {
|
||||||
|
transform: translateY(-1px) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(60,64,67,.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All-day events styling */
|
||||||
|
html body .event.all-day,
|
||||||
|
html [data-theme] .event.all-day {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 4px 12px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
margin: 2px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event time display */
|
||||||
|
html body .event-time,
|
||||||
|
html [data-theme] .event-time {
|
||||||
|
opacity: 0.9 !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
margin-right: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week view events */
|
||||||
|
html body .week-view .event,
|
||||||
|
html [data-theme] .week-view .event {
|
||||||
|
border-left: 3px solid rgba(255,255,255,0.8) !important;
|
||||||
|
border-radius: 0 4px 4px 0 !important;
|
||||||
|
padding-left: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar table structure */
|
||||||
|
html body .calendar-table,
|
||||||
|
html [data-theme] .calendar-table,
|
||||||
|
body table,
|
||||||
|
[data-theme] table {
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .calendar-table td,
|
||||||
|
html [data-theme] .calendar-table td,
|
||||||
|
body table td,
|
||||||
|
[data-theme] table td {
|
||||||
|
vertical-align: top !important;
|
||||||
|
border: 1px solid #e8eaed !important;
|
||||||
|
background: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month/Week view toggle */
|
||||||
|
html body .view-toggle,
|
||||||
|
html [data-theme] .view-toggle {
|
||||||
|
display: flex !important;
|
||||||
|
gap: 4px !important;
|
||||||
|
background: #f1f3f4 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
padding: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .view-toggle button,
|
||||||
|
html [data-theme] .view-toggle button {
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #5f6368 !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.15s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .view-toggle button.active,
|
||||||
|
html [data-theme] .view-toggle button.active {
|
||||||
|
background: white !important;
|
||||||
|
color: #1a73e8 !important;
|
||||||
|
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today button */
|
||||||
|
html body .today-button,
|
||||||
|
html [data-theme] .today-button {
|
||||||
|
background: white !important;
|
||||||
|
border: 1px solid #dadce0 !important;
|
||||||
|
color: #1a73e8 !important;
|
||||||
|
padding: 8px 16px !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.15s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .today-button:hover,
|
||||||
|
html [data-theme] .today-button:hover {
|
||||||
|
background: #f8f9fa !important;
|
||||||
|
border-color: #1a73e8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style modals */
|
||||||
|
.modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3c4043;
|
||||||
|
font-family: 'Google Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style form inputs */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #3c4043;
|
||||||
|
background: white;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a73e8;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style labels */
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3c4043;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user