Compare commits
33 Commits
feature/sq
...
419cb3d790
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419cb3d790 | ||
|
|
53a62fb05e | ||
|
|
322c88612a | ||
|
|
4aa53d79e7 | ||
|
|
3464754489 | ||
|
|
e56253b9c2 | ||
|
|
cb8cc7258c | ||
|
|
b576cd8c4a | ||
|
|
a773159016 | ||
|
|
a9521ad536 | ||
|
|
5456d7140c | ||
|
|
62cc910e1a | ||
|
|
6ec7bb5422 | ||
|
|
ce74750d85 | ||
|
|
d089f1545b | ||
|
|
7b06fef6c3 | ||
|
|
7be9f5a869 | ||
|
|
a7ebbe0635 | ||
|
|
3662f117f5 | ||
|
|
0899a84b42 | ||
|
|
85d23b0347 | ||
|
|
13db4abc0f | ||
|
|
57e434e4ff | ||
|
|
7c2901f453 | ||
| 6c67444b19 | |||
|
|
970b0a07da | ||
|
|
e2e5813b54 | ||
|
|
73567c185c | ||
| 0587762bbb | |||
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
| 4d2aad404b |
@@ -38,4 +38,4 @@ calendar.db
|
||||
**/tests/
|
||||
|
||||
# Migrations (not needed for builds)
|
||||
migrations/
|
||||
migrations/
|
||||
|
||||
@@ -18,17 +18,18 @@ jobs:
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ vars.USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
|
||||
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
|
||||
${{ vars.REGISTRY }}/connor/calendar:latest
|
||||
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
}
|
||||
|
||||
:80, :443 {
|
||||
@backend {
|
||||
path /api /api/*
|
||||
}
|
||||
reverse_proxy @backend calendar-backend:3000
|
||||
try_files {path} /index.html
|
||||
root * /srv/www
|
||||
file_server
|
||||
}
|
||||
|
||||
96
Dockerfile
96
Dockerfile
@@ -1,96 +0,0 @@
|
||||
# Build stage
|
||||
# -----------------------------------------------------------
|
||||
FROM rust:alpine AS builder
|
||||
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
|
||||
|
||||
# Install trunk ahead of the compilation. This may break and then you'll have to update the version.
|
||||
RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100
|
||||
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace files to maintain workspace structure
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY calendar-models ./calendar-models
|
||||
COPY frontend/Cargo.toml ./frontend/
|
||||
COPY frontend/Trunk.toml ./frontend/
|
||||
COPY frontend/index.html ./frontend/
|
||||
COPY frontend/styles.css ./frontend/
|
||||
|
||||
# Create empty backend directory to satisfy workspace
|
||||
RUN mkdir -p backend/src && \
|
||||
printf '[package]\nname = "calendar-backend"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > backend/Cargo.toml && \
|
||||
echo 'fn main() {}' > backend/src/main.rs
|
||||
|
||||
# Create dummy source files to build dependencies first
|
||||
RUN mkdir -p frontend/src && \
|
||||
echo "use web_sys::*; fn main() {}" > frontend/src/main.rs && \
|
||||
echo "pub fn add(a: usize, b: usize) -> usize { a + b }" > calendar-models/src/lib.rs
|
||||
|
||||
# Build dependencies (this layer will be cached unless dependencies change)
|
||||
RUN cargo build --release --target wasm32-unknown-unknown --bin calendar-app
|
||||
|
||||
# Copy actual source code and build the frontend application
|
||||
RUN rm -rf frontend
|
||||
COPY frontend ./frontend
|
||||
RUN trunk build --release --config ./frontend/Trunk.toml
|
||||
|
||||
|
||||
|
||||
|
||||
# Backend build stage
|
||||
# -----------------------------------------------------------
|
||||
FROM rust:alpine AS backend-builder
|
||||
|
||||
# Install build dependencies for backend
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
|
||||
# Copy shared models
|
||||
COPY calendar-models ./calendar-models
|
||||
|
||||
# Create empty frontend directory to satisfy workspace
|
||||
RUN mkdir -p frontend/src && \
|
||||
printf '[package]\nname = "calendar-app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
|
||||
echo 'fn main() {}' > frontend/src/main.rs
|
||||
|
||||
# Create dummy backend source to build dependencies first
|
||||
RUN mkdir -p backend/src && \
|
||||
echo "fn main() {}" > backend/src/main.rs
|
||||
|
||||
# Build dependencies (this layer will be cached unless dependencies change)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY backend/Cargo.toml ./backend/
|
||||
RUN cargo build --release
|
||||
|
||||
# Build the backend
|
||||
COPY backend ./backend
|
||||
RUN cargo build --release --bin backend
|
||||
|
||||
|
||||
|
||||
|
||||
# Runtime stage
|
||||
# -----------------------------------------------------------
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy frontend files to temporary location
|
||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||
|
||||
# Copy backend binary (built in workspace root)
|
||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||
|
||||
# Create startup script to copy frontend files to shared volume
|
||||
RUN mkdir -p /srv/www
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
# Start with script that copies frontend files then starts backend
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
67
README.md
67
README.md
@@ -1,13 +1,22 @@
|
||||
# Modern CalDAV Web Client
|
||||
# Runway
|
||||
## _Passive infrastructure for life's coordination_
|
||||
|
||||

|
||||
|
||||
>[!WARNING]
|
||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
||||
|
||||
A full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
|
||||
A modern CalDAV web client built with Rust WebAssembly.
|
||||
|
||||
## Motivation
|
||||
## The Name
|
||||
|
||||
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. This project aims to provide a modern, fast, and reliable web interface for CalDAV servers.
|
||||
Runway embodies the concept of **passive infrastructure** — unobtrusive systems that enable better coordination without getting in the way. Planes can fly and do lots of cool things, but without runways, they can't take off or land. Similarly, calendars and scheduling tools are essential for organizing our lives, but they should not dominate our attention.
|
||||
|
||||
The best infrastructure is invisible when working, essential when needed, and enables rather than constrains.
|
||||
|
||||
## Why Runway?
|
||||
|
||||
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. Runway provides a modern, fast, and reliable web interface for CalDAV servers — infrastructure that just works.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -29,6 +38,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
||||
- **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
|
||||
|
||||
### Frontend (Yew WebAssembly)
|
||||
@@ -40,7 +55,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
### Backend (Axum)
|
||||
- **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
|
||||
- **API Design**: RESTful endpoints following calendar operation patterns
|
||||
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
||||
@@ -54,12 +70,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
The easiest way to run Runway 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)
|
||||
- Trunk (`cargo install trunk`)
|
||||
|
||||
### Development Setup
|
||||
#### Local Development
|
||||
|
||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||
```bash
|
||||
@@ -73,6 +113,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -120,7 +171,7 @@ calendar/
|
||||
This client is designed to work with any RFC-compliant CalDAV server:
|
||||
|
||||
- **Baikal** - ✅ Fully tested with complete event and recurrence support
|
||||
- **Nextcloud** - 🚧 Planned compatibility with calendar app
|
||||
- **Nextcloud** - 🚧 Planned compatibility with Nextcloud calendar
|
||||
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
||||
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
||||
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
||||
|
||||
64
backend/Dockerfile
Normal file
64
backend/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
||||
# Build stage
|
||||
# -----------------------------------------------------------
|
||||
FROM rust:alpine AS builder
|
||||
|
||||
# Install build dependencies for backend
|
||||
WORKDIR /app
|
||||
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 workspace files to maintain workspace structure
|
||||
COPY ./Cargo.toml ./
|
||||
COPY ./calendar-models ./calendar-models
|
||||
|
||||
# Create empty frontend directory to satisfy workspace
|
||||
RUN mkdir -p frontend/src && \
|
||||
printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
|
||||
echo 'fn main() {}' > frontend/src/main.rs
|
||||
|
||||
# Copy backend files
|
||||
COPY backend/Cargo.toml ./backend/
|
||||
|
||||
# Create dummy backend source to build dependencies first
|
||||
RUN mkdir -p backend/src && \
|
||||
echo "fn main() {}" > backend/src/main.rs
|
||||
|
||||
# Build dependencies (this layer will be cached unless dependencies change)
|
||||
RUN cargo build --release
|
||||
|
||||
# Copy actual backend source and build
|
||||
COPY backend/src ./backend/src
|
||||
COPY backend/migrations ./backend/migrations
|
||||
RUN cargo build --release --bin backend
|
||||
|
||||
# Runtime stage
|
||||
# -----------------------------------------------------------
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||
|
||||
# Copy backend binary and sqlx-cli
|
||||
COPY --from=builder /app/target/release/backend /usr/local/bin/backend
|
||||
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||
|
||||
# Copy migrations for database setup
|
||||
COPY backend/migrations /migrations
|
||||
|
||||
# Create startup script to run migrations and start backend
|
||||
RUN mkdir -p /db
|
||||
RUN echo '#!/bin/sh' > /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 'touch /db/calendar.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 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
# Start with script that runs migrations then starts backend
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
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';
|
||||
@@ -91,6 +91,7 @@ impl AuthService {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -361,11 +361,10 @@ impl CalDAVClient {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine if it's an all-day event
|
||||
let all_day = properties
|
||||
.get("DTSTART")
|
||||
.map(|s| !s.contains("T"))
|
||||
.unwrap_or(false);
|
||||
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
||||
let empty_string = String::new();
|
||||
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
|
||||
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
|
||||
|
||||
// Parse status
|
||||
let status = properties
|
||||
|
||||
@@ -93,6 +93,7 @@ pub struct UserPreferences {
|
||||
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>,
|
||||
}
|
||||
@@ -106,6 +107,7 @@ impl UserPreferences {
|
||||
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(),
|
||||
}
|
||||
@@ -264,14 +266,15 @@ impl<'a> PreferencesRepository<'a> {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_preferences
|
||||
(user_id, calendar_selected_date, calendar_time_increment,
|
||||
calendar_view_mode, calendar_theme, calendar_colors, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
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())
|
||||
@@ -286,7 +289,7 @@ impl<'a> PreferencesRepository<'a> {
|
||||
sqlx::query(
|
||||
"UPDATE user_preferences
|
||||
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||
calendar_view_mode = ?, calendar_theme = ?,
|
||||
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
||||
calendar_colors = ?, updated_at = ?
|
||||
WHERE user_id = ?",
|
||||
)
|
||||
@@ -294,6 +297,7 @@ impl<'a> PreferencesRepository<'a> {
|
||||
.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)
|
||||
|
||||
@@ -2,7 +2,6 @@ use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::{
|
||||
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||
AppState,
|
||||
|
||||
@@ -76,10 +76,54 @@ pub async fn get_calendar_events(
|
||||
|
||||
// If year and month are specified, filter events
|
||||
if let (Some(year), Some(month)) = (params.year, params.month) {
|
||||
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
let month_start = target_date;
|
||||
let month_end = if month == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||
} - chrono::Duration::days(1);
|
||||
|
||||
all_events.retain(|event| {
|
||||
let event_year = event.dtstart.year();
|
||||
let event_month = event.dtstart.month();
|
||||
event_year == year && event_month == month
|
||||
let event_date = event.dtstart.date_naive();
|
||||
|
||||
// For non-recurring events, check if the event date is within the month
|
||||
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
||||
let event_year = event.dtstart.year();
|
||||
let event_month = event.dtstart.month();
|
||||
return event_year == year && event_month == month;
|
||||
}
|
||||
|
||||
// For recurring events, check if they could have instances in this month
|
||||
// Include if:
|
||||
// 1. The event starts before or during the requested month
|
||||
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
|
||||
if event_date > month_end {
|
||||
// Event starts after the requested month
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check UNTIL date in RRULE if present
|
||||
if let Some(ref rrule) = event.rrule {
|
||||
if let Some(until_pos) = rrule.find("UNTIL=") {
|
||||
let until_part = &rrule[until_pos + 6..];
|
||||
let until_end = until_part.find(';').unwrap_or(until_part.len());
|
||||
let until_str = &until_part[..until_end];
|
||||
|
||||
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
|
||||
if until_str.len() >= 8 {
|
||||
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
|
||||
if until_date < month_start {
|
||||
// Recurring event ended before the requested month
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include the recurring event - the frontend will do proper expansion
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -414,14 +458,28 @@ pub async fn create_event(
|
||||
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 mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||
|
||||
// Validate that end is after start
|
||||
if end_datetime <= start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date/time must be after start date/time".to_string(),
|
||||
));
|
||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||
// RFC-5545 uses exclusive end dates for all-day events
|
||||
if request.all_day {
|
||||
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||
}
|
||||
|
||||
// Validate that end is after start (allow equal times for all-day events)
|
||||
if request.all_day {
|
||||
if end_datetime < start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date must be on or after start date for all-day events".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if end_datetime <= start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date/time must be after start date/time".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique UID for the event
|
||||
@@ -704,14 +762,28 @@ pub async fn update_event(
|
||||
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 mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||
|
||||
// Validate that end is after start
|
||||
if end_datetime <= start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date/time must be after start date/time".to_string(),
|
||||
));
|
||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||
// RFC-5545 uses exclusive end dates for all-day events
|
||||
if request.all_day {
|
||||
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||
}
|
||||
|
||||
// Validate that end is after start (allow equal times for all-day events)
|
||||
if request.all_day {
|
||||
if end_datetime < start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date must be on or after start date for all-day events".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if end_datetime <= start_datetime {
|
||||
return Err(ApiError::BadRequest(
|
||||
"End date/time must be after start date/time".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Update event properties
|
||||
|
||||
@@ -38,6 +38,7 @@ pub async fn get_preferences(
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -78,6 +79,9 @@ pub async fn update_preferences(
|
||||
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;
|
||||
}
|
||||
@@ -94,6 +98,7 @@ pub async fn update_preferences(
|
||||
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,
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -175,6 +175,7 @@ pub async fn create_event_series(
|
||||
// Create the VEvent for the series
|
||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||
event.dtend = Some(end_datetime);
|
||||
event.all_day = request.all_day; // Set the all_day flag properly
|
||||
event.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct UserPreferencesResponse {
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ pub struct UpdatePreferencesRequest {
|
||||
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>,
|
||||
}
|
||||
|
||||
|
||||
BIN
calendar.db
BIN
calendar.db
Binary file not shown.
14
compose.yml
14
compose.yml
@@ -1,22 +1,22 @@
|
||||
services:
|
||||
calendar-backend:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data/site_dist:/srv/www
|
||||
- ./data/db:/db
|
||||
|
||||
calendar-frontend:
|
||||
image: caddy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_API_URL=http://localhost:3000/api
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./data/site_dist:/srv/www:ro
|
||||
- ./frontend/dist:/srv/www:ro
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./data/caddy/data:/data
|
||||
- ./data/caddy/config:/config
|
||||
|
||||
4
deploy_frontend.sh
Executable file
4
deploy_frontend.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||
BIN
favicon_big.png
Normal file
BIN
favicon_big.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "calendar-app"
|
||||
name = "runway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlSelectElement",
|
||||
"HtmlInputElement",
|
||||
"HtmlTextAreaElement",
|
||||
"HtmlLinkElement",
|
||||
"HtmlHeadElement",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"InputEvent",
|
||||
|
||||
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -2,17 +2,19 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Calendar App</title>
|
||||
<title>Runway</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base data-trunk-public-url />
|
||||
<link data-trunk rel="css" href="styles.css">
|
||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||
<link data-trunk rel="icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
console.log("HTML loaded, waiting for WASM...");
|
||||
console.log("HTML fully loaded, waiting for WASM...");
|
||||
window.addEventListener('TrunkApplicationStarted', () => {
|
||||
console.log("Trunk application started successfully!");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::components::{
|
||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModalV2, DeleteAction,
|
||||
EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType,
|
||||
ReminderType, RouteHandler, Sidebar, Theme, ViewMode,
|
||||
};
|
||||
use crate::components::sidebar::{Style};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
use chrono::NaiveDate;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
@@ -96,6 +98,16 @@ pub fn App() -> Html {
|
||||
}
|
||||
});
|
||||
|
||||
// Style state - load from localStorage if available
|
||||
let current_style = use_state(|| {
|
||||
// Try to load saved style from localStorage
|
||||
if let Ok(saved_style) = LocalStorage::get::<String>("calendar_style") {
|
||||
Style::from_value(&saved_style)
|
||||
} else {
|
||||
Style::Default // Default style
|
||||
}
|
||||
});
|
||||
|
||||
let available_colors = use_state(|| get_theme_event_colors());
|
||||
|
||||
let on_login = {
|
||||
@@ -152,6 +164,42 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_style_change = {
|
||||
let current_style = current_style.clone();
|
||||
Callback::from(move |new_style: Style| {
|
||||
// Save style to localStorage
|
||||
let _ = LocalStorage::set("calendar_style", new_style.value());
|
||||
|
||||
// Hot-swap stylesheet
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
// Remove existing style link if it exists
|
||||
if let Some(existing_link) = document.get_element_by_id("dynamic-style") {
|
||||
existing_link.remove();
|
||||
}
|
||||
|
||||
// Create and append new stylesheet link only if style has a path
|
||||
if let Some(stylesheet_path) = new_style.stylesheet_path() {
|
||||
if let Ok(link) = document.create_element("link") {
|
||||
let link = link.dyn_into::<web_sys::HtmlLinkElement>().unwrap();
|
||||
link.set_id("dynamic-style");
|
||||
link.set_rel("stylesheet");
|
||||
link.set_href(stylesheet_path);
|
||||
|
||||
if let Some(head) = document.head() {
|
||||
let _ = head.append_child(&link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If stylesheet_path is None (Default style), just removing the dynamic link is enough
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
current_style.set(new_style);
|
||||
})
|
||||
};
|
||||
|
||||
// Apply initial theme on mount
|
||||
{
|
||||
let current_theme = current_theme.clone();
|
||||
@@ -165,6 +213,32 @@ pub fn App() -> Html {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply initial style on mount
|
||||
{
|
||||
let current_style = current_style.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let style = (*current_style).clone();
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
// Create and append stylesheet link for initial style only if it has a path
|
||||
if let Some(stylesheet_path) = style.stylesheet_path() {
|
||||
if let Ok(link) = document.create_element("link") {
|
||||
let link = link.dyn_into::<web_sys::HtmlLinkElement>().unwrap();
|
||||
link.set_id("dynamic-style");
|
||||
link.set_rel("stylesheet");
|
||||
link.set_href(stylesheet_path);
|
||||
|
||||
if let Some(head) = document.head() {
|
||||
let _ = head.append_child(&link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// If initial style is Default (None), no additional stylesheet needed
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user info when token is available
|
||||
{
|
||||
let user_info = user_info.clone();
|
||||
@@ -718,6 +792,8 @@ pub fn App() -> Html {
|
||||
on_view_change={on_view_change}
|
||||
current_theme={(*current_theme).clone()}
|
||||
on_theme_change={on_theme_change}
|
||||
current_style={(*current_style).clone()}
|
||||
on_style_change={on_style_change}
|
||||
/>
|
||||
<main class="app-main">
|
||||
<RouteHandler
|
||||
@@ -955,9 +1031,11 @@ pub fn App() -> Html {
|
||||
on_create_event={on_create_event_click}
|
||||
/>
|
||||
|
||||
<CreateEventModal
|
||||
<CreateEventModalV2
|
||||
is_open={*create_event_modal_open}
|
||||
selected_date={(*selected_date_for_event).clone()}
|
||||
initial_start_time={None}
|
||||
initial_end_time={None}
|
||||
event_to_edit={(*event_context_menu_event).clone()}
|
||||
edit_scope={(*event_edit_scope).clone()}
|
||||
on_close={Callback::from({
|
||||
@@ -972,242 +1050,6 @@ pub fn App() -> Html {
|
||||
}
|
||||
})}
|
||||
on_create={on_event_create}
|
||||
on_update={Callback::from({
|
||||
let auth_token = auth_token.clone();
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
let event_edit_scope = event_edit_scope.clone();
|
||||
move |(original_event, updated_data): (VEvent, EventCreationData)| {
|
||||
web_sys::console::log_1(&format!("Updating event: {:?}, edit_scope: {:?}", updated_data, updated_data.edit_scope).into());
|
||||
create_event_modal_open.set(false);
|
||||
event_context_menu_event.set(None);
|
||||
event_edit_scope.set(None);
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
// Get CalDAV password from storage
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_local = updated_data.start_date.and_time(updated_data.start_time);
|
||||
let end_local = updated_data.end_date.and_time(updated_data.end_time);
|
||||
|
||||
let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert enums to strings for backend
|
||||
let status_str = match updated_data.status {
|
||||
EventStatus::Tentative => "tentative",
|
||||
EventStatus::Cancelled => "cancelled",
|
||||
_ => "confirmed",
|
||||
}.to_string();
|
||||
|
||||
let class_str = match updated_data.class {
|
||||
EventClass::Private => "private",
|
||||
EventClass::Confidential => "confidential",
|
||||
_ => "public",
|
||||
}.to_string();
|
||||
|
||||
let reminder_str = match updated_data.reminder {
|
||||
ReminderType::Minutes15 => "15min",
|
||||
ReminderType::Minutes30 => "30min",
|
||||
ReminderType::Hour1 => "1hour",
|
||||
ReminderType::Hours2 => "2hours",
|
||||
ReminderType::Day1 => "1day",
|
||||
ReminderType::Days2 => "2days",
|
||||
ReminderType::Week1 => "1week",
|
||||
_ => "none",
|
||||
}.to_string();
|
||||
|
||||
let recurrence_str = match updated_data.recurrence {
|
||||
RecurrenceType::Daily => "daily",
|
||||
RecurrenceType::Weekly => "weekly",
|
||||
RecurrenceType::Monthly => "monthly",
|
||||
RecurrenceType::Yearly => "yearly",
|
||||
_ => "none",
|
||||
}.to_string();
|
||||
|
||||
// Check if the calendar has changed
|
||||
let calendar_changed = original_event.calendar_path.as_ref() != updated_data.selected_calendar.as_ref();
|
||||
|
||||
if calendar_changed {
|
||||
// Calendar changed - need to delete from original and create in new
|
||||
web_sys::console::log_1(&"Calendar changed - performing delete + create".into());
|
||||
|
||||
// First delete from original calendar
|
||||
if let Some(original_calendar_path) = &original_event.calendar_path {
|
||||
if let Some(event_href) = &original_event.href {
|
||||
match calendar_service.delete_event(
|
||||
&token,
|
||||
&password,
|
||||
original_calendar_path.clone(),
|
||||
event_href.clone(),
|
||||
"single".to_string(), // delete single occurrence
|
||||
None
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Original event deleted successfully".into());
|
||||
|
||||
// Now create the event in the new calendar
|
||||
match calendar_service.create_event(
|
||||
&token,
|
||||
&password,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event moved to new calendar successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to create event in new calendar: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to move event to new calendar: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to delete original event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to delete original event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
web_sys::console::error_1(&"Original event missing href for deletion".into());
|
||||
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing href").unwrap();
|
||||
}
|
||||
} else {
|
||||
web_sys::console::error_1(&"Original event missing calendar_path for deletion".into());
|
||||
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap();
|
||||
}
|
||||
} else {
|
||||
// Calendar hasn't changed - check if we should use series endpoint
|
||||
let use_series_endpoint = updated_data.edit_scope.is_some() && original_event.rrule.is_some();
|
||||
|
||||
if use_series_endpoint {
|
||||
// Use series endpoint for recurring event modal edits
|
||||
let update_scope = match updated_data.edit_scope.as_ref().unwrap() {
|
||||
EditAction::EditThis => "this_only",
|
||||
EditAction::EditFuture => "this_and_future",
|
||||
EditAction::EditAll => "all_in_series",
|
||||
};
|
||||
|
||||
// For single occurrence edits, we need the occurrence date
|
||||
let occurrence_date = if update_scope == "this_only" || update_scope == "this_and_future" {
|
||||
// Use the original event's start date as the occurrence date
|
||||
Some(original_event.dtstart.format("%Y-%m-%d").to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match calendar_service.update_series(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.selected_calendar,
|
||||
update_scope.to_string(),
|
||||
occurrence_date,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Series updated successfully".into());
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to update series: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update series: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use regular event endpoint for non-recurring events or legacy updates
|
||||
match calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
updated_data.title,
|
||||
updated_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
updated_data.location,
|
||||
updated_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
updated_data.priority,
|
||||
updated_data.organizer,
|
||||
updated_data.attendees,
|
||||
updated_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar,
|
||||
original_event.exdate.clone(),
|
||||
Some("update_series".to_string()), // This is for event edit modal, preserve original RRULE
|
||||
None // No until_date for edit modal
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})}
|
||||
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::components::{
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
CalendarHeader, CreateEventModalV2, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
@@ -492,7 +492,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
/>
|
||||
|
||||
// Create event modal
|
||||
<CreateEventModal
|
||||
<CreateEventModalV2
|
||||
is_open={*show_create_modal}
|
||||
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
||||
event_to_edit={None}
|
||||
@@ -521,15 +521,6 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
}
|
||||
})
|
||||
}}
|
||||
on_update={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
// TODO: Handle actual event update
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -18,9 +18,45 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
// Smart positioning to keep menu within viewport
|
||||
let (x, y) = {
|
||||
let mut x = props.x;
|
||||
let mut y = props.y;
|
||||
|
||||
// Try to get actual viewport dimensions
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||
let viewport_width = w as i32;
|
||||
let viewport_height = h as i32;
|
||||
|
||||
// Calendar context menu: "Create Event" with icon
|
||||
let menu_width = 180; // "Create Event" text + icon + padding
|
||||
let menu_height = 60; // Single item + padding + margins
|
||||
|
||||
// Adjust horizontally if too close to right edge
|
||||
if x + menu_width > viewport_width - 10 {
|
||||
x = x.saturating_sub(menu_width);
|
||||
}
|
||||
|
||||
// Adjust vertically if too close to bottom edge
|
||||
if y + menu_height > viewport_height - 10 {
|
||||
y = y.saturating_sub(menu_height);
|
||||
}
|
||||
|
||||
// Ensure minimum margins from edges
|
||||
x = x.max(5);
|
||||
y = y.max(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
x, y
|
||||
);
|
||||
|
||||
let on_create_event_click = {
|
||||
|
||||
@@ -20,9 +20,45 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
// Smart positioning to keep menu within viewport
|
||||
let (x, y) = {
|
||||
let mut x = props.x;
|
||||
let mut y = props.y;
|
||||
|
||||
// Try to get actual viewport dimensions
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||
let viewport_width = w as i32;
|
||||
let viewport_height = h as i32;
|
||||
|
||||
// Generic context menu: "Delete Calendar"
|
||||
let menu_width = 180; // "Delete Calendar" text + padding
|
||||
let menu_height = 60; // Single item + padding + margins
|
||||
|
||||
// Adjust horizontally if too close to right edge
|
||||
if x + menu_width > viewport_width - 10 {
|
||||
x = x.saturating_sub(menu_width);
|
||||
}
|
||||
|
||||
// Adjust vertically if too close to bottom edge
|
||||
if y + menu_height > viewport_height - 10 {
|
||||
y = y.saturating_sub(menu_height);
|
||||
}
|
||||
|
||||
// Ensure minimum margins from edges
|
||||
x = x.max(5);
|
||||
y = y.max(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
x, y
|
||||
);
|
||||
|
||||
let on_delete_click = {
|
||||
|
||||
210
frontend/src/components/create_event_modal_v2.rs
Normal file
210
frontend/src/components/create_event_modal_v2.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use crate::components::event_form::*;
|
||||
use crate::components::create_event_modal::{EventCreationData}; // Use the existing types
|
||||
use crate::components::{EditAction};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateEventModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create: Callback<EventCreationData>,
|
||||
pub available_calendars: Vec<CalendarInfo>,
|
||||
pub selected_date: Option<chrono::NaiveDate>,
|
||||
pub initial_start_time: Option<chrono::NaiveTime>,
|
||||
pub initial_end_time: Option<chrono::NaiveTime>,
|
||||
#[prop_or_default]
|
||||
pub event_to_edit: Option<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub edit_scope: Option<EditAction>,
|
||||
}
|
||||
|
||||
#[function_component(CreateEventModalV2)]
|
||||
pub fn create_event_modal_v2(props: &CreateEventModalProps) -> Html {
|
||||
let active_tab = use_state(|| ModalTab::default());
|
||||
let event_data = use_state(|| EventCreationData::default());
|
||||
|
||||
// Initialize data when modal opens
|
||||
{
|
||||
let event_data = event_data.clone();
|
||||
let is_open = props.is_open;
|
||||
let event_to_edit = props.event_to_edit.clone();
|
||||
let selected_date = props.selected_date;
|
||||
let initial_start_time = props.initial_start_time;
|
||||
let initial_end_time = props.initial_end_time;
|
||||
let edit_scope = props.edit_scope.clone();
|
||||
let available_calendars = props.available_calendars.clone();
|
||||
|
||||
use_effect_with(is_open, move |&is_open| {
|
||||
if is_open {
|
||||
let mut data = if let Some(_event) = &event_to_edit {
|
||||
// TODO: Convert VEvent to EventCreationData
|
||||
EventCreationData::default()
|
||||
} else if let Some(date) = selected_date {
|
||||
let mut data = EventCreationData::default();
|
||||
data.start_date = date;
|
||||
data.end_date = date;
|
||||
if let Some(start_time) = initial_start_time {
|
||||
data.start_time = start_time;
|
||||
}
|
||||
if let Some(end_time) = initial_end_time {
|
||||
data.end_time = end_time;
|
||||
}
|
||||
data
|
||||
} else {
|
||||
EventCreationData::default()
|
||||
};
|
||||
|
||||
// Set default calendar
|
||||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
||||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||
}
|
||||
|
||||
// Set edit scope if provided
|
||||
if let Some(scope) = &edit_scope {
|
||||
data.edit_scope = Some(scope.clone());
|
||||
}
|
||||
|
||||
event_data.set(data);
|
||||
}
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let switch_to_tab = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: ModalTab| {
|
||||
active_tab.set(tab);
|
||||
})
|
||||
};
|
||||
|
||||
let on_save = {
|
||||
let event_data = event_data.clone();
|
||||
let on_create = props.on_create.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_create.emit((*event_data).clone());
|
||||
})
|
||||
};
|
||||
|
||||
let on_close = props.on_close.clone();
|
||||
let on_close_header = on_close.clone();
|
||||
|
||||
let tab_props = TabProps {
|
||||
data: event_data.clone(),
|
||||
available_calendars: props.available_calendars.clone(),
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||
<div class="modal-content create-event-modal">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
{if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }}
|
||||
</h3>
|
||||
<button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-tabs">
|
||||
<div class="tab-navigation">
|
||||
<button
|
||||
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
|
||||
}}
|
||||
>
|
||||
{"Basic"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
|
||||
}}
|
||||
>
|
||||
{"Advanced"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
|
||||
}}
|
||||
>
|
||||
{"People"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
|
||||
}}
|
||||
>
|
||||
{"Categories"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
|
||||
}}
|
||||
>
|
||||
{"Location"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
|
||||
onclick={{
|
||||
let switch_to_tab = switch_to_tab.clone();
|
||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
|
||||
}}
|
||||
>
|
||||
{"Reminders"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="tab-content">
|
||||
{
|
||||
match *active_tab {
|
||||
ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> },
|
||||
ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> },
|
||||
ModalTab::People => html! { <PeopleTab ..tab_props /> },
|
||||
ModalTab::Categories => html! { <CategoriesTab ..tab_props /> },
|
||||
ModalTab::Location => html! { <LocationTab ..tab_props /> },
|
||||
ModalTab::Reminders => html! { <RemindersTab ..tab_props /> },
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={on_save}>
|
||||
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,53 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
// Smart positioning to keep menu within viewport
|
||||
let (x, y) = {
|
||||
let mut x = props.x;
|
||||
let mut y = props.y;
|
||||
|
||||
// Try to get actual viewport dimensions
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||
let viewport_width = w as i32;
|
||||
let viewport_height = h as i32;
|
||||
|
||||
// More accurate menu dimensions based on actual CSS and content
|
||||
let menu_width = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
|
||||
280 // Recurring: "Edit This and Future Events" is long text + padding
|
||||
} else {
|
||||
180 // Non-recurring: "Edit Event" + "Delete Event" + padding
|
||||
};
|
||||
let menu_height = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
|
||||
200 // 6 items × ~32px per item (12px padding top/bottom + text height + borders)
|
||||
} else {
|
||||
100 // 2 items × ~32px per item + some extra margin
|
||||
};
|
||||
|
||||
// Adjust horizontally if too close to right edge
|
||||
if x + menu_width > viewport_width - 10 {
|
||||
x = x.saturating_sub(menu_width);
|
||||
}
|
||||
|
||||
// Adjust vertically if too close to bottom edge
|
||||
if y + menu_height > viewport_height - 10 {
|
||||
y = y.saturating_sub(menu_height);
|
||||
}
|
||||
|
||||
// Ensure minimum margins from edges
|
||||
x = x.max(5);
|
||||
y = y.max(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
x, y
|
||||
);
|
||||
|
||||
// Check if the event is recurring
|
||||
|
||||
109
frontend/src/components/event_form/advanced.rs
Normal file
109
frontend/src/components/event_form/advanced.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use super::types::*;
|
||||
use crate::components::create_event_modal::{EventStatus, EventClass};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(AdvancedTab)]
|
||||
pub fn advanced_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
let on_status_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.status = match select.value().as_str() {
|
||||
"tentative" => EventStatus::Tentative,
|
||||
"cancelled" => EventStatus::Cancelled,
|
||||
_ => EventStatus::Confirmed,
|
||||
};
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_class_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.class = match select.value().as_str() {
|
||||
"private" => EventClass::Private,
|
||||
"confidential" => EventClass::Confidential,
|
||||
_ => EventClass::Public,
|
||||
};
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_priority_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
let value = select.value();
|
||||
event_data.priority = if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
value.parse::<u8>().ok().filter(|&p| p <= 9)
|
||||
};
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-status">{"Status"}</label>
|
||||
<select
|
||||
id="event-status"
|
||||
class="form-input"
|
||||
onchange={on_status_change}
|
||||
>
|
||||
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
|
||||
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
|
||||
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-class">{"Privacy"}</label>
|
||||
<select
|
||||
id="event-class"
|
||||
class="form-input"
|
||||
onchange={on_class_change}
|
||||
>
|
||||
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
|
||||
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
|
||||
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-priority">{"Priority"}</label>
|
||||
<select
|
||||
id="event-priority"
|
||||
class="form-input"
|
||||
onchange={on_priority_change}
|
||||
>
|
||||
<option value="" selected={data.priority.is_none()}>{"Not set"}</option>
|
||||
<option value="1" selected={data.priority == Some(1)}>{"High"}</option>
|
||||
<option value="5" selected={data.priority == Some(5)}>{"Medium"}</option>
|
||||
<option value="9" selected={data.priority == Some(9)}>{"Low"}</option>
|
||||
</select>
|
||||
<p class="form-help-text">{"Set the importance level for this event."}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
730
frontend/src/components/event_form/basic_details.rs
Normal file
730
frontend/src/components/event_form/basic_details.rs
Normal file
@@ -0,0 +1,730 @@
|
||||
use super::types::*;
|
||||
use crate::components::create_event_modal::{EventStatus, EventClass, RecurrenceType, ReminderType};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(BasicDetailsTab)]
|
||||
pub fn basic_details_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
// Event handlers
|
||||
let on_title_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.title = input.value();
|
||||
if !event_data.changed_fields.contains(&"title".to_string()) {
|
||||
event_data.changed_fields.push("title".to_string());
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.description = textarea.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_calendar_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
let value = select.value();
|
||||
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
||||
if event_data.selected_calendar != new_calendar {
|
||||
event_data.selected_calendar = new_calendar;
|
||||
if !event_data.changed_fields.contains(&"selected_calendar".to_string()) {
|
||||
event_data.changed_fields.push("selected_calendar".to_string());
|
||||
}
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_all_day_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.all_day = input.checked();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurrence_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.recurrence = match select.value().as_str() {
|
||||
"daily" => RecurrenceType::Daily,
|
||||
"weekly" => RecurrenceType::Weekly,
|
||||
"monthly" => RecurrenceType::Monthly,
|
||||
"yearly" => RecurrenceType::Yearly,
|
||||
_ => RecurrenceType::None,
|
||||
};
|
||||
// Reset recurrence-related fields when changing type
|
||||
event_data.recurrence_days = vec![false; 7];
|
||||
event_data.recurrence_interval = 1;
|
||||
event_data.recurrence_until = None;
|
||||
event_data.recurrence_count = None;
|
||||
event_data.monthly_by_day = None;
|
||||
event_data.monthly_by_monthday = None;
|
||||
event_data.yearly_by_month = vec![false; 12];
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_reminder_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.reminder = match select.value().as_str() {
|
||||
"15min" => ReminderType::Minutes15,
|
||||
"30min" => ReminderType::Minutes30,
|
||||
"1hour" => ReminderType::Hour1,
|
||||
"1day" => ReminderType::Day1,
|
||||
"2days" => ReminderType::Days2,
|
||||
"1week" => ReminderType::Week1,
|
||||
_ => ReminderType::None,
|
||||
};
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurrence_interval_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(interval) = input.value().parse::<u32>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.recurrence_interval = interval.max(1);
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurrence_until_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if input.value().is_empty() {
|
||||
event_data.recurrence_until = None;
|
||||
} else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||
event_data.recurrence_until = Some(date);
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurrence_count_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if input.value().is_empty() {
|
||||
event_data.recurrence_count = None;
|
||||
} else if let Ok(count) = input.value().parse::<u32>() {
|
||||
event_data.recurrence_count = Some(count.max(1));
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_monthly_by_monthday_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if input.value().is_empty() {
|
||||
event_data.monthly_by_monthday = None;
|
||||
} else if let Ok(day) = input.value().parse::<u8>() {
|
||||
if day >= 1 && day <= 31 {
|
||||
event_data.monthly_by_monthday = Some(day);
|
||||
event_data.monthly_by_day = None; // Clear the other option
|
||||
}
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_monthly_by_day_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if select.value().is_empty() || select.value() == "none" {
|
||||
event_data.monthly_by_day = None;
|
||||
} else {
|
||||
event_data.monthly_by_day = Some(select.value());
|
||||
event_data.monthly_by_monthday = None; // Clear the other option
|
||||
}
|
||||
data.set(event_data);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_weekday_change = {
|
||||
let data = data.clone();
|
||||
move |day_index: usize| {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if day_index < event_data.recurrence_days.len() {
|
||||
event_data.recurrence_days[day_index] = input.checked();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let on_yearly_month_change = {
|
||||
let data = data.clone();
|
||||
move |month_index: usize| {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
if month_index < event_data.yearly_by_month.len() {
|
||||
event_data.yearly_by_month[month_index] = input.checked();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let on_start_date_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.start_date = date;
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_time_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.start_time = time;
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_date_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.end_date = date;
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_time_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.end_time = time;
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_location_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.location = input.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-group">
|
||||
<label for="event-title">{"Event Title *"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-title"
|
||||
class="form-input"
|
||||
value={data.title.clone()}
|
||||
oninput={on_title_input}
|
||||
placeholder="Add a title"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-description">{"Description"}</label>
|
||||
<textarea
|
||||
id="event-description"
|
||||
class="form-input"
|
||||
value={data.description.clone()}
|
||||
oninput={on_description_input}
|
||||
placeholder="Add a description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-calendar">{"Calendar"}</label>
|
||||
<select
|
||||
id="event-calendar"
|
||||
class="form-input"
|
||||
onchange={on_calendar_change}
|
||||
>
|
||||
<option value="">{"Select Calendar"}</option>
|
||||
{
|
||||
props.available_calendars.iter().map(|calendar| {
|
||||
html! {
|
||||
<option
|
||||
key={calendar.path.clone()}
|
||||
value={calendar.path.clone()}
|
||||
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
||||
>
|
||||
{&calendar.display_name}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.all_day}
|
||||
onchange={on_all_day_change}
|
||||
/>
|
||||
{" All Day"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-recurrence-basic">{"Repeat"}</label>
|
||||
<select
|
||||
id="event-recurrence-basic"
|
||||
class="form-input"
|
||||
onchange={on_recurrence_change}
|
||||
>
|
||||
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
|
||||
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
|
||||
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
|
||||
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
|
||||
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-reminder-basic">{"Reminder"}</label>
|
||||
<select
|
||||
id="event-reminder-basic"
|
||||
class="form-input"
|
||||
onchange={on_reminder_change}
|
||||
>
|
||||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
|
||||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
|
||||
if matches!(data.recurrence, RecurrenceType::Weekly) {
|
||||
<div class="form-group">
|
||||
<label>{"Repeat on"}</label>
|
||||
<div class="weekday-selection">
|
||||
{
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, day)| {
|
||||
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
|
||||
let on_change = on_weekday_change(i);
|
||||
html! {
|
||||
<label key={i} class="weekday-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={day_checked}
|
||||
onchange={on_change}
|
||||
/>
|
||||
<span class="weekday-label">{day}</span>
|
||||
</label>
|
||||
}
|
||||
})
|
||||
.collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if !matches!(data.recurrence, RecurrenceType::None) {
|
||||
<div class="recurrence-options">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="recurrence-interval">{"Every"}</label>
|
||||
<div class="interval-input">
|
||||
<input
|
||||
id="recurrence-interval"
|
||||
type="number"
|
||||
class="form-input"
|
||||
value={data.recurrence_interval.to_string()}
|
||||
min="1"
|
||||
max="999"
|
||||
onchange={on_recurrence_interval_change}
|
||||
/>
|
||||
<span class="interval-unit">
|
||||
{match data.recurrence {
|
||||
RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" },
|
||||
RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" },
|
||||
RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" },
|
||||
RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" },
|
||||
RecurrenceType::None => "",
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{"Ends"}</label>
|
||||
<div class="end-options">
|
||||
<div class="end-option">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="recurrence-end"
|
||||
value="never"
|
||||
checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()}
|
||||
onchange={{
|
||||
let data = data.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_data = (*data).clone();
|
||||
new_data.recurrence_until = None;
|
||||
new_data.recurrence_count = None;
|
||||
data.set(new_data);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{"Never"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="end-option">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="recurrence-end"
|
||||
value="until"
|
||||
checked={data.recurrence_until.is_some()}
|
||||
onchange={{
|
||||
let data = data.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_data = (*data).clone();
|
||||
new_data.recurrence_count = None;
|
||||
new_data.recurrence_until = Some(new_data.start_date);
|
||||
data.set(new_data);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{"Until"}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="form-input"
|
||||
value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()}
|
||||
onchange={on_recurrence_until_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="end-option">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="recurrence-end"
|
||||
value="count"
|
||||
checked={data.recurrence_count.is_some()}
|
||||
onchange={{
|
||||
let data = data.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_data = (*data).clone();
|
||||
new_data.recurrence_until = None;
|
||||
new_data.recurrence_count = Some(10); // Default count
|
||||
data.set(new_data);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{"After"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input count-input"
|
||||
value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()}
|
||||
min="1"
|
||||
max="999"
|
||||
placeholder="1"
|
||||
onchange={on_recurrence_count_change}
|
||||
/>
|
||||
<span class="count-unit">{"occurrences"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Monthly specific options
|
||||
if matches!(data.recurrence, RecurrenceType::Monthly) {
|
||||
<div class="form-group">
|
||||
<label>{"Repeat by"}</label>
|
||||
<div class="monthly-options">
|
||||
<div class="monthly-option">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="monthly-type"
|
||||
checked={data.monthly_by_monthday.is_some()}
|
||||
onchange={{
|
||||
let data = data.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_data = (*data).clone();
|
||||
new_data.monthly_by_day = None;
|
||||
new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8);
|
||||
data.set(new_data);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{"Day of month:"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-input day-input"
|
||||
value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())}
|
||||
min="1"
|
||||
max="31"
|
||||
onchange={on_monthly_by_monthday_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="monthly-option">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="monthly-type"
|
||||
checked={data.monthly_by_day.is_some()}
|
||||
onchange={{
|
||||
let data = data.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut new_data = (*data).clone();
|
||||
new_data.monthly_by_monthday = None;
|
||||
new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday
|
||||
data.set(new_data);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{"Day of week:"}
|
||||
</label>
|
||||
<select
|
||||
class="form-input"
|
||||
value={data.monthly_by_day.clone().unwrap_or_default()}
|
||||
onchange={on_monthly_by_day_change}
|
||||
>
|
||||
<option value="none">{"Select..."}</option>
|
||||
<option value="1MO">{"First Monday"}</option>
|
||||
<option value="1TU">{"First Tuesday"}</option>
|
||||
<option value="1WE">{"First Wednesday"}</option>
|
||||
<option value="1TH">{"First Thursday"}</option>
|
||||
<option value="1FR">{"First Friday"}</option>
|
||||
<option value="1SA">{"First Saturday"}</option>
|
||||
<option value="1SU">{"First Sunday"}</option>
|
||||
<option value="2MO">{"Second Monday"}</option>
|
||||
<option value="2TU">{"Second Tuesday"}</option>
|
||||
<option value="2WE">{"Second Wednesday"}</option>
|
||||
<option value="2TH">{"Second Thursday"}</option>
|
||||
<option value="2FR">{"Second Friday"}</option>
|
||||
<option value="2SA">{"Second Saturday"}</option>
|
||||
<option value="2SU">{"Second Sunday"}</option>
|
||||
<option value="3MO">{"Third Monday"}</option>
|
||||
<option value="3TU">{"Third Tuesday"}</option>
|
||||
<option value="3WE">{"Third Wednesday"}</option>
|
||||
<option value="3TH">{"Third Thursday"}</option>
|
||||
<option value="3FR">{"Third Friday"}</option>
|
||||
<option value="3SA">{"Third Saturday"}</option>
|
||||
<option value="3SU">{"Third Sunday"}</option>
|
||||
<option value="4MO">{"Fourth Monday"}</option>
|
||||
<option value="4TU">{"Fourth Tuesday"}</option>
|
||||
<option value="4WE">{"Fourth Wednesday"}</option>
|
||||
<option value="4TH">{"Fourth Thursday"}</option>
|
||||
<option value="4FR">{"Fourth Friday"}</option>
|
||||
<option value="4SA">{"Fourth Saturday"}</option>
|
||||
<option value="4SU">{"Fourth Sunday"}</option>
|
||||
<option value="-1MO">{"Last Monday"}</option>
|
||||
<option value="-1TU">{"Last Tuesday"}</option>
|
||||
<option value="-1WE">{"Last Wednesday"}</option>
|
||||
<option value="-1TH">{"Last Thursday"}</option>
|
||||
<option value="-1FR">{"Last Friday"}</option>
|
||||
<option value="-1SA">{"Last Saturday"}</option>
|
||||
<option value="-1SU">{"Last Sunday"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Yearly specific options
|
||||
if matches!(data.recurrence, RecurrenceType::Yearly) {
|
||||
<div class="form-group">
|
||||
<label>{"Repeat in months"}</label>
|
||||
<div class="yearly-months">
|
||||
{
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, month)| {
|
||||
let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false);
|
||||
let on_change = on_yearly_month_change(i);
|
||||
html! {
|
||||
<label key={i} class="month-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={month_checked}
|
||||
onchange={on_change}
|
||||
/>
|
||||
<span class="month-label">{month}</span>
|
||||
</label>
|
||||
}
|
||||
})
|
||||
.collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Date and time fields go here AFTER recurrence options
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="start-date">{"Start Date *"}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
class="form-input"
|
||||
value={data.start_date.format("%Y-%m-%d").to_string()}
|
||||
onchange={on_start_date_change}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
if !data.all_day {
|
||||
<div class="form-group">
|
||||
<label for="start-time">{"Start Time"}</label>
|
||||
<input
|
||||
type="time"
|
||||
id="start-time"
|
||||
class="form-input"
|
||||
value={data.start_time.format("%H:%M").to_string()}
|
||||
onchange={on_start_time_change}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="end-date">{"End Date *"}</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
class="form-input"
|
||||
value={data.end_date.format("%Y-%m-%d").to_string()}
|
||||
onchange={on_end_date_change}
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
|
||||
if !data.all_day {
|
||||
<div class="form-group">
|
||||
<label for="end-time">{"End Time"}</label>
|
||||
<input
|
||||
type="time"
|
||||
id="end-time"
|
||||
class="form-input"
|
||||
value={data.end_time.format("%H:%M").to_string()}
|
||||
onchange={on_end_time_change}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-location">{"Location"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-location"
|
||||
class="form-input"
|
||||
value={data.location.clone()}
|
||||
oninput={on_location_input}
|
||||
placeholder="Enter event location"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
98
frontend/src/components/event_form/categories.rs
Normal file
98
frontend/src/components/event_form/categories.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use super::types::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(CategoriesTab)]
|
||||
pub fn categories_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
let on_categories_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.categories = input.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let add_category = {
|
||||
let data = data.clone();
|
||||
move |category: &str| {
|
||||
let data = data.clone();
|
||||
let category = category.to_string();
|
||||
Callback::from(move |_| {
|
||||
let mut event_data = (*data).clone();
|
||||
if event_data.categories.is_empty() {
|
||||
event_data.categories = category.clone();
|
||||
} else {
|
||||
event_data.categories = format!("{}, {}", event_data.categories, category);
|
||||
}
|
||||
data.set(event_data);
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-group">
|
||||
<label for="event-categories">{"Categories"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-categories"
|
||||
class="form-input"
|
||||
value={data.categories.clone()}
|
||||
oninput={on_categories_input}
|
||||
placeholder="work, meeting, personal, project, urgent"
|
||||
/>
|
||||
<p class="form-help-text">{"Enter categories separated by commas to help organize and filter your events"}</p>
|
||||
</div>
|
||||
|
||||
<div class="categories-suggestions">
|
||||
<h5>{"Common Categories"}</h5>
|
||||
<div class="category-tags">
|
||||
<button type="button" class="category-tag" onclick={add_category("work")}>{"work"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("meeting")}>{"meeting"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("personal")}>{"personal"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("project")}>{"project"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("urgent")}>{"urgent"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("social")}>{"social"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("travel")}>{"travel"}</button>
|
||||
<button type="button" class="category-tag" onclick={add_category("health")}>{"health"}</button>
|
||||
</div>
|
||||
<p class="form-help-text">{"Click to add these common categories to your event"}</p>
|
||||
</div>
|
||||
|
||||
<div class="categories-info">
|
||||
<h5>{"Event Organization & Filtering"}</h5>
|
||||
<ul>
|
||||
<li>{"Categories help organize events in calendar views"}</li>
|
||||
<li>{"Filter events by category to focus on specific types"}</li>
|
||||
<li>{"Categories are searchable and can be used for reporting"}</li>
|
||||
<li>{"Multiple categories per event are fully supported"}</li>
|
||||
</ul>
|
||||
|
||||
<div class="categories-examples">
|
||||
<h6>{"Category Usage Examples"}</h6>
|
||||
<div class="category-example">
|
||||
<strong>{"Work Events:"}</strong>
|
||||
<span>{"work, meeting, project, urgent, deadline"}</span>
|
||||
</div>
|
||||
<div class="category-example">
|
||||
<strong>{"Personal Events:"}</strong>
|
||||
<span>{"personal, family, health, social, travel"}</span>
|
||||
</div>
|
||||
<div class="category-example">
|
||||
<strong>{"Mixed Events:"}</strong>
|
||||
<span>{"work, travel, client, important"}</span>
|
||||
</div>
|
||||
<p class="form-help-text">{"Categories follow RFC 5545 CATEGORIES property standards"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
118
frontend/src/components/event_form/location.rs
Normal file
118
frontend/src/components/event_form/location.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use super::types::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(LocationTab)]
|
||||
pub fn location_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
let on_location_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.location = input.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let set_location = {
|
||||
let data = data.clone();
|
||||
move |location: &str| {
|
||||
let data = data.clone();
|
||||
let location = location.to_string();
|
||||
Callback::from(move |_| {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.location = location.clone();
|
||||
data.set(event_data);
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-group">
|
||||
<label for="event-location-detailed">{"Event Location"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="event-location-detailed"
|
||||
class="form-input"
|
||||
value={data.location.clone()}
|
||||
oninput={on_location_input}
|
||||
placeholder="Conference Room A, 123 Main St, City, State 12345"
|
||||
/>
|
||||
<p class="form-help-text">{"Enter the full address or location description for the event"}</p>
|
||||
</div>
|
||||
|
||||
<div class="location-suggestions">
|
||||
<h5>{"Common Locations"}</h5>
|
||||
<div class="location-tags">
|
||||
<button type="button" class="location-tag" onclick={set_location("Conference Room")}>{"Conference Room"}</button>
|
||||
<button type="button" class="location-tag" onclick={set_location("Online Meeting")}>{"Online Meeting"}</button>
|
||||
<button type="button" class="location-tag" onclick={set_location("Main Office")}>{"Main Office"}</button>
|
||||
<button type="button" class="location-tag" onclick={set_location("Client Site")}>{"Client Site"}</button>
|
||||
<button type="button" class="location-tag" onclick={set_location("Home Office")}>{"Home Office"}</button>
|
||||
<button type="button" class="location-tag" onclick={set_location("Remote")}>{"Remote"}</button>
|
||||
</div>
|
||||
<p class="form-help-text">{"Click to quickly set common location types"}</p>
|
||||
</div>
|
||||
|
||||
<div class="location-info">
|
||||
<h5>{"Location Features & Integration"}</h5>
|
||||
<ul>
|
||||
<li>{"Location information is included in calendar invitations"}</li>
|
||||
<li>{"Supports both physical addresses and virtual meeting links"}</li>
|
||||
<li>{"Compatible with mapping and navigation applications"}</li>
|
||||
<li>{"Room booking integration available for enterprise setups"}</li>
|
||||
</ul>
|
||||
|
||||
<div class="geo-section">
|
||||
<h6>{"Geographic Coordinates (Advanced)"}</h6>
|
||||
<p>{"Future versions will support:"}</p>
|
||||
<div class="geo-features">
|
||||
<div class="geo-item">
|
||||
<strong>{"GPS Coordinates:"}</strong>
|
||||
<span>{"Precise latitude/longitude positioning"}</span>
|
||||
</div>
|
||||
<div class="geo-item">
|
||||
<strong>{"Map Integration:"}</strong>
|
||||
<span>{"Embedded maps in event details"}</span>
|
||||
</div>
|
||||
<div class="geo-item">
|
||||
<strong>{"Travel Time:"}</strong>
|
||||
<span>{"Automatic travel time calculation"}</span>
|
||||
</div>
|
||||
<div class="geo-item">
|
||||
<strong>{"Proximity Alerts:"}</strong>
|
||||
<span>{"Location-based notifications"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-help-text">{"Advanced geographic features will be implemented in future releases"}</p>
|
||||
</div>
|
||||
|
||||
<div class="virtual-meeting-section">
|
||||
<h6>{"Virtual Meeting Integration"}</h6>
|
||||
<div class="meeting-platforms">
|
||||
<div class="platform-item">
|
||||
<strong>{"Video Conferencing:"}</strong>
|
||||
<span>{"Zoom, Teams, Google Meet links"}</span>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<strong>{"Phone Conference:"}</strong>
|
||||
<span>{"Dial-in numbers and access codes"}</span>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<strong>{"Webinar Links:"}</strong>
|
||||
<span>{"Live streaming and presentation URLs"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-help-text">{"Paste meeting links directly in the location field for virtual events"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
16
frontend/src/components/event_form/mod.rs
Normal file
16
frontend/src/components/event_form/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// Event form components module
|
||||
pub mod types;
|
||||
pub mod basic_details;
|
||||
pub mod advanced;
|
||||
pub mod people;
|
||||
pub mod categories;
|
||||
pub mod location;
|
||||
pub mod reminders;
|
||||
|
||||
pub use types::*;
|
||||
pub use basic_details::BasicDetailsTab;
|
||||
pub use advanced::AdvancedTab;
|
||||
pub use people::PeopleTab;
|
||||
pub use categories::CategoriesTab;
|
||||
pub use location::LocationTab;
|
||||
pub use reminders::RemindersTab;
|
||||
103
frontend/src/components/event_form/people.rs
Normal file
103
frontend/src/components/event_form/people.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use super::types::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(PeopleTab)]
|
||||
pub fn people_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
let on_organizer_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.organizer = input.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_attendees_input = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.attendees = textarea.value();
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-group">
|
||||
<label for="event-organizer">{"Organizer"}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="event-organizer"
|
||||
class="form-input"
|
||||
value={data.organizer.clone()}
|
||||
oninput={on_organizer_input}
|
||||
placeholder="organizer@example.com"
|
||||
/>
|
||||
<p class="form-help-text">{"Email address of the person organizing this event"}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-attendees">{"Attendees"}</label>
|
||||
<textarea
|
||||
id="event-attendees"
|
||||
class="form-input"
|
||||
value={data.attendees.clone()}
|
||||
oninput={on_attendees_input}
|
||||
placeholder="attendee1@example.com, attendee2@example.com, attendee3@example.com"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<p class="form-help-text">{"Enter attendee email addresses separated by commas"}</p>
|
||||
</div>
|
||||
|
||||
<div class="people-info">
|
||||
<h5>{"Invitation & Response Management"}</h5>
|
||||
<ul>
|
||||
<li>{"Invitations are sent automatically when the event is saved"}</li>
|
||||
<li>{"Attendees can respond with Accept, Decline, or Tentative"}</li>
|
||||
<li>{"Response tracking follows RFC 5545 PARTSTAT standards"}</li>
|
||||
<li>{"Delegation and role management available after event creation"}</li>
|
||||
</ul>
|
||||
|
||||
<div class="people-validation">
|
||||
<h6>{"Email Validation"}</h6>
|
||||
<p>{"Email addresses will be validated when you save the event. Invalid emails will be highlighted and must be corrected before proceeding."}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attendee-roles-preview">
|
||||
<h5>{"Advanced Attendee Features"}</h5>
|
||||
<div class="role-examples">
|
||||
<div class="role-item">
|
||||
<strong>{"Required Participant:"}</strong>
|
||||
<span>{"Must attend for meeting to proceed"}</span>
|
||||
</div>
|
||||
<div class="role-item">
|
||||
<strong>{"Optional Participant:"}</strong>
|
||||
<span>{"Attendance welcome but not required"}</span>
|
||||
</div>
|
||||
<div class="role-item">
|
||||
<strong>{"Resource:"}</strong>
|
||||
<span>{"Meeting room, equipment, or facility"}</span>
|
||||
</div>
|
||||
<div class="role-item">
|
||||
<strong>{"Non-Participant:"}</strong>
|
||||
<span>{"For information only"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-help-text">{"Advanced role assignment and RSVP management will be available in future versions"}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
100
frontend/src/components/event_form/reminders.rs
Normal file
100
frontend/src/components/event_form/reminders.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use super::types::*;
|
||||
use crate::components::create_event_modal::ReminderType;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component(RemindersTab)]
|
||||
pub fn reminders_tab(props: &TabProps) -> Html {
|
||||
let data = &props.data;
|
||||
|
||||
let on_reminder_change = {
|
||||
let data = data.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(target) = e.target() {
|
||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||
let mut event_data = (*data).clone();
|
||||
event_data.reminder = match select.value().as_str() {
|
||||
"15min" => ReminderType::Minutes15,
|
||||
"30min" => ReminderType::Minutes30,
|
||||
"1hour" => ReminderType::Hour1,
|
||||
"1day" => ReminderType::Day1,
|
||||
"2days" => ReminderType::Days2,
|
||||
"1week" => ReminderType::Week1,
|
||||
_ => ReminderType::None,
|
||||
};
|
||||
data.set(event_data);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="tab-panel">
|
||||
<div class="form-group">
|
||||
<label for="event-reminder-main">{"Primary Reminder"}</label>
|
||||
<select
|
||||
id="event-reminder-main"
|
||||
class="form-input"
|
||||
onchange={on_reminder_change}
|
||||
>
|
||||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
|
||||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||||
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
|
||||
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
|
||||
</select>
|
||||
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
|
||||
</div>
|
||||
|
||||
<div class="reminder-types">
|
||||
<h5>{"Reminder & Alarm Types"}</h5>
|
||||
<div class="alarm-examples">
|
||||
<div class="alarm-type">
|
||||
<strong>{"Display Alarm"}</strong>
|
||||
<p>{"Pop-up notification on your device"}</p>
|
||||
</div>
|
||||
<div class="alarm-type">
|
||||
<strong>{"Email Reminder"}</strong>
|
||||
<p>{"Email notification sent to your address"}</p>
|
||||
</div>
|
||||
<div class="alarm-type">
|
||||
<strong>{"Audio Alert"}</strong>
|
||||
<p>{"Sound notification with custom audio"}</p>
|
||||
</div>
|
||||
<div class="alarm-type">
|
||||
<strong>{"SMS/Text"}</strong>
|
||||
<p>{"Text message reminder (enterprise feature)"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
|
||||
</div>
|
||||
|
||||
<div class="reminder-info">
|
||||
<h5>{"Advanced Reminder Features"}</h5>
|
||||
<ul>
|
||||
<li>{"Multiple reminders per event with different timing"}</li>
|
||||
<li>{"Custom reminder messages and descriptions"}</li>
|
||||
<li>{"Recurring reminders for recurring events"}</li>
|
||||
<li>{"Snooze and dismiss functionality"}</li>
|
||||
<li>{"Integration with system notifications"}</li>
|
||||
</ul>
|
||||
|
||||
<div class="attachments-section">
|
||||
<h6>{"File Attachments & Documents"}</h6>
|
||||
<p>{"Future attachment features will include:"}</p>
|
||||
<ul>
|
||||
<li>{"Drag-and-drop file uploads"}</li>
|
||||
<li>{"Document preview and thumbnails"}</li>
|
||||
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li>
|
||||
<li>{"Version control for updated documents"}</li>
|
||||
<li>{"Shared access permissions for attendees"}</li>
|
||||
</ul>
|
||||
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
180
frontend/src/components/event_form/types.rs
Normal file
180
frontend/src/components/event_form/types.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EventStatus {
|
||||
Confirmed,
|
||||
Tentative,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for EventStatus {
|
||||
fn default() -> Self {
|
||||
EventStatus::Confirmed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EventClass {
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
impl Default for EventClass {
|
||||
fn default() -> Self {
|
||||
EventClass::Public
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ReminderType {
|
||||
None,
|
||||
Minutes15,
|
||||
Minutes30,
|
||||
Hour1,
|
||||
Day1,
|
||||
Days2,
|
||||
Week1,
|
||||
}
|
||||
|
||||
impl Default for ReminderType {
|
||||
fn default() -> Self {
|
||||
ReminderType::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum RecurrenceType {
|
||||
None,
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl Default for RecurrenceType {
|
||||
fn default() -> Self {
|
||||
RecurrenceType::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ModalTab {
|
||||
BasicDetails,
|
||||
Advanced,
|
||||
People,
|
||||
Categories,
|
||||
Location,
|
||||
Reminders,
|
||||
}
|
||||
|
||||
impl Default for ModalTab {
|
||||
fn default() -> Self {
|
||||
ModalTab::BasicDetails
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EditAction {
|
||||
ThisOnly,
|
||||
ThisAndFuture,
|
||||
AllInSeries,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct EventCreationData {
|
||||
// Basic event info
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
|
||||
// Timing
|
||||
pub start_date: NaiveDate,
|
||||
pub end_date: NaiveDate,
|
||||
pub start_time: NaiveTime,
|
||||
pub end_time: NaiveTime,
|
||||
|
||||
// Classification
|
||||
pub status: EventStatus,
|
||||
pub class: EventClass,
|
||||
pub priority: Option<u8>,
|
||||
|
||||
// People
|
||||
pub organizer: String,
|
||||
pub attendees: String,
|
||||
|
||||
// Categorization
|
||||
pub categories: String,
|
||||
|
||||
// Reminders
|
||||
pub reminder: ReminderType,
|
||||
|
||||
// Recurrence
|
||||
pub recurrence: RecurrenceType,
|
||||
pub recurrence_interval: u32,
|
||||
pub recurrence_until: Option<NaiveDate>,
|
||||
pub recurrence_count: Option<u32>,
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
|
||||
// Advanced recurrence
|
||||
pub monthly_by_day: Option<String>, // e.g., "1MO" for first Monday
|
||||
pub monthly_by_monthday: Option<u8>, // e.g., 15 for 15th day of month
|
||||
pub yearly_by_month: Vec<bool>, // [Jan, Feb, Mar, ...]
|
||||
|
||||
// Calendar selection
|
||||
pub selected_calendar: Option<String>,
|
||||
|
||||
// Edit tracking (for recurring events)
|
||||
pub edit_scope: Option<EditAction>,
|
||||
pub changed_fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for EventCreationData {
|
||||
fn default() -> Self {
|
||||
let now_local = Local::now();
|
||||
let start_date = now_local.date_naive();
|
||||
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
|
||||
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
|
||||
|
||||
Self {
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
location: String::new(),
|
||||
all_day: false,
|
||||
start_date,
|
||||
end_date: start_date,
|
||||
start_time,
|
||||
end_time,
|
||||
status: EventStatus::default(),
|
||||
class: EventClass::default(),
|
||||
priority: None,
|
||||
organizer: String::new(),
|
||||
attendees: String::new(),
|
||||
categories: String::new(),
|
||||
reminder: ReminderType::default(),
|
||||
recurrence: RecurrenceType::default(),
|
||||
recurrence_interval: 1,
|
||||
recurrence_until: None,
|
||||
recurrence_count: None,
|
||||
recurrence_days: vec![false; 7],
|
||||
monthly_by_day: None,
|
||||
monthly_by_monthday: None,
|
||||
yearly_by_month: vec![false; 12],
|
||||
selected_calendar: None,
|
||||
edit_scope: None,
|
||||
changed_fields: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common props for all tab components
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct TabProps {
|
||||
pub data: UseStateHandle<crate::components::create_event_modal::EventCreationData>,
|
||||
pub available_calendars: Vec<CalendarInfo>,
|
||||
}
|
||||
@@ -63,7 +63,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"End:"}</strong>
|
||||
<span>{format_datetime(end, event.all_day)}</span>
|
||||
<span>{format_datetime_end(end, event.all_day)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
@@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||
if all_day {
|
||||
// For all-day events, subtract one day from end date for display
|
||||
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
||||
let display_date = *dt - chrono::Duration::days(1);
|
||||
display_date.format("%B %d, %Y").to_string()
|
||||
} else {
|
||||
dt.format("%B %d, %Y at %I:%M %p").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_recurrence_rule(rrule: &str) -> String {
|
||||
// Basic parsing of RRULE to display user-friendly text
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
|
||||
@@ -5,7 +5,9 @@ pub mod calendar_list_item;
|
||||
pub mod context_menu;
|
||||
pub mod create_calendar_modal;
|
||||
pub mod create_event_modal;
|
||||
pub mod create_event_modal_v2;
|
||||
pub mod event_context_menu;
|
||||
pub mod event_form;
|
||||
pub mod event_modal;
|
||||
pub mod login;
|
||||
pub mod month_view;
|
||||
@@ -23,6 +25,11 @@ pub use create_calendar_modal::CreateCalendarModal;
|
||||
pub use create_event_modal::{
|
||||
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
||||
};
|
||||
pub use create_event_modal_v2::CreateEventModalV2;
|
||||
pub use event_form::{
|
||||
EventClass as EventFormClass, EventCreationData as EventFormData, EventStatus as EventFormStatus,
|
||||
RecurrenceType as EventFormRecurrenceType, ReminderType as EventFormReminderType,
|
||||
};
|
||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||
pub use event_modal::EventModal;
|
||||
pub use login::Login;
|
||||
|
||||
@@ -32,6 +32,12 @@ pub enum Theme {
|
||||
Mint,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Style {
|
||||
Default,
|
||||
Google,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -60,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 {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
@@ -80,6 +110,8 @@ pub struct SidebarProps {
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
pub on_theme_change: Callback<Theme>,
|
||||
pub current_style: Style,
|
||||
pub on_style_change: Callback<Style>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
@@ -111,10 +143,22 @@ 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! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>{"Calendar App"}</h1>
|
||||
<h1>{"Runway"}</h1>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
html! {
|
||||
@@ -187,6 +231,13 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="style-selector">
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -319,11 +319,67 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
week_days.iter().map(|date| {
|
||||
let is_today = *date == props.today;
|
||||
let weekday_name = get_weekday_name(date.weekday());
|
||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||
|
||||
// Filter for all-day events only
|
||||
let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect();
|
||||
|
||||
html! {
|
||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||
<div class="weekday-name">{weekday_name}</div>
|
||||
<div class="day-number">{date.day()}</div>
|
||||
<div class="day-header-content">
|
||||
<div class="weekday-name">{weekday_name}</div>
|
||||
<div class="day-number">{date.day()}</div>
|
||||
</div>
|
||||
|
||||
// All-day events section
|
||||
{if !all_day_events.is_empty() {
|
||||
html! {
|
||||
<div class="all-day-events">
|
||||
{
|
||||
all_day_events.iter().map(|event| {
|
||||
let event_color = get_event_color(event);
|
||||
let onclick = {
|
||||
let on_event_click = props.on_event_click.clone();
|
||||
let event = (*event).clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_event_click.emit(event.clone());
|
||||
})
|
||||
};
|
||||
|
||||
let oncontextmenu = {
|
||||
if let Some(callback) = &props.on_event_context_menu {
|
||||
let callback = callback.clone();
|
||||
let event = (*event).clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
||||
callback.emit((e, event.clone()));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class="all-day-event"
|
||||
style={format!("background-color: {}", event_color)}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
>
|
||||
<span class="all-day-event-title">
|
||||
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
@@ -353,6 +409,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||
let is_today = *date == props.today;
|
||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||
let event_layouts = calculate_event_layout(&day_events, *date);
|
||||
|
||||
// Drag event handlers
|
||||
let drag_state_clone = drag_state.clone();
|
||||
@@ -398,6 +455,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
let time_increment = props.time_increment;
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only process mouse move if a button is still pressed
|
||||
if e.buttons() == 0 {
|
||||
// No mouse button pressed, clear drag state
|
||||
drag_state.set(None);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
||||
if current_drag.is_dragging {
|
||||
// Use layer_y for consistent coordinate calculation
|
||||
@@ -567,9 +631,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
// Check if currently dragging to create an event
|
||||
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
|
||||
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
html! {
|
||||
<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 },
|
||||
if is_creating_event { Some("creating-event") } else { None }
|
||||
)}
|
||||
{onmousedown}
|
||||
{onmousemove}
|
||||
{onmouseup}
|
||||
@@ -585,20 +660,20 @@ pub fn week_view(props: &WeekViewProps) -> 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
|
||||
<div class="events-container">
|
||||
{
|
||||
day_events.iter().filter_map(|event| {
|
||||
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||
|
||||
// Skip all-day events (they're rendered in the header)
|
||||
if is_all_day {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -787,12 +862,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
if is_refreshing { Some("refreshing") } else { None },
|
||||
if is_all_day { Some("all-day") } else { None }
|
||||
)}
|
||||
style={format!(
|
||||
"background-color: {}; top: {}px; height: {}px;",
|
||||
event_color,
|
||||
start_pixels,
|
||||
duration_pixels
|
||||
)}
|
||||
style={
|
||||
let (column_idx, total_columns) = event_layouts[event_idx];
|
||||
let column_width = if total_columns > 1 {
|
||||
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
|
||||
} else {
|
||||
"calc(100% - 8px)".to_string()
|
||||
};
|
||||
let left_offset = if total_columns > 1 {
|
||||
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
|
||||
} else {
|
||||
"4px".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
|
||||
event_color,
|
||||
start_pixels,
|
||||
duration_pixels,
|
||||
left_offset,
|
||||
column_width
|
||||
)
|
||||
}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
onmousedown={onmousedown_event}
|
||||
@@ -840,7 +931,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Temporary event box during drag
|
||||
{
|
||||
if let Some(drag) = (*drag_state).clone() {
|
||||
if drag.is_dragging && drag.start_date == *date {
|
||||
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
|
||||
match &drag.drag_type {
|
||||
DragType::CreateEvent => {
|
||||
let start_y = drag.start_y.min(drag.current_y);
|
||||
@@ -1029,8 +1120,13 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
|
||||
// Only position events that are on this specific date
|
||||
if event_date != date {
|
||||
// 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);
|
||||
|
||||
if !should_display_here {
|
||||
return (0.0, 0.0, false); // Event not on this date
|
||||
}
|
||||
|
||||
@@ -1065,3 +1161,111 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
||||
|
||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||
}
|
||||
|
||||
// Check if two events overlap in time
|
||||
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
||||
let end1 = if let Some(end) = event1.dtend {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
} else {
|
||||
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||
};
|
||||
|
||||
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
||||
let end2 = if let Some(end) = event2.dtend {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
} else {
|
||||
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||
};
|
||||
|
||||
// Events overlap if one starts before the other ends
|
||||
start1 < end2 && start2 < end1
|
||||
}
|
||||
|
||||
// Calculate layout columns for overlapping events
|
||||
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
|
||||
|
||||
// Filter and sort events that should appear on this date
|
||||
let mut day_events: Vec<_> = events.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, event)| {
|
||||
let (_, _, _) = calculate_event_position(event, date);
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
if event_date == date ||
|
||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||
Some((idx, event))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by start time
|
||||
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
||||
|
||||
// For each event, find all events it overlaps with
|
||||
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
||||
|
||||
for i in 0..day_events.len() {
|
||||
let (orig_idx_i, event_i) = day_events[i];
|
||||
|
||||
// Find all events that overlap with this event
|
||||
let mut overlapping_events = vec![i];
|
||||
for j in 0..day_events.len() {
|
||||
if i != j {
|
||||
let (_, event_j) = day_events[j];
|
||||
if events_overlap(event_i, event_j) {
|
||||
overlapping_events.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this event doesn't overlap with anything, it gets full width
|
||||
if overlapping_events.len() == 1 {
|
||||
event_columns[orig_idx_i] = (0, 1);
|
||||
} else {
|
||||
// This event overlaps - we need to calculate column layout
|
||||
// Sort the overlapping group by start time
|
||||
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
|
||||
|
||||
// Assign columns using a greedy algorithm
|
||||
let mut columns: Vec<Vec<usize>> = Vec::new();
|
||||
|
||||
for &event_idx in &overlapping_events {
|
||||
let (orig_idx, event) = day_events[event_idx];
|
||||
|
||||
// Find the first column where this event doesn't overlap with existing events
|
||||
let mut placed = false;
|
||||
for (col_idx, column) in columns.iter_mut().enumerate() {
|
||||
let can_place = column.iter().all(|&existing_idx| {
|
||||
let (_, existing_event) = day_events[existing_idx];
|
||||
!events_overlap(event, existing_event)
|
||||
});
|
||||
|
||||
if can_place {
|
||||
column.push(event_idx);
|
||||
event_columns[orig_idx] = (col_idx, columns.len());
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !placed {
|
||||
// Create new column
|
||||
columns.push(vec![event_idx]);
|
||||
event_columns[orig_idx] = (columns.len() - 1, columns.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Update total_columns for all events in this overlapping group
|
||||
let total_columns = columns.len();
|
||||
for &event_idx in &overlapping_events {
|
||||
let (orig_idx, _) = day_events[event_idx];
|
||||
event_columns[orig_idx].1 = total_columns;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event_columns
|
||||
}
|
||||
|
||||
@@ -439,9 +439,17 @@ impl CalendarService {
|
||||
let mut occurrence_event = base_event.clone();
|
||||
occurrence_event.dtstart = occurrence_datetime;
|
||||
occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence
|
||||
|
||||
|
||||
if let Some(end) = base_event.dtend {
|
||||
occurrence_event.dtend = Some(end + Duration::days(days_diff));
|
||||
if let Some(base_end) = base_event.dtend {
|
||||
if base_event.all_day {
|
||||
// For all-day events, maintain the RFC-5545 end date pattern
|
||||
// End date should always be exactly one day after start date
|
||||
occurrence_event.dtend = Some(occurrence_datetime + Duration::days(1));
|
||||
} else {
|
||||
// For timed events, preserve the original duration
|
||||
occurrence_event.dtend = Some(base_end + Duration::days(days_diff));
|
||||
}
|
||||
}
|
||||
|
||||
occurrences.push(occurrence_event);
|
||||
|
||||
@@ -2,4 +2,3 @@ pub mod calendar_service;
|
||||
pub mod preferences;
|
||||
|
||||
pub use calendar_service::CalendarService;
|
||||
pub use preferences::PreferencesService;
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct UserPreferences {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct UpdatePreferencesRequest {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
@@ -23,10 +24,12 @@ pub struct UpdatePreferencesRequest {
|
||||
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")
|
||||
@@ -147,7 +150,7 @@ impl PreferencesService {
|
||||
let session_token = LocalStorage::get::<String>("session_token")
|
||||
.map_err(|_| "No session token found".to_string())?;
|
||||
|
||||
let mut request = UpdatePreferencesRequest {
|
||||
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(),
|
||||
|
||||
@@ -1,9 +1,120 @@
|
||||
/* Base Styles - Always Loaded */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
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 {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
@@ -539,11 +650,14 @@ body {
|
||||
}
|
||||
|
||||
.week-day-header {
|
||||
padding: 1rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
background: var(--weekday-header-bg, #f8f9fa);
|
||||
color: var(--weekday-header-text, inherit);
|
||||
min-height: 70px; /* Ensure space for all-day events */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.week-day-header.today {
|
||||
@@ -569,17 +683,57 @@ body {
|
||||
color: var(--calendar-today-text, #1976d2);
|
||||
}
|
||||
|
||||
/* All-day events in header */
|
||||
.day-header-content {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all-day-events {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.all-day-event {
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
min-height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.all-day-event:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.all-day-event-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Week Content */
|
||||
.week-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Allow flex item to shrink below content size */
|
||||
}
|
||||
|
||||
.time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
min-height: 100%;
|
||||
min-height: 1530px;
|
||||
}
|
||||
|
||||
/* Time Labels */
|
||||
@@ -589,6 +743,7 @@ body {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
min-height: 1440px; /* Match the time slots height */
|
||||
}
|
||||
|
||||
.time-label {
|
||||
@@ -614,12 +769,13 @@ body {
|
||||
.week-days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */
|
||||
}
|
||||
|
||||
.week-day-column {
|
||||
position: relative;
|
||||
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 {
|
||||
@@ -668,8 +824,7 @@ body {
|
||||
/* Week Events */
|
||||
.week-event {
|
||||
position: absolute !important;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
/* left and width are now set inline for overlap handling */
|
||||
min-height: 20px;
|
||||
background: #3B82F6;
|
||||
color: white;
|
||||
@@ -689,6 +844,20 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Disable pointer events on existing events when creating a new event */
|
||||
.week-day-column.creating-event .week-event {
|
||||
pointer-events: none;
|
||||
opacity: 0.6; /* Visual feedback that events are not interactive */
|
||||
}
|
||||
|
||||
.week-day-column.creating-event .week-event .event-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.week-day-column.creating-event .week-event .resize-handle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.week-event:hover {
|
||||
filter: brightness(1.1);
|
||||
z-index: 4;
|
||||
@@ -3014,6 +3183,50 @@ body {
|
||||
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 */
|
||||
:root {
|
||||
/* 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;
|
||||
}
|
||||
BIN
sample.png
Normal file
BIN
sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Reference in New Issue
Block a user