Compare commits
187 Commits
7c83a4522c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933d7a8c1b | ||
|
|
c938f25951 | ||
|
|
c612f567b4 | ||
|
|
b5b53bb23a | ||
|
|
7e058ba972 | ||
|
|
1f86ea9f71 | ||
|
|
ce9914e388 | ||
|
|
faf5ce2cfd | ||
|
|
2fee7a15f9 | ||
|
|
7caf3539f7 | ||
|
|
1538869f4a | ||
|
|
7ce7d4c9d9 | ||
|
|
037b733d48 | ||
|
|
cb1bb23132 | ||
|
|
5c406569af | ||
|
|
4aca6c7fae | ||
|
|
fd80624429 | ||
|
|
b530dcaa69 | ||
|
|
0821573041 | ||
|
|
703c9ee2f5 | ||
|
|
5854ad291d | ||
|
|
ac1164fd81 | ||
|
|
a6092d13ce | ||
|
|
acc5ced551 | ||
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 | ||
| a4476dcfae | |||
|
|
ca1ca0c3b1 | ||
|
|
64dbf65beb | ||
|
|
96585440d1 | ||
|
|
a297d38276 | ||
|
|
4fdaa9931d | ||
|
|
c6c7b38bef | ||
|
|
78db2cc00f | ||
|
|
73d191c5ca | ||
| d930468748 | |||
|
|
91be4436a9 | ||
|
|
4cbc495c48 | ||
|
|
927cd7d2bb | ||
|
|
38b22287c7 | ||
|
|
0de2eee626 | ||
|
|
aa7a15e6fa | ||
|
|
b0a8ef09a8 | ||
|
|
efbaea5ac1 | ||
|
|
bbad327ea2 | ||
|
|
72273a3f1c | ||
|
|
8329244c69 | ||
|
|
b16603b50b | ||
|
|
c6eea88002 | ||
|
|
5876553515 | ||
|
|
d73bc78af5 | ||
|
|
393bfecff2 | ||
| aab478202b | |||
|
|
45e16313ba | ||
|
|
64c737c023 | ||
|
|
75d9149c76 | ||
|
|
28b3946e86 | ||
|
|
6a01a75cce | ||
|
|
189dd32f8c | ||
|
|
7461e8b123 | ||
|
|
f88c238b0a | ||
|
|
8caa1f45ae | ||
|
|
289284a532 | ||
|
|
089f4ce105 | ||
|
|
235dcf8e1d | ||
|
|
8dd60a8ec1 | ||
|
|
20679b6b53 | ||
|
|
53c4a99697 | ||
|
|
5ea33b7d0a | ||
|
|
13a752a69c | ||
|
|
0609a99839 | ||
|
|
dce82d5f7d | ||
|
|
1e8a8ce5f2 | ||
|
|
c0bdd3d8c2 | ||
|
|
2b98c4d229 | ||
|
|
ceae654a39 | ||
|
|
fb28fa95c9 | ||
|
|
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 | |||
|
|
0453763c98 | ||
|
|
03c0011445 | ||
|
|
79f287ed61 | ||
|
|
e55e6bf4dd | ||
| 1fa3bf44b6 | |||
|
|
51d5552156 | ||
|
|
5a12c0e0d0 | ||
|
|
ee181cf6cb | ||
|
|
74d636117d | ||
|
|
ed458e6c3a | ||
|
|
9b9378477a | ||
|
|
1b4a26e31a | ||
|
|
45c32a6d1e | ||
|
|
63968280b8 | ||
|
|
3ccf31f479 | ||
|
|
c599598390 | ||
|
|
d0aa6fda08 | ||
|
|
62c39b8aa5 | ||
|
|
75eddcf85d | ||
|
|
0babfc90f4 | ||
|
|
7538054b20 | ||
|
|
117dd2cc75 | ||
|
|
b9e8778f8f | ||
|
|
9536158f58 | ||
|
|
783e13eb10 | ||
|
|
1794cf9a59 | ||
|
|
ee1c6ee299 | ||
|
|
a6aac42c78 | ||
|
|
071fc3099f | ||
|
|
78f1db7203 | ||
|
|
e21430f6ff | ||
|
|
b195208ddc | ||
|
|
5cb77235da | ||
|
|
a6d72ce37f | ||
|
|
663b322d97 | ||
|
|
15f2d0c6d9 | ||
|
|
6887e0b389 | ||
|
|
f266d3f304 | ||
|
|
4af4aafd98 | ||
|
|
81805289e4 | ||
|
|
9f2f58e23e | ||
|
|
53ea5e3fc1 | ||
|
|
d35fc11267 | ||
|
|
697eb64dd4 | ||
|
|
d36609d8c2 | ||
|
|
e23278d71e | ||
|
|
edd209238f | ||
|
|
4fbef8a5dc | ||
|
|
edb216347d | ||
|
|
508c4f129f | ||
|
|
1c0140292f | ||
|
|
53815c4814 | ||
|
|
df714a43a2 | ||
|
|
a8bb2c8164 | ||
|
|
5d0628878b | ||
|
|
dacc18fe5d | ||
|
|
9ab6377d16 | ||
|
|
197157cecb | ||
|
|
c273a8625a | ||
|
|
2a2666e75f | ||
|
|
1b57adab98 | ||
|
|
e1578ed11c | ||
|
|
7a53228ec8 | ||
|
|
811cceae52 | ||
|
|
34461640af | ||
|
|
749ffaff58 | ||
|
|
3440403bed | ||
|
|
5c966b2571 | ||
|
|
7e62e3b7e3 | ||
|
|
b444ae710d | ||
|
|
c454104c69 | ||
|
|
f9c87369e5 | ||
|
|
f94d057f81 | ||
|
|
5d519fd875 |
@@ -1,6 +1,9 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
target/
|
target/
|
||||||
dist/
|
frontend/dist/
|
||||||
|
backend/target/
|
||||||
|
# Allow backend binary for multi-stage builds
|
||||||
|
!backend/target/release/backend
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git/
|
.git/
|
||||||
@@ -21,8 +24,18 @@ Thumbs.db
|
|||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
README.md
|
README.md
|
||||||
*.md
|
|
||||||
|
|
||||||
# Docker
|
# Development files
|
||||||
Dockerfile
|
CLAUDE.md
|
||||||
.dockerignore
|
*.txt
|
||||||
|
test_*.js
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
calendar.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
**/tests/
|
||||||
|
|
||||||
|
# Migrations (not needed for builds)
|
||||||
|
migrations/
|
||||||
|
|||||||
35
.gitea/workflows/docker.yml
Normal file
35
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
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: |
|
||||||
|
${{ vars.REGISTRY }}/connor/calendar:latest
|
||||||
|
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -16,4 +16,15 @@ dist/
|
|||||||
# Environment variables (secrets)
|
# Environment variables (secrets)
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Development notes (keep local)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
data/
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
calendar.db
|
||||||
|
|||||||
15
Caddyfile
Normal file
15
Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
default_sni rcjohnstone.com
|
||||||
|
key_type rsa4096
|
||||||
|
email c@rcjohnstone.com
|
||||||
|
}
|
||||||
|
|
||||||
|
:80, :443 {
|
||||||
|
@backend {
|
||||||
|
path /api /api/*
|
||||||
|
}
|
||||||
|
reverse_proxy @backend calendar-backend:3000
|
||||||
|
try_files {path} /index.html
|
||||||
|
root * /srv/www
|
||||||
|
file_server
|
||||||
|
}
|
||||||
56
Cargo.toml
56
Cargo.toml
@@ -1,48 +1,14 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "calendar-app"
|
members = [
|
||||||
version = "0.1.0"
|
"frontend",
|
||||||
edition = "2021"
|
"backend",
|
||||||
|
"calendar-models"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
# Frontend binary only
|
[workspace.dependencies]
|
||||||
|
calendar-models = { path = "calendar-models" }
|
||||||
[dependencies]
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
yew = { version = "0.21", features = ["csr"] }
|
|
||||||
web-sys = "0.3"
|
|
||||||
wasm-bindgen = "0.2"
|
|
||||||
|
|
||||||
# HTTP client for CalDAV requests
|
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
|
||||||
# Calendar and iCal parsing
|
|
||||||
ical = "0.7"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
# Date and time handling
|
|
||||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
|
||||||
chrono-tz = "0.8"
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
anyhow = "1.0"
|
|
||||||
thiserror = "1.0"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log = "0.4"
|
|
||||||
console_log = "1.0"
|
|
||||||
|
|
||||||
# UUID generation for calendar events
|
|
||||||
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
|
|
||||||
getrandom = { version = "0.2", features = ["js"] }
|
|
||||||
|
|
||||||
# Environment variable handling
|
|
||||||
dotenvy = "0.15"
|
|
||||||
base64 = "0.21"
|
|
||||||
|
|
||||||
# XML/Regex parsing
|
|
||||||
regex = "1.0"
|
|
||||||
|
|
||||||
# Yew routing and local storage (WASM only)
|
|
||||||
yew-router = "0.18"
|
|
||||||
gloo-storage = "0.3"
|
|
||||||
gloo-timers = "0.3"
|
|
||||||
wasm-bindgen-futures = "0.4"
|
|
||||||
|
|
||||||
67
Dockerfile
67
Dockerfile
@@ -1,67 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM rust:alpine AS builder
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
musl-dev \
|
|
||||||
pkgconfig \
|
|
||||||
openssl-dev \
|
|
||||||
nodejs \
|
|
||||||
npm
|
|
||||||
|
|
||||||
# Install trunk for building Yew apps
|
|
||||||
RUN cargo install trunk wasm-pack
|
|
||||||
|
|
||||||
# Add wasm32 target
|
|
||||||
RUN rustup target add wasm32-unknown-unknown
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy dependency files
|
|
||||||
COPY Cargo.toml ./
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
# Copy web assets
|
|
||||||
COPY index.html ./
|
|
||||||
COPY Trunk.toml ./
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN trunk build --release
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM docker.io/nginx:alpine
|
|
||||||
|
|
||||||
# Remove default nginx content
|
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
|
||||||
COPY --from=builder /app/dist/* /usr/share/nginx/html/
|
|
||||||
|
|
||||||
# Add nginx configuration for SPA
|
|
||||||
RUN echo 'server { \
|
|
||||||
listen 80; \
|
|
||||||
server_name localhost; \
|
|
||||||
root /usr/share/nginx/html; \
|
|
||||||
index index.html; \
|
|
||||||
location / { \
|
|
||||||
try_files $uri $uri/ /index.html; \
|
|
||||||
} \
|
|
||||||
# Enable gzip compression \
|
|
||||||
gzip on; \
|
|
||||||
gzip_types text/css application/javascript application/wasm; \
|
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
179
README.md
Normal file
179
README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 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 decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
|
||||||
|
|
||||||
|
A modern CalDAV web client built with Rust WebAssembly.
|
||||||
|
|
||||||
|
## The Name
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Calendar Management
|
||||||
|
- **Interactive Calendar Views**: Month and week views with intuitive navigation
|
||||||
|
- **Event Creation & Editing**: Comprehensive event forms with all standard iCalendar properties
|
||||||
|
- **Drag & Drop**: Move events between dates and times with automatic timezone conversion
|
||||||
|
- **CalDAV Integration**: Full bidirectional sync with any RFC-compliant CalDAV server
|
||||||
|
|
||||||
|
### Recurring Events
|
||||||
|
- **RFC 5545 Compliance**: Complete RRULE support with proper parsing and generation
|
||||||
|
- **Flexible Patterns**: Daily, weekly, monthly, and yearly recurrence with custom intervals
|
||||||
|
- **Advanced Options**: BYDAY rules, COUNT limits, UNTIL dates, and exception handling
|
||||||
|
- **Series Management**: Edit entire series or "this and future" events with proper UNTIL handling
|
||||||
|
|
||||||
|
### Modern Web Experience
|
||||||
|
- **Fast & Responsive**: Rust WebAssembly frontend for native-like performance
|
||||||
|
- **Clean Interface**: Modern, intuitive design built with web standards
|
||||||
|
- **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)
|
||||||
|
- **Framework**: Yew for component-based UI development
|
||||||
|
- **Performance**: Rust WebAssembly for near-native browser performance
|
||||||
|
- **Models**: RFC 5545-compliant VEvent structures throughout
|
||||||
|
- **Services**: HTTP client for backend API communication
|
||||||
|
- **Views**: Responsive month/week calendar views with drag-and-drop
|
||||||
|
|
||||||
|
### Backend (Axum)
|
||||||
|
- **Framework**: Axum async web framework with CORS support
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Key Technical Features
|
||||||
|
- **RFC 5545 Compliance**: Complete iCalendar standard implementation
|
||||||
|
- **RRULE Processing**: Advanced recurrence rule parsing and generation
|
||||||
|
- **Timezone Handling**: Local time in UI, UTC for storage and CalDAV sync
|
||||||
|
- **Event Series**: Proper handling of recurring event modifications and exceptions
|
||||||
|
- **Build System**: Trunk for frontend bundling, Cargo workspaces for organization
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 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`)
|
||||||
|
|
||||||
|
#### Local Development
|
||||||
|
|
||||||
|
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||||
|
```bash
|
||||||
|
cargo run --manifest-path=backend/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the frontend development server** (serves at http://localhost:8080):
|
||||||
|
```bash
|
||||||
|
trunk serve
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
trunk build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Commands
|
||||||
|
|
||||||
|
- `cargo check` - Check frontend compilation
|
||||||
|
- `cargo check --manifest-path=backend/Cargo.toml` - Check backend compilation
|
||||||
|
- `trunk serve` - Start frontend development server with hot reload
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
calendar/
|
||||||
|
├── frontend/ # Yew WebAssembly frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app.rs # Main app component with routing
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ │ ├── calendar.rs # Main calendar container
|
||||||
|
│ │ │ ├── month_view.rs # Month calendar view
|
||||||
|
│ │ │ ├── week_view.rs # Week calendar view
|
||||||
|
│ │ │ ├── create_event_modal.rs # Event creation form
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── ical.rs # RFC 5545 VEvent structures
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── calendar_service.rs # HTTP client & RRULE logic
|
||||||
|
│ ├── index.html # HTML template
|
||||||
|
│ └── Trunk.toml # Frontend build config
|
||||||
|
├── backend/ # Axum REST API server
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.rs # Server entry point
|
||||||
|
│ ├── handlers/ # API endpoint handlers
|
||||||
|
│ │ ├── events.rs # Event CRUD operations
|
||||||
|
│ │ └── series.rs # Recurring event operations
|
||||||
|
│ ├── auth.rs # JWT authentication
|
||||||
|
│ └── calendar.rs # CalDAV client implementation
|
||||||
|
└── CLAUDE.md # Development instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
## CalDAV Compatibility
|
||||||
|
|
||||||
|
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 Nextcloud calendar
|
||||||
|
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
||||||
|
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
||||||
|
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
||||||
|
|
||||||
|
*Note: While the client follows RFC standards and should work with any compliant CalDAV server, we have currently only tested extensively with Baikal. Testing with other servers is planned.*
|
||||||
19
Trunk.toml
19
Trunk.toml
@@ -1,19 +0,0 @@
|
|||||||
[build]
|
|
||||||
target = "index.html"
|
|
||||||
dist = "dist"
|
|
||||||
|
|
||||||
[env]
|
|
||||||
BACKEND_API_URL = "http://localhost:3000/api"
|
|
||||||
|
|
||||||
[watch]
|
|
||||||
watch = ["src", "Cargo.toml", "styles.css", "index.html"]
|
|
||||||
ignore = ["backend/"]
|
|
||||||
|
|
||||||
[serve]
|
|
||||||
address = "127.0.0.1"
|
|
||||||
port = 8080
|
|
||||||
open = false
|
|
||||||
|
|
||||||
[[copy]]
|
|
||||||
from = "styles.css"
|
|
||||||
to = "dist/"
|
|
||||||
@@ -8,6 +8,8 @@ name = "backend"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
calendar-models = { workspace = true }
|
||||||
|
|
||||||
# Backend authentication dependencies
|
# Backend authentication dependencies
|
||||||
jsonwebtoken = "9.0"
|
jsonwebtoken = "9.0"
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
@@ -20,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chrono-tz = "0.8"
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
@@ -30,6 +33,14 @@ regex = "1.0"
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
lazy_static = "1.4"
|
||||||
|
|
||||||
|
# Database dependencies
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||||
|
tokio-rusqlite = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
hyper = "1.0"
|
||||||
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"]
|
||||||
8
backend/migrations/001_create_users_table.sql
Normal file
8
backend/migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
server_url TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(username, server_url)
|
||||||
|
);
|
||||||
16
backend/migrations/002_create_sessions_table.sql
Normal file
16
backend/migrations/002_create_sessions_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Create sessions table
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_accessed TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster token lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||||
|
|
||||||
|
-- Index for cleanup of expired sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Create user preferences table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
calendar_selected_date TEXT,
|
||||||
|
calendar_time_increment INTEGER,
|
||||||
|
calendar_view_mode TEXT,
|
||||||
|
calendar_theme TEXT,
|
||||||
|
calendar_colors TEXT, -- JSON string for calendar color mappings
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
2
backend/migrations/004_add_style_preference.sql
Normal file
2
backend/migrations/004_add_style_preference.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add calendar style preference to user preferences
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';
|
||||||
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add last used calendar preference to user preferences
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN last_used_calendar TEXT;
|
||||||
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Create external_calendars table
|
||||||
|
CREATE TABLE external_calendars (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_fetched DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for performance
|
||||||
|
CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Create external calendar cache table for storing ICS data
|
||||||
|
CREATE TABLE external_calendar_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
external_calendar_id INTEGER NOT NULL,
|
||||||
|
ics_data TEXT NOT NULL,
|
||||||
|
cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
etag TEXT,
|
||||||
|
FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(external_calendar_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id);
|
||||||
|
CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at);
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||||
|
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthService {
|
pub struct AuthService {
|
||||||
jwt_secret: String,
|
jwt_secret: String,
|
||||||
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
pub fn new(jwt_secret: String) -> Self {
|
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||||
Self { jwt_secret }
|
Self { jwt_secret, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate user directly against CalDAV server
|
/// Authenticate user directly against CalDAV server
|
||||||
@@ -31,36 +34,69 @@ impl AuthService {
|
|||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
|
|
||||||
// Create CalDAV config with provided credentials
|
// Create CalDAV config with provided credentials
|
||||||
let caldav_config = CalDAVConfig {
|
let caldav_config = CalDAVConfig::new(
|
||||||
server_url: request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
username: request.username.clone(),
|
request.username.clone(),
|
||||||
password: request.password.clone(),
|
request.password.clone(),
|
||||||
calendar_path: None,
|
);
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
println!("📝 Created CalDAV config");
|
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(_calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
|
||||||
// Authentication successful, generate JWT token
|
// Find or create user in database
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
let user = user_repo
|
||||||
|
.find_or_create(&request.username, &request.server_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||||
|
|
||||||
|
// Create session in database
|
||||||
|
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
session_repo
|
||||||
|
.create(&session)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||||
|
|
||||||
|
// Get or create user preferences
|
||||||
|
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||||
|
let preferences = prefs_repo
|
||||||
|
.get_or_create(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
Ok(AuthResponse {
|
Ok(AuthResponse {
|
||||||
token,
|
token: jwt_token,
|
||||||
|
session_token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
server_url: request.server_url,
|
server_url: request.server_url,
|
||||||
|
preferences: UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,17 +106,30 @@ impl AuthService {
|
|||||||
self.decode_token(token)
|
self.decode_token(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Get user from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
let user_repo = UserRepository::new(&self.db);
|
||||||
server_url: claims.server_url,
|
user_repo
|
||||||
username: claims.username,
|
.find_or_create(&claims.username, &claims.server_url)
|
||||||
password: password.to_string(),
|
.await
|
||||||
calendar_path: None,
|
.map_err(|e| ApiError::Database(format!("Failed to get user: {}", e)))
|
||||||
tasks_path: None,
|
}
|
||||||
})
|
|
||||||
|
/// Create CalDAV config from token
|
||||||
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
|
Ok(CalDAVConfig::new(
|
||||||
|
claims.server_url,
|
||||||
|
claims.username,
|
||||||
|
password.to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||||
@@ -97,14 +146,17 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
|
pub fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
||||||
|
|
||||||
@@ -135,4 +187,33 @@ impl AuthService {
|
|||||||
|
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Validate session token
|
||||||
|
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
let session = session_repo
|
||||||
|
.find_by_token(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
|
||||||
|
|
||||||
|
if session.is_expired() {
|
||||||
|
return Err(ApiError::Unauthorized("Session expired".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user by deleting session
|
||||||
|
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
session_repo
|
||||||
|
.delete(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,30 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
/// This struct holds all the necessary information to connect to a CalDAV server,
|
||||||
/// including server URL, credentials, and optional collection paths.
|
/// including server URL, credentials, and optional collection paths.
|
||||||
///
|
///
|
||||||
/// # Security Note
|
/// # Security Note
|
||||||
///
|
///
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
/// The password field contains sensitive information and should be handled carefully.
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
/// This struct implements `Debug` but in production, consider implementing a custom
|
||||||
/// `Debug` that masks the password field.
|
/// `Debug` that masks the password field.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig {
|
||||||
/// // Load configuration from environment variables
|
/// server_url: "https://caldav.example.com".to_string(),
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
/// username: "user@example.com".to_string(),
|
||||||
///
|
/// password: "password".to_string(),
|
||||||
|
/// calendar_path: None,
|
||||||
|
/// tasks_path: None,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
/// // Use the configuration for HTTP requests
|
/// // Use the configuration for HTTP requests
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||||
/// ```
|
/// ```
|
||||||
@@ -28,103 +32,66 @@ use base64::prelude::*;
|
|||||||
pub struct CalDAVConfig {
|
pub struct CalDAVConfig {
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
/// Username for authentication with the CalDAV server
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
/// Password for authentication with the CalDAV server
|
||||||
///
|
///
|
||||||
/// **Security Note**: This contains sensitive information
|
/// **Security Note**: This contains sensitive information
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
/// Optional path to the calendar collection on the server
|
||||||
///
|
///
|
||||||
/// If not provided, the client will need to discover available calendars
|
/// If not provided, the client will discover available calendars
|
||||||
/// through CalDAV PROPFIND requests
|
/// through CalDAV PROPFIND requests
|
||||||
pub calendar_path: Option<String>,
|
pub calendar_path: Option<String>,
|
||||||
|
|
||||||
/// Optional path to the tasks/todo collection on the server
|
|
||||||
///
|
|
||||||
/// Some CalDAV servers store tasks separately from calendar events
|
|
||||||
pub tasks_path: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalDAVConfig {
|
impl CalDAVConfig {
|
||||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
/// Creates a new CalDAVConfig with the given credentials.
|
||||||
///
|
///
|
||||||
/// This method will attempt to load a `.env` file from the current directory
|
/// # Arguments
|
||||||
/// and then read the following required environment variables:
|
///
|
||||||
///
|
/// * `server_url` - The base URL of the CalDAV server
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
/// * `username` - Username for authentication
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
/// * `password` - Password for authentication
|
||||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
///
|
||||||
///
|
|
||||||
/// Optional environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
|
||||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
|
||||||
/// is not set or cannot be read.
|
|
||||||
///
|
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig::new(
|
||||||
/// match CalDAVConfig::from_env() {
|
/// "https://caldav.example.com".to_string(),
|
||||||
/// Ok(config) => {
|
/// "user@example.com".to_string(),
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
/// "password".to_string()
|
||||||
/// }
|
/// );
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
Self {
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
let server_url = env::var("CALDAV_SERVER_URL")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
|
||||||
|
|
||||||
let username = env::var("CALDAV_USERNAME")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
|
||||||
|
|
||||||
let password = env::var("CALDAV_PASSWORD")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
|
||||||
|
|
||||||
// Optional paths - it's fine if these are not set
|
|
||||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
|
||||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
|
||||||
server_url,
|
server_url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
calendar_path,
|
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||||
tasks_path,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||||
///
|
///
|
||||||
/// This method combines the username and password in the format
|
/// This method combines the username and password in the format
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
/// `username:password` and encodes it using Base64, which is the
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
/// standard format for the `Authorization: Basic` HTTP header.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A Base64-encoded string that can be used directly in the
|
/// A Base64-encoded string that can be used directly in the
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
///
|
||||||
/// let config = CalDAVConfig {
|
/// let config = CalDAVConfig {
|
||||||
/// server_url: "https://example.com".to_string(),
|
/// server_url: "https://example.com".to_string(),
|
||||||
/// username: "user".to_string(),
|
/// username: "user".to_string(),
|
||||||
@@ -132,7 +99,7 @@ impl CalDAVConfig {
|
|||||||
/// calendar_path: None,
|
/// calendar_path: None,
|
||||||
/// tasks_path: None,
|
/// tasks_path: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// let auth_value = config.get_basic_auth();
|
/// let auth_value = config.get_basic_auth();
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
/// let auth_header = format!("Basic {}", auth_value);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -146,15 +113,15 @@ impl CalDAVConfig {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
/// A required environment variable is missing or cannot be read.
|
/// A required environment variable is missing or cannot be read.
|
||||||
///
|
///
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
/// or `CALDAV_PASSWORD`) is not set.
|
||||||
#[error("Missing environment variable: {0}")]
|
#[error("Missing environment variable: {0}")]
|
||||||
MissingVar(String),
|
MissingVar(String),
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
/// The configuration contains invalid or malformed values.
|
||||||
///
|
///
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
/// This could include malformed URLs, invalid authentication credentials,
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
/// or other configuration issues that prevent proper CalDAV operation.
|
||||||
#[error("Invalid configuration: {0}")]
|
#[error("Invalid configuration: {0}")]
|
||||||
@@ -172,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -181,18 +147,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
/// Integration test that authenticates with the actual Baikal CalDAV server
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file with:
|
/// This test requires a valid .env file with:
|
||||||
/// - CALDAV_SERVER_URL
|
/// - CALDAV_SERVER_URL
|
||||||
/// - CALDAV_USERNAME
|
/// - CALDAV_USERNAME
|
||||||
/// - CALDAV_PASSWORD
|
/// - CALDAV_PASSWORD
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
/// Run with: `cargo test test_baikal_auth`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_baikal_auth() {
|
async fn test_baikal_auth() {
|
||||||
// Load config from .env
|
// Use test config - update these values to test with real server
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
|
|
||||||
@@ -202,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -220,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -230,14 +202,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
/// Test making a PROPFIND request to discover calendars
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
/// Run with: `cargo test test_propfind_calendars`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_propfind_calendars() {
|
async fn test_propfind_calendars() {
|
||||||
let config = CalDAVConfig::from_env()
|
// Use test config - update these values to test with real server
|
||||||
.expect("Failed to load CalDAV config from environment");
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -253,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -265,7 +247,7 @@ mod tests {
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("PROPFIND Response status: {}", status);
|
println!("PROPFIND Response status: {}", status);
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
let body = response.text().await.expect("Failed to read response body");
|
||||||
println!("PROPFIND Response body: {}", body);
|
println!("PROPFIND Response body: {}", body);
|
||||||
|
|
||||||
@@ -277,8 +259,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
488
backend/src/db.rs
Normal file
488
backend/src/db.rs
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||||
|
use sqlx::{FromRow, Result};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Database connection pool wrapper
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
pool: Arc<SqlitePool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// Create a new database connection pool
|
||||||
|
pub async fn new(database_url: &str) -> Result<Self> {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
pool: Arc::new(pool),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the connection pool
|
||||||
|
pub fn pool(&self) -> &SqlitePool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User model representing a CalDAV user
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String, // UUID as string for SQLite
|
||||||
|
pub username: String,
|
||||||
|
pub server_url: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
/// Create a new user with generated UUID
|
||||||
|
pub fn new(username: String, server_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
username,
|
||||||
|
server_url,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session model for user sessions
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String, // UUID as string
|
||||||
|
pub user_id: String, // Foreign key to User
|
||||||
|
pub token: String, // Session token
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub last_accessed: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Create a new session for a user
|
||||||
|
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
user_id,
|
||||||
|
token,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: now + chrono::Duration::hours(expires_in_hours),
|
||||||
|
last_accessed: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the session has expired
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User preferences model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct UserPreferences {
|
||||||
|
pub user_id: String,
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>, // JSON string
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External calendar model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct ExternalCalendar {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_fetched: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalCalendar {
|
||||||
|
/// Create a new external calendar
|
||||||
|
pub fn new(user_id: String, name: String, url: String, color: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: 0, // Will be set by database
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
color,
|
||||||
|
is_visible: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_fetched: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserPreferences {
|
||||||
|
/// Create default preferences for a new user
|
||||||
|
pub fn default_for_user(user_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id,
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: Some(15),
|
||||||
|
calendar_view_mode: Some("month".to_string()),
|
||||||
|
calendar_theme: Some("light".to_string()),
|
||||||
|
calendar_style: Some("default".to_string()),
|
||||||
|
calendar_colors: None,
|
||||||
|
last_used_calendar: None,
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for User operations
|
||||||
|
pub struct UserRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> UserRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find or create a user by username and server URL
|
||||||
|
pub async fn find_or_create(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
server_url: &str,
|
||||||
|
) -> Result<User> {
|
||||||
|
// Try to find existing user
|
||||||
|
let existing = sqlx::query_as::<_, User>(
|
||||||
|
"SELECT * FROM users WHERE username = ? AND server_url = ?",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(server_url)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(user) = existing {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
let user = User::new(username.to_string(), server_url.to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&user.username)
|
||||||
|
.bind(&user.server_url)
|
||||||
|
.bind(&user.created_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a user by ID
|
||||||
|
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for Session operations
|
||||||
|
pub struct SessionRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SessionRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session
|
||||||
|
pub async fn create(&self, session: &Session) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&session.id)
|
||||||
|
.bind(&session.user_id)
|
||||||
|
.bind(&session.token)
|
||||||
|
.bind(&session.created_at)
|
||||||
|
.bind(&session.expires_at)
|
||||||
|
.bind(&session.last_accessed)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a session by token and update last_accessed
|
||||||
|
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
|
||||||
|
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
if !s.is_expired() {
|
||||||
|
// Update last_accessed time
|
||||||
|
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(&s.id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a session (logout)
|
||||||
|
pub async fn delete(&self, token: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||||
|
.bind(token)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired sessions
|
||||||
|
pub async fn cleanup_expired(&self) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for UserPreferences operations
|
||||||
|
pub struct PreferencesRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PreferencesRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user preferences, creating defaults if not exist
|
||||||
|
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
|
||||||
|
let existing = sqlx::query_as::<_, UserPreferences>(
|
||||||
|
"SELECT * FROM user_preferences WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(prefs) = existing {
|
||||||
|
Ok(prefs)
|
||||||
|
} else {
|
||||||
|
// Create default preferences
|
||||||
|
let prefs = UserPreferences::default_for_user(user_id.to_string());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO user_preferences
|
||||||
|
(user_id, calendar_selected_date, calendar_time_increment,
|
||||||
|
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, 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.last_used_calendar)
|
||||||
|
.bind(&prefs.updated_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user preferences
|
||||||
|
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE user_preferences
|
||||||
|
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||||
|
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
||||||
|
calendar_colors = ?, last_used_calendar = ?, updated_at = ?
|
||||||
|
WHERE user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&prefs.calendar_selected_date)
|
||||||
|
.bind(&prefs.calendar_time_increment)
|
||||||
|
.bind(&prefs.calendar_view_mode)
|
||||||
|
.bind(&prefs.calendar_theme)
|
||||||
|
.bind(&prefs.calendar_style)
|
||||||
|
.bind(&prefs.calendar_colors)
|
||||||
|
.bind(&prefs.last_used_calendar)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(&prefs.user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for ExternalCalendar operations
|
||||||
|
pub struct ExternalCalendarRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ExternalCalendarRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all external calendars for a user
|
||||||
|
pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> {
|
||||||
|
sqlx::query_as::<_, ExternalCalendar>(
|
||||||
|
"SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(self.db.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new external calendar
|
||||||
|
pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&calendar.user_id)
|
||||||
|
.bind(&calendar.name)
|
||||||
|
.bind(&calendar.url)
|
||||||
|
.bind(&calendar.color)
|
||||||
|
.bind(&calendar.is_visible)
|
||||||
|
.bind(&calendar.created_at)
|
||||||
|
.bind(&calendar.updated_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an external calendar
|
||||||
|
pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE external_calendars
|
||||||
|
SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&calendar.name)
|
||||||
|
.bind(&calendar.url)
|
||||||
|
.bind(&calendar.color)
|
||||||
|
.bind(&calendar.is_visible)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(id)
|
||||||
|
.bind(&calendar.user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an external calendar
|
||||||
|
pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update last_fetched timestamp
|
||||||
|
pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached ICS data for an external calendar
|
||||||
|
pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> {
|
||||||
|
let result = sqlx::query_as::<_, (String, DateTime<Utc>)>(
|
||||||
|
"SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cache with new ICS data
|
||||||
|
pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(external_calendar_id) DO UPDATE SET
|
||||||
|
ics_data = excluded.ics_data,
|
||||||
|
etag = excluded.etag,
|
||||||
|
cached_at = excluded.cached_at",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.bind(ics_data)
|
||||||
|
.bind(etag)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if cache is stale (older than max_age_minutes)
|
||||||
|
pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> {
|
||||||
|
let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes);
|
||||||
|
|
||||||
|
let result = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM external_calendar_cache
|
||||||
|
WHERE external_calendar_id = ? AND cached_at > ?",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.bind(cutoff_time)
|
||||||
|
.fetch_one(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear cache for an external calendar
|
||||||
|
pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?")
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
|
|||||||
use crate::config::CalDAVConfig;
|
use crate::config::CalDAVConfig;
|
||||||
|
|
||||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = CalDAVConfig::from_env()?;
|
// Use debug/test configuration
|
||||||
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"debug_user".to_string(),
|
||||||
|
"debug_password".to_string()
|
||||||
|
);
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
println!("=== DEBUG: CalDAV Fetch ===");
|
println!("=== DEBUG: CalDAV Fetch ===");
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{State, Query, Path},
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use chrono::Datelike;
|
|
||||||
|
|
||||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CalendarQuery {
|
|
||||||
pub year: Option<i32>,
|
|
||||||
pub month: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_calendar_events(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Query(params): Query<CalendarQuery>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
|
|
||||||
// Extract and verify token
|
|
||||||
let token = extract_bearer_token(&headers)?;
|
|
||||||
let password = extract_password_header(&headers)?;
|
|
||||||
println!("🔑 API call with password length: {}", password.len());
|
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
|
||||||
let client = CalDAVClient::new(config);
|
|
||||||
|
|
||||||
// Discover calendars if needed
|
|
||||||
let calendar_paths = client.discover_calendars()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
|
||||||
return Ok(Json(vec![])); // No calendars found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch events from the first calendar
|
|
||||||
let calendar_path = &calendar_paths[0];
|
|
||||||
let events = client.fetch_events(calendar_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch events: {}", e)))?;
|
|
||||||
|
|
||||||
// Filter events by month if specified
|
|
||||||
let filtered_events = if let (Some(year), Some(month)) = (params.year, params.month) {
|
|
||||||
events.into_iter().filter(|event| {
|
|
||||||
let event_date = event.start.date_naive();
|
|
||||||
event_date.year() == year && event_date.month() == month
|
|
||||||
}).collect()
|
|
||||||
} else {
|
|
||||||
events
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(filtered_events))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn refresh_event(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Path(uid): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
|
||||||
// Extract and verify token
|
|
||||||
let token = extract_bearer_token(&headers)?;
|
|
||||||
let password = extract_password_header(&headers)?;
|
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
|
||||||
let client = CalDAVClient::new(config);
|
|
||||||
|
|
||||||
// Discover calendars if needed
|
|
||||||
let calendar_paths = client.discover_calendars()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
|
||||||
return Ok(Json(None)); // No calendars found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the specific event by UID from the first calendar
|
|
||||||
let calendar_path = &calendar_paths[0];
|
|
||||||
let event = client.fetch_event_by_uid(calendar_path, &uid)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(Json(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
Json(request): Json<CalDAVLoginRequest>,
|
|
||||||
) -> Result<Json<AuthResponse>, ApiError> {
|
|
||||||
println!("🔐 Login attempt:");
|
|
||||||
println!(" Server URL: {}", request.server_url);
|
|
||||||
println!(" Username: {}", request.username);
|
|
||||||
println!(" Password length: {}", request.password.len());
|
|
||||||
|
|
||||||
let response = state.auth_service.login(request).await?;
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn verify_token(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
||||||
let token = extract_bearer_token(&headers)?;
|
|
||||||
let claims = state.auth_service.verify_token(&token)?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"valid": true,
|
|
||||||
"username": claims.username,
|
|
||||||
"server_url": claims.server_url
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_info(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<UserInfo>, ApiError> {
|
|
||||||
// Extract and verify token
|
|
||||||
let token = extract_bearer_token(&headers)?;
|
|
||||||
let password = extract_password_header(&headers)?;
|
|
||||||
let claims = state.auth_service.verify_token(&token)?;
|
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
|
||||||
let client = CalDAVClient::new(config);
|
|
||||||
|
|
||||||
// Discover calendars
|
|
||||||
let calendar_paths = client.discover_calendars()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
||||||
|
|
||||||
// Convert paths to CalendarInfo structs with display names, filtering out generic collections
|
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths.into_iter()
|
|
||||||
.filter_map(|path| {
|
|
||||||
let display_name = extract_calendar_name(&path);
|
|
||||||
// Skip generic collection names
|
|
||||||
if display_name.eq_ignore_ascii_case("calendar") ||
|
|
||||||
display_name.eq_ignore_ascii_case("calendars") ||
|
|
||||||
display_name.eq_ignore_ascii_case("collection") {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(CalendarInfo {
|
|
||||||
path,
|
|
||||||
display_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Ok(Json(UserInfo {
|
|
||||||
username: claims.username,
|
|
||||||
server_url: claims.server_url,
|
|
||||||
calendars,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to extract a readable calendar name from path
|
|
||||||
fn extract_calendar_name(path: &str) -> String {
|
|
||||||
// Extract the last meaningful part of the path
|
|
||||||
// e.g., "/calendars/user/personal/" -> "personal"
|
|
||||||
// or "/calendars/user/work-calendar/" -> "work-calendar"
|
|
||||||
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
|
|
||||||
|
|
||||||
if let Some(last_part) = parts.last() {
|
|
||||||
if !last_part.is_empty() && *last_part != "calendars" {
|
|
||||||
// Convert kebab-case or snake_case to title case
|
|
||||||
last_part
|
|
||||||
.replace('-', " ")
|
|
||||||
.replace('_', " ")
|
|
||||||
.split_whitespace()
|
|
||||||
.map(|word| {
|
|
||||||
let mut chars = word.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
} else if parts.len() > 1 {
|
|
||||||
// If the last part is empty or "calendars", try the second to last
|
|
||||||
extract_calendar_name(&parts[..parts.len()-1].join("/"))
|
|
||||||
} else {
|
|
||||||
"Calendar".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Calendar".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
|
||||||
if let Some(auth_header) = headers.get("authorization") {
|
|
||||||
let auth_str = auth_header
|
|
||||||
.to_str()
|
|
||||||
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
|
|
||||||
|
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
|
||||||
Ok(token.to_string())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(ApiError::Unauthorized("Authorization header required".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
|
||||||
if let Some(password_header) = headers.get("x-caldav-password") {
|
|
||||||
let password = password_header
|
|
||||||
.to_str()
|
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
|
|
||||||
Ok(password.to_string())
|
|
||||||
} else {
|
|
||||||
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
140
backend/src/handlers/auth.rs
Normal file
140
backend/src/handlers/auth.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||||
|
|
||||||
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
|
Ok(token.to_string())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest(
|
||||||
|
"Authorization header must be Bearer token".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
|
password_header
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<CalDAVLoginRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, ApiError> {
|
||||||
|
println!("🔐 Login attempt:");
|
||||||
|
println!(" Server URL: {}", request.server_url);
|
||||||
|
println!(" Username: {}", request.username);
|
||||||
|
println!(" Password length: {}", request.password.len());
|
||||||
|
|
||||||
|
// Use the auth service login method which now handles database, sessions, and preferences
|
||||||
|
let response = state.auth_service.login(request).await?;
|
||||||
|
|
||||||
|
println!("✅ Login successful with session management");
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_info(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<UserInfo>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
|
// Discover calendars
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
|
||||||
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| CalendarInfo {
|
||||||
|
path: path.clone(),
|
||||||
|
display_name: extract_calendar_name(path),
|
||||||
|
color: generate_calendar_color(path),
|
||||||
|
is_visible: true, // Default to visible
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(UserInfo {
|
||||||
|
username: config.username,
|
||||||
|
server_url: config.server_url,
|
||||||
|
calendars,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_calendar_color(path: &str) -> String {
|
||||||
|
// Generate a consistent color based on the calendar path
|
||||||
|
// This is a simple hash-based approach
|
||||||
|
let mut hash: u32 = 0;
|
||||||
|
for byte in path.bytes() {
|
||||||
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a set of pleasant colors
|
||||||
|
let colors = [
|
||||||
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||||
|
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||||
|
];
|
||||||
|
|
||||||
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_calendar_name(path: &str) -> String {
|
||||||
|
// Extract calendar name from path
|
||||||
|
// E.g., "/calendars/user/calendar-name/" -> "Calendar Name"
|
||||||
|
path.split('/')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.last()
|
||||||
|
.unwrap_or("Calendar")
|
||||||
|
.replace('-', " ")
|
||||||
|
.replace('_', " ")
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|word| {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
94
backend/src/handlers/calendar.rs
Normal file
94
backend/src/handlers/calendar.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
|
pub async fn create_calendar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<CreateCalendarRequest>,
|
||||||
|
) -> Result<Json<CreateCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.name.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar name is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Create calendar on CalDAV server
|
||||||
|
match client
|
||||||
|
.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(Json(CreateCalendarResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Calendar created successfully".to_string(),
|
||||||
|
})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to create calendar: {}", e);
|
||||||
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to create calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_calendar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<DeleteCalendarRequest>,
|
||||||
|
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.path.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Delete calendar on CalDAV server
|
||||||
|
match client.delete_calendar(&request.path).await {
|
||||||
|
Ok(_) => Ok(Json(DeleteCalendarResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Calendar deleted successfully".to_string(),
|
||||||
|
})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
936
backend/src/handlers/events.rs
Normal file
936
backend/src/handlers/events.rs
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
||||||
|
UpdateEventRequest, UpdateEventResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{
|
||||||
|
Attendee, CalendarUser, EventClass, EventStatus, VEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CalendarQuery {
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub month: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_calendar_events(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<CalendarQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Discover calendars if needed
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
if calendar_paths.is_empty() {
|
||||||
|
return Ok(Json(vec![])); // No calendars found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events from all calendars
|
||||||
|
let mut all_events = Vec::new();
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
match client.fetch_events(calendar_path).await {
|
||||||
|
Ok(mut events) => {
|
||||||
|
// Set calendar_path for each event to identify which calendar it belongs to
|
||||||
|
for event in &mut events {
|
||||||
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
}
|
||||||
|
all_events.extend(events);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
|
// Continue with other calendars instead of failing completely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_date = event.dtstart.date();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(all_events))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(uid): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Discover calendars
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
// Search for the event by UID across all calendars
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
||||||
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
return Ok(Json(Some(event)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_event_by_href(
|
||||||
|
client: &CalDAVClient,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||||
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
||||||
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
||||||
|
let events = client.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
||||||
|
println!(
|
||||||
|
"🔍 Available events with hrefs: {:?}",
|
||||||
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// First try to match by exact href
|
||||||
|
for event in &events {
|
||||||
|
if let Some(stored_href) = &event.href {
|
||||||
|
if stored_href == event_href {
|
||||||
|
println!("✅ Found matching event by exact href: {}", event.uid);
|
||||||
|
return Ok(Some(event.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to match by UID extracted from href filename
|
||||||
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||||
|
let uid_from_href = filename.trim_end_matches(".ics");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
||||||
|
filename, uid_from_href
|
||||||
|
);
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if event.uid == uid_from_href {
|
||||||
|
println!("✅ Found matching event by UID: {}", event.uid);
|
||||||
|
return Ok(Some(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("❌ No matching event found for href: {}", event_href);
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<DeleteEventRequest>,
|
||||||
|
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Handle different delete actions for recurring events
|
||||||
|
match request.delete_action.as_str() {
|
||||||
|
"delete_this" => {
|
||||||
|
if let Some(event) =
|
||||||
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
|
// Check if this is a recurring event
|
||||||
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
|
// Recurring event - add EXDATE for this occurrence
|
||||||
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
|
let exception_datetime = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
|
// RFC3339 format (with time and timezone) - convert to naive
|
||||||
|
date.naive_utc()
|
||||||
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
|
// Simple date format (YYYY-MM-DD)
|
||||||
|
naive_date.and_hms_opt(0, 0, 0).unwrap()
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut updated_event = event;
|
||||||
|
updated_event.exdate.push(exception_datetime);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
|
exception_datetime.format("%Y%m%dT%H%M%S"),
|
||||||
|
updated_event.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the event with the new EXDATE
|
||||||
|
client
|
||||||
|
.update_event(
|
||||||
|
&request.calendar_path,
|
||||||
|
&updated_event,
|
||||||
|
&request.event_href,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with EXDATE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Single occurrence deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-recurring event - delete the entire event
|
||||||
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"delete_following" => {
|
||||||
|
// For "this and following" deletion, we need to:
|
||||||
|
// 1. Fetch the recurring event
|
||||||
|
// 2. Modify the RRULE to end before this occurrence
|
||||||
|
// 3. Update the event
|
||||||
|
|
||||||
|
if let Some(mut event) =
|
||||||
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
|
let until_date = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
|
// RFC3339 format (with time and timezone)
|
||||||
|
date.with_timezone(&chrono::Utc)
|
||||||
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
|
// Simple date format (YYYY-MM-DD)
|
||||||
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
||||||
|
occurrence_date
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modify the RRULE to add an UNTIL clause
|
||||||
|
if let Some(rrule) = &event.rrule {
|
||||||
|
// Remove existing UNTIL if present and add new one
|
||||||
|
let parts: Vec<&str> = rrule
|
||||||
|
.split(';')
|
||||||
|
.filter(|part| {
|
||||||
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let new_rrule = format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
until_date.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
|
event.rrule = Some(new_rrule);
|
||||||
|
|
||||||
|
// Update the event with the modified RRULE
|
||||||
|
client
|
||||||
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with modified RRULE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "This and following occurrences deleted successfully"
|
||||||
|
.to_string(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// No RRULE, just delete the single event
|
||||||
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest(
|
||||||
|
"Occurrence date is required for following deletion".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"delete_series" | _ => {
|
||||||
|
// Delete the entire event/series
|
||||||
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<CreateEventRequest>,
|
||||||
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||||
|
println!(
|
||||||
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||||
|
request.title, request.all_day, request.calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.len() > 200 {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Determine which calendar to use
|
||||||
|
let calendar_path = if let Some(path) = request.calendar_path {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
// Use the first available calendar
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
if calendar_paths.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar_paths[0].clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
|
let start_datetime =
|
||||||
|
parse_event_datetime_local(&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_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
|
// Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
|
||||||
|
// No additional conversion needed here
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
|
"tentative" => EventStatus::Tentative,
|
||||||
|
"cancelled" => EventStatus::Cancelled,
|
||||||
|
_ => EventStatus::Confirmed,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse class
|
||||||
|
let class = match request.class.to_lowercase().as_str() {
|
||||||
|
"private" => EventClass::Private,
|
||||||
|
"confidential" => EventClass::Confidential,
|
||||||
|
_ => EventClass::Public,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse attendees (comma-separated email list)
|
||||||
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.attendees
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse categories (comma-separated list)
|
||||||
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.categories
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use VAlarms directly from request (no conversion needed)
|
||||||
|
let alarms = request.alarms;
|
||||||
|
|
||||||
|
// Check if recurrence is already a full RRULE or just a simple type
|
||||||
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
|
// Frontend sent a complete RRULE string, use it directly
|
||||||
|
if request.recurrence.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.recurrence.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy path: Parse recurrence with BYDAY support for weekly recurrence
|
||||||
|
match request.recurrence.to_uppercase().as_str() {
|
||||||
|
"DAILY" => Some("FREQ=DAILY".to_string()),
|
||||||
|
"WEEKLY" => {
|
||||||
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
|
if request.recurrence_days.len() == 7 {
|
||||||
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, &selected)| {
|
||||||
|
if selected {
|
||||||
|
Some(match i {
|
||||||
|
0 => "SU", // Sunday
|
||||||
|
1 => "MO", // Monday
|
||||||
|
2 => "TU", // Tuesday
|
||||||
|
3 => "WE", // Wednesday
|
||||||
|
4 => "TH", // Thursday
|
||||||
|
5 => "FR", // Friday
|
||||||
|
6 => "SA", // Saturday
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !selected_days.is_empty() {
|
||||||
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule)
|
||||||
|
}
|
||||||
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the VEvent struct (RFC 5545 compliant) with local times
|
||||||
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
|
event.dtend = Some(end_datetime);
|
||||||
|
|
||||||
|
// Set timezone information from client
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
|
event.summary = if request.title.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
|
event.status = Some(status);
|
||||||
|
event.class = Some(class);
|
||||||
|
event.priority = request.priority;
|
||||||
|
event.organizer = if request.organizer.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(CalendarUser {
|
||||||
|
cal_address: request.organizer,
|
||||||
|
common_name: None,
|
||||||
|
dir_entry_ref: None,
|
||||||
|
sent_by: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
event.attendees = attendees
|
||||||
|
.into_iter()
|
||||||
|
.map(|email| Attendee {
|
||||||
|
cal_address: email,
|
||||||
|
common_name: None,
|
||||||
|
role: None,
|
||||||
|
part_stat: None,
|
||||||
|
rsvp: None,
|
||||||
|
cu_type: None,
|
||||||
|
member: Vec::new(),
|
||||||
|
delegated_to: Vec::new(),
|
||||||
|
delegated_from: Vec::new(),
|
||||||
|
sent_by: None,
|
||||||
|
dir_entry_ref: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
event.categories = categories;
|
||||||
|
event.rrule = rrule;
|
||||||
|
event.all_day = request.all_day;
|
||||||
|
event.alarms = alarms;
|
||||||
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
|
// Create the event on the CalDAV server
|
||||||
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(CreateEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event created successfully".to_string(),
|
||||||
|
event_href: Some(event_href),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<UpdateEventRequest>,
|
||||||
|
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
||||||
|
// Handle update request
|
||||||
|
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.uid.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.len() > 200 {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CalDAV config from token and password
|
||||||
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
|
vec![path.clone()]
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
||||||
|
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
match client.fetch_events(calendar_path).await {
|
||||||
|
Ok(events) => {
|
||||||
|
for event in events {
|
||||||
|
if event.uid == request.uid {
|
||||||
|
// Use the actual href from the event, or generate one if missing
|
||||||
|
let event_href = event
|
||||||
|
.href
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
||||||
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found_event.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut event, calendar_path, event_href) = found_event
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
|
println!("🕐 UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||||
|
request.start_date, request.start_time, request.timezone);
|
||||||
|
let start_datetime =
|
||||||
|
parse_event_datetime_local(&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_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
|
// Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
|
||||||
|
// No additional conversion needed here
|
||||||
|
|
||||||
|
// 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 with local times and timezone info
|
||||||
|
event.dtstart = start_datetime;
|
||||||
|
event.dtend = Some(end_datetime);
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
|
event.summary = if request.title.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.title)
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
|
event.all_day = request.all_day;
|
||||||
|
|
||||||
|
// Parse and update status
|
||||||
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
||||||
|
"tentative" => EventStatus::Tentative,
|
||||||
|
"cancelled" => EventStatus::Cancelled,
|
||||||
|
_ => EventStatus::Confirmed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse and update class
|
||||||
|
event.class = Some(match request.class.to_lowercase().as_str() {
|
||||||
|
"private" => EventClass::Private,
|
||||||
|
"confidential" => EventClass::Confidential,
|
||||||
|
_ => EventClass::Public,
|
||||||
|
});
|
||||||
|
|
||||||
|
event.priority = request.priority;
|
||||||
|
|
||||||
|
// Process recurrence information to set RRULE
|
||||||
|
println!("🔄 Processing recurrence: '{}'", request.recurrence);
|
||||||
|
println!("🔄 Recurrence days: {:?}", request.recurrence_days);
|
||||||
|
println!("🔄 Recurrence interval: {:?}", request.recurrence_interval);
|
||||||
|
println!("🔄 Recurrence count: {:?}", request.recurrence_count);
|
||||||
|
println!("🔄 Recurrence end date: {:?}", request.recurrence_end_date);
|
||||||
|
|
||||||
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
|
// Frontend sent a complete RRULE string, use it directly
|
||||||
|
if request.recurrence.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.recurrence.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse recurrence type and build RRULE with all parameters
|
||||||
|
let base_rrule = match request.recurrence.to_uppercase().as_str() {
|
||||||
|
"DAILY" => Some("FREQ=DAILY".to_string()),
|
||||||
|
"WEEKLY" => {
|
||||||
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
|
if request.recurrence_days.len() == 7 {
|
||||||
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, &selected)| {
|
||||||
|
if selected {
|
||||||
|
Some(match i {
|
||||||
|
0 => "SU", // Sunday
|
||||||
|
1 => "MO", // Monday
|
||||||
|
2 => "TU", // Tuesday
|
||||||
|
3 => "WE", // Wednesday
|
||||||
|
4 => "TH", // Thursday
|
||||||
|
5 => "FR", // Friday
|
||||||
|
6 => "SA", // Saturday
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !selected_days.is_empty() {
|
||||||
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule)
|
||||||
|
}
|
||||||
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
|
"NONE" | "" => None, // Clear any existing recurrence
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add INTERVAL, COUNT, and UNTIL parameters if specified
|
||||||
|
if let Some(mut rrule_string) = base_rrule {
|
||||||
|
// Add INTERVAL parameter (every N days/weeks/months/years)
|
||||||
|
if let Some(interval) = request.recurrence_interval {
|
||||||
|
if interval > 1 {
|
||||||
|
rrule_string = format!("{};INTERVAL={}", rrule_string, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add COUNT or UNTIL parameter (but not both - COUNT takes precedence)
|
||||||
|
if let Some(count) = request.recurrence_count {
|
||||||
|
rrule_string = format!("{};COUNT={}", rrule_string, count);
|
||||||
|
} else if let Some(end_date) = &request.recurrence_end_date {
|
||||||
|
// Convert YYYY-MM-DD to YYYYMMDD format for UNTIL
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||||
|
rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.rrule = rrule.clone();
|
||||||
|
println!("🔄 Set event RRULE to: {:?}", rrule);
|
||||||
|
|
||||||
|
if rrule.is_some() {
|
||||||
|
println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap());
|
||||||
|
} else {
|
||||||
|
println!("📝 Event remains non-recurring (no RRULE set)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the event on the CalDAV server
|
||||||
|
println!(
|
||||||
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
|
event.uid, calendar_path, event_href
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.update_event(&calendar_path, &event, &event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
|
println!("✅ Successfully updated event {}", event.uid);
|
||||||
|
|
||||||
|
Ok(Json(UpdateEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event updated successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_event_datetime_local(
|
||||||
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::NaiveDateTime, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
|
// Parse the date
|
||||||
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
|
if all_day {
|
||||||
|
// For all-day events, use start of day
|
||||||
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
|
||||||
|
Ok(datetime)
|
||||||
|
} else {
|
||||||
|
// Parse the time
|
||||||
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
|
// Combine date and time - now keeping as local time
|
||||||
|
Ok(NaiveDateTime::new(date, time))
|
||||||
|
}
|
||||||
|
}
|
||||||
142
backend/src/handlers/external_calendars.rs
Normal file
142
backend/src/handlers/external_calendars.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{ExternalCalendar, ExternalCalendarRepository},
|
||||||
|
models::ApiError,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateExternalCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateExternalCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExternalCalendarResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExternalCalendar> for ExternalCalendarResponse {
|
||||||
|
fn from(calendar: ExternalCalendar) -> Self {
|
||||||
|
Self {
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
url: calendar.url,
|
||||||
|
color: calendar.color,
|
||||||
|
is_visible: calendar.is_visible,
|
||||||
|
created_at: calendar.created_at,
|
||||||
|
updated_at: calendar.updated_at,
|
||||||
|
last_fetched: calendar.last_fetched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_external_calendars(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ExternalCalendarResponse>>, ApiError> {
|
||||||
|
// Extract and verify token, get user
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
let calendars = repo
|
||||||
|
.get_by_user(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
|
||||||
|
|
||||||
|
let response: Vec<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<CreateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<ExternalCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let calendar = ExternalCalendar::new(
|
||||||
|
user.id,
|
||||||
|
request.name,
|
||||||
|
request.url,
|
||||||
|
request.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
let id = repo
|
||||||
|
.create(&calendar)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
let mut created_calendar = calendar;
|
||||||
|
created_calendar.id = id;
|
||||||
|
|
||||||
|
Ok(Json(created_calendar.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(request): Json<UpdateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<()>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let mut calendar = ExternalCalendar::new(
|
||||||
|
user.id,
|
||||||
|
request.name,
|
||||||
|
request.url,
|
||||||
|
request.color,
|
||||||
|
);
|
||||||
|
calendar.is_visible = request.is_visible;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
repo.update(id, &calendar)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<()>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
repo.delete(id, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
873
backend/src/handlers/ics_fetcher.rs
Normal file
873
backend/src/handlers/ics_fetcher.rs
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc, Datelike};
|
||||||
|
use ical::parser::ical::component::IcalEvent;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::ExternalCalendarRepository,
|
||||||
|
models::ApiError,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import VEvent from calendar-models shared crate
|
||||||
|
use calendar_models::VEvent;
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExternalCalendarEventsResponse {
|
||||||
|
pub events: Vec<VEvent>,
|
||||||
|
pub last_fetched: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_external_calendar_events(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
|
||||||
|
// Get user's external calendars to verify ownership and get URL
|
||||||
|
let calendars = repo
|
||||||
|
.get_by_user(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
|
||||||
|
|
||||||
|
let calendar = calendars
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?;
|
||||||
|
|
||||||
|
if !calendar.is_visible {
|
||||||
|
return Ok(Json(ExternalCalendarEventsResponse {
|
||||||
|
events: vec![],
|
||||||
|
last_fetched: Utc::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let cache_max_age_minutes = 5;
|
||||||
|
let mut ics_content = String::new();
|
||||||
|
let mut last_fetched = Utc::now();
|
||||||
|
let mut fetched_from_cache = false;
|
||||||
|
|
||||||
|
// Try to get from cache if not stale
|
||||||
|
match repo.is_cache_stale(id, cache_max_age_minutes).await {
|
||||||
|
Ok(is_stale) => {
|
||||||
|
if !is_stale {
|
||||||
|
// Cache is fresh, use it
|
||||||
|
if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await {
|
||||||
|
ics_content = cached_data;
|
||||||
|
last_fetched = cached_at;
|
||||||
|
fetched_from_cache = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// If cache check fails, proceed to fetch from URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not fetched from cache, get from external URL
|
||||||
|
if !fetched_from_cache {
|
||||||
|
// Log the URL being fetched for debugging
|
||||||
|
println!("🌍 Fetching calendar URL: {}", calendar.url);
|
||||||
|
|
||||||
|
let user_agents = vec![
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
|
||||||
|
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut response = None;
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
// Try different user agents
|
||||||
|
for (i, ua) in user_agents.iter().enumerate() {
|
||||||
|
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(10))
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.user_agent(*ua)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.get(&calendar.url)
|
||||||
|
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
|
||||||
|
.header("Accept-Charset", "utf-8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
println!("📡 Response status: {}", status);
|
||||||
|
if status.is_success() {
|
||||||
|
response = Some(resp);
|
||||||
|
break;
|
||||||
|
} else if status == 400 {
|
||||||
|
// Check if this is an Outlook auth error
|
||||||
|
let error_body = resp.text().await.unwrap_or_default();
|
||||||
|
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
|
||||||
|
println!("🚫 Outlook authentication error detected, trying next approach...");
|
||||||
|
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
} else {
|
||||||
|
last_error = Some(format!("HTTP {}", status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Request failed: {}", e);
|
||||||
|
last_error = Some(format!("Request error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = response.ok_or_else(|| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
|
||||||
|
user_agents.len(),
|
||||||
|
last_error.unwrap_or("Unknown error".to_string())
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Response is guaranteed to be successful here since we checked in the loop
|
||||||
|
println!("✅ Successfully fetched calendar data");
|
||||||
|
|
||||||
|
ics_content = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?;
|
||||||
|
|
||||||
|
// Store in cache for future requests
|
||||||
|
let etag = None; // TODO: Extract ETag from response headers if available
|
||||||
|
if let Err(_) = repo.update_cache(id, &ics_content, etag).await {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_fetched timestamp
|
||||||
|
if let Err(_) = repo.update_last_fetched(id, &user.id).await {
|
||||||
|
}
|
||||||
|
|
||||||
|
last_fetched = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ICS content
|
||||||
|
let events = parse_ics_content(&ics_content)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(ExternalCalendarEventsResponse {
|
||||||
|
events,
|
||||||
|
last_fetched,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> {
|
||||||
|
let reader = ical::IcalParser::new(ics_content.as_bytes());
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut _total_components = 0;
|
||||||
|
let mut _failed_conversions = 0;
|
||||||
|
|
||||||
|
for calendar in reader {
|
||||||
|
let calendar = calendar?;
|
||||||
|
for component in calendar.events {
|
||||||
|
_total_components += 1;
|
||||||
|
match convert_ical_to_vevent(component) {
|
||||||
|
Ok(vevent) => {
|
||||||
|
events.push(vevent);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
_failed_conversions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate events based on UID, start time, and summary
|
||||||
|
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
|
||||||
|
events = deduplicate_events(events);
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> {
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
let mut summary = None;
|
||||||
|
let mut description = None;
|
||||||
|
let mut location = None;
|
||||||
|
let mut dtstart = None;
|
||||||
|
let mut dtend = None;
|
||||||
|
let mut uid = None;
|
||||||
|
let mut all_day = false;
|
||||||
|
let mut rrule = None;
|
||||||
|
|
||||||
|
|
||||||
|
// Extract properties
|
||||||
|
for property in ical_event.properties {
|
||||||
|
match property.name.as_str() {
|
||||||
|
"SUMMARY" => {
|
||||||
|
summary = property.value;
|
||||||
|
}
|
||||||
|
"DESCRIPTION" => {
|
||||||
|
description = property.value;
|
||||||
|
}
|
||||||
|
"LOCATION" => {
|
||||||
|
location = property.value;
|
||||||
|
}
|
||||||
|
"DTSTART" => {
|
||||||
|
if let Some(value) = property.value {
|
||||||
|
// Check if it's a date-only value (all-day event)
|
||||||
|
if value.len() == 8 && !value.contains('T') {
|
||||||
|
all_day = true;
|
||||||
|
// Parse YYYYMMDD format
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||||
|
dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Extract timezone info from parameters
|
||||||
|
let tzid = property.params.as_ref()
|
||||||
|
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||||
|
.and_then(|(_, v)| v.first().cloned());
|
||||||
|
|
||||||
|
// Parse datetime with timezone information
|
||||||
|
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||||
|
dtstart = Some(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"DTEND" => {
|
||||||
|
if let Some(value) = property.value {
|
||||||
|
if all_day && value.len() == 8 && !value.contains('T') {
|
||||||
|
// For all-day events, DTEND is exclusive so use the date as-is at noon
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||||
|
dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Extract timezone info from parameters
|
||||||
|
let tzid = property.params.as_ref()
|
||||||
|
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||||
|
.and_then(|(_, v)| v.first().cloned());
|
||||||
|
|
||||||
|
// Parse datetime with timezone information
|
||||||
|
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||||
|
dtend = Some(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"UID" => {
|
||||||
|
uid = property.value;
|
||||||
|
}
|
||||||
|
"RRULE" => {
|
||||||
|
rrule = property.value;
|
||||||
|
}
|
||||||
|
_ => {} // Ignore other properties for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let dtstart = dtstart.ok_or("Missing DTSTART")?;
|
||||||
|
|
||||||
|
let vevent = VEvent {
|
||||||
|
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||||
|
dtstart: dtstart.naive_utc(),
|
||||||
|
dtstart_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
dtend: dtend.map(|dt| dt.naive_utc()),
|
||||||
|
dtend_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
all_day,
|
||||||
|
rrule,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
|
exdate: Vec::new(), // External calendars don't need exception handling
|
||||||
|
exdate_tzid: None,
|
||||||
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
|
created: None,
|
||||||
|
created_tzid: None,
|
||||||
|
last_modified: None,
|
||||||
|
last_modified_tzid: None,
|
||||||
|
dtstamp: Utc::now(),
|
||||||
|
sequence: Some(0),
|
||||||
|
status: None,
|
||||||
|
transp: None,
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
url: None,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
categories: Vec::new(),
|
||||||
|
priority: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
related_to: None,
|
||||||
|
geo: None,
|
||||||
|
duration: None,
|
||||||
|
class: None,
|
||||||
|
contact: None,
|
||||||
|
comment: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
etag: None,
|
||||||
|
href: None,
|
||||||
|
calendar_path: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(vevent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<DateTime<Utc>> {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
|
||||||
|
// Try various datetime formats commonly found in ICS files
|
||||||
|
|
||||||
|
// Format: 20231201T103000Z (UTC) - handle as naive datetime first
|
||||||
|
if datetime_str.ends_with('Z') {
|
||||||
|
let datetime_without_z = &datetime_str[..datetime_str.len()-1];
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") {
|
||||||
|
return Some(naive_dt.and_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: 20231201T103000-0500 (with timezone offset)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") {
|
||||||
|
return Some(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: 2023-12-01T10:30:00Z (ISO format)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
return Some(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle naive datetime with timezone parameter
|
||||||
|
let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") {
|
||||||
|
Some(dt)
|
||||||
|
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
|
||||||
|
Some(dt)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(naive_dt) = naive_dt {
|
||||||
|
// If TZID is provided, try to parse it
|
||||||
|
if let Some(tzid_str) = tzid {
|
||||||
|
// Handle common timezone formats
|
||||||
|
let tz_result = if tzid_str.starts_with("/mozilla.org/") {
|
||||||
|
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||||
|
tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||||
|
} else if tzid_str.contains('/') {
|
||||||
|
// Standard timezone format: America/New_York, Europe/London
|
||||||
|
tzid_str.parse::<Tz>().ok()
|
||||||
|
} else {
|
||||||
|
// Try common abbreviations and Windows timezone names
|
||||||
|
match tzid_str {
|
||||||
|
// Standard abbreviations
|
||||||
|
"EST" => Some(Tz::America__New_York),
|
||||||
|
"PST" => Some(Tz::America__Los_Angeles),
|
||||||
|
"MST" => Some(Tz::America__Denver),
|
||||||
|
"CST" => Some(Tz::America__Chicago),
|
||||||
|
|
||||||
|
// North America - Windows timezone names to IANA mapping
|
||||||
|
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||||
|
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||||
|
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||||
|
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||||
|
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||||
|
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||||
|
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||||
|
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||||
|
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||||
|
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||||
|
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||||
|
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||||
|
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||||
|
|
||||||
|
// Asia
|
||||||
|
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||||
|
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||||
|
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||||
|
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||||
|
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||||
|
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||||
|
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||||
|
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||||
|
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||||
|
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||||
|
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||||
|
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||||
|
"Afghanistan Standard Time" => Some(Tz::Asia__Kabul),
|
||||||
|
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||||
|
"Arabian Standard Time" => Some(Tz::Asia__Dubai),
|
||||||
|
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||||
|
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||||
|
"Jordan Standard Time" => Some(Tz::Asia__Amman),
|
||||||
|
"Syria Standard Time" => Some(Tz::Asia__Damascus),
|
||||||
|
"Middle East Standard Time" => Some(Tz::Asia__Beirut),
|
||||||
|
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||||
|
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||||
|
"E. Africa Standard Time" => Some(Tz::Africa__Nairobi),
|
||||||
|
"W. Central Africa Standard Time" => Some(Tz::Africa__Lagos),
|
||||||
|
|
||||||
|
// Asia Pacific
|
||||||
|
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||||
|
"AUS Central Standard Time" => Some(Tz::Australia__Darwin),
|
||||||
|
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||||
|
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||||
|
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||||
|
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||||
|
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||||
|
|
||||||
|
// South America
|
||||||
|
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||||
|
"E. South America Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||||
|
"SA Eastern Standard Time" => Some(Tz::America__Cayenne),
|
||||||
|
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||||
|
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||||
|
"Pacific SA Standard Time" => Some(Tz::America__Santiago),
|
||||||
|
"Venezuela Standard Time" => Some(Tz::America__Caracas),
|
||||||
|
"Montevideo Standard Time" => Some(Tz::America__Montevideo),
|
||||||
|
|
||||||
|
// Try parsing as IANA name
|
||||||
|
_ => tzid_str.parse::<Tz>().ok()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tz) = tz_result {
|
||||||
|
if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() {
|
||||||
|
return Some(dt_with_tz.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no timezone info or parsing failed, treat as UTC (safer than local time assumptions)
|
||||||
|
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate events based on UID, start time, and summary
|
||||||
|
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
|
||||||
|
/// This includes both exact duplicates and recurring event instances that would be
|
||||||
|
/// generated by existing RRULE patterns, and events with same title but different
|
||||||
|
/// RRULE patterns that should be consolidated
|
||||||
|
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
||||||
|
// First pass: Group by UID and prefer recurring events over single events with same UID
|
||||||
|
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in events.drain(..) {
|
||||||
|
|
||||||
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
|
for (_uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
|
if events_with_uid.len() == 1 {
|
||||||
|
// Only one event with this UID, keep it
|
||||||
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same UID - prefer recurring over non-recurring
|
||||||
|
|
||||||
|
// Sort by preference: recurring events first, then by completeness
|
||||||
|
events_with_uid.sort_by(|a, b| {
|
||||||
|
let a_has_rrule = a.rrule.is_some();
|
||||||
|
let b_has_rrule = b.rrule.is_some();
|
||||||
|
|
||||||
|
match (a_has_rrule, b_has_rrule) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
|
||||||
|
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
|
||||||
|
_ => {
|
||||||
|
// Both same type (both recurring or both single) - compare by completeness
|
||||||
|
event_completeness_score(b).cmp(&event_completeness_score(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the first (preferred) event
|
||||||
|
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||||
|
uid_deduplicated_events.push(preferred_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: separate recurring and single events from UID-deduplicated set
|
||||||
|
let mut recurring_events = Vec::new();
|
||||||
|
let mut single_events = Vec::new();
|
||||||
|
|
||||||
|
for event in uid_deduplicated_events.drain(..) {
|
||||||
|
if event.rrule.is_some() {
|
||||||
|
recurring_events.push(event);
|
||||||
|
} else {
|
||||||
|
single_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
|
||||||
|
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in recurring_events.drain(..) {
|
||||||
|
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
title_groups.entry(title).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduplicated_recurring = Vec::new();
|
||||||
|
|
||||||
|
for (_title, events_with_title) in title_groups.drain() {
|
||||||
|
if events_with_title.len() == 1 {
|
||||||
|
// Single event with this title, keep as-is
|
||||||
|
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same title - consolidate or deduplicate
|
||||||
|
|
||||||
|
// Check if these are actually different recurring patterns for the same logical event
|
||||||
|
let consolidated = consolidate_same_title_events(events_with_title);
|
||||||
|
deduplicated_recurring.extend(consolidated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth pass: filter single events, removing those that would be generated by recurring events
|
||||||
|
let mut deduplicated_single = Vec::new();
|
||||||
|
let mut seen_single: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
for event in single_events.drain(..) {
|
||||||
|
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
normalized_title
|
||||||
|
);
|
||||||
|
|
||||||
|
// First check for exact duplicates among single events
|
||||||
|
if let Some(&existing_index) = seen_single.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated_single[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
deduplicated_single[existing_index] = event;
|
||||||
|
} else {
|
||||||
|
// Discarding duplicate single event - keeping existing
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by any recurring event
|
||||||
|
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
|
||||||
|
// Check if this single event matches the recurring event's pattern (use normalized titles)
|
||||||
|
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
|
||||||
|
if single_title != recurring_title {
|
||||||
|
return false; // Different events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by the recurring event
|
||||||
|
would_event_be_generated_by_rrule(recurring_event, &event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_rrule_generated {
|
||||||
|
} else {
|
||||||
|
// This is a unique single event
|
||||||
|
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||||
|
deduplicated_single.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine recurring and single events
|
||||||
|
let mut result = deduplicated_recurring;
|
||||||
|
result.extend(deduplicated_single);
|
||||||
|
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize title for grouping similar events
|
||||||
|
fn normalize_title(title: &str) -> String {
|
||||||
|
title.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolidate events with the same title but potentially different RRULE patterns
|
||||||
|
/// This handles cases where calendar systems provide multiple recurring definitions
|
||||||
|
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
|
||||||
|
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the RRULEs we're working with
|
||||||
|
|
||||||
|
// Check if all events have similar time patterns and could be consolidated
|
||||||
|
let first_event = &events[0];
|
||||||
|
let base_time = first_event.dtstart.time();
|
||||||
|
let base_duration = if let Some(end) = first_event.dtend {
|
||||||
|
Some(end.signed_duration_since(first_event.dtstart))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all events have the same time and duration
|
||||||
|
let can_consolidate = events.iter().all(|event| {
|
||||||
|
let same_time = event.dtstart.time() == base_time;
|
||||||
|
let same_duration = match (event.dtend, base_duration) {
|
||||||
|
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
same_time && same_duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if !can_consolidate {
|
||||||
|
// Just deduplicate exact duplicates
|
||||||
|
return deduplicate_exact_recurring_events(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect if these are complementary weekly patterns
|
||||||
|
let weekly_events: Vec<_> = events.iter()
|
||||||
|
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||||
|
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||||
|
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||||
|
return vec![consolidated];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||||
|
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||||
|
|
||||||
|
// If we still have multiple events, keep only the most complete one
|
||||||
|
if deduplicated.len() > 1 {
|
||||||
|
let best_event = deduplicated.into_iter()
|
||||||
|
.max_by_key(|e| event_completeness_score(e))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("🎯 Kept most complete event: '{}'",
|
||||||
|
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
vec![best_event]
|
||||||
|
} else {
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate exact recurring event matches
|
||||||
|
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut deduplicated = Vec::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
event.summary.as_ref().unwrap_or(&String::new()),
|
||||||
|
event.rrule.as_ref().unwrap_or(&String::new())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(&existing_index) = seen.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing exact duplicate: Keeping more complete event");
|
||||||
|
deduplicated[existing_index] = event;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen.insert(dedup_key, deduplicated.len());
|
||||||
|
deduplicated.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
|
||||||
|
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut all_days = HashSet::new();
|
||||||
|
let mut base_event = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let Some(rrule) = &event.rrule else { continue; };
|
||||||
|
|
||||||
|
if !rrule.contains("FREQ=WEEKLY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract BYDAY if present
|
||||||
|
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
|
||||||
|
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
|
||||||
|
for day in days_str.split(',') {
|
||||||
|
all_days.insert(day.trim().to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no BYDAY specified, use the weekday from the start date
|
||||||
|
let weekday = match event.dtstart.weekday() {
|
||||||
|
chrono::Weekday::Mon => "MO",
|
||||||
|
chrono::Weekday::Tue => "TU",
|
||||||
|
chrono::Weekday::Wed => "WE",
|
||||||
|
chrono::Weekday::Thu => "TH",
|
||||||
|
chrono::Weekday::Fri => "FR",
|
||||||
|
chrono::Weekday::Sat => "SA",
|
||||||
|
chrono::Weekday::Sun => "SU",
|
||||||
|
};
|
||||||
|
all_days.insert(weekday.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first event as the base (we already know they have same time/duration)
|
||||||
|
if base_event.is_none() {
|
||||||
|
base_event = Some(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_days.is_empty() || base_event.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create consolidated RRULE
|
||||||
|
let mut base = base_event.unwrap();
|
||||||
|
let days_list: Vec<_> = all_days.into_iter().collect();
|
||||||
|
let byday_str = days_list.join(",");
|
||||||
|
|
||||||
|
// Build new RRULE with consolidated BYDAY
|
||||||
|
let new_rrule = if let Some(existing_rrule) = &base.rrule {
|
||||||
|
// Remove existing BYDAY and add our consolidated one
|
||||||
|
let parts: Vec<_> = existing_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("BYDAY="))
|
||||||
|
.collect();
|
||||||
|
format!("{};BYDAY={}", parts.join(";"), byday_str)
|
||||||
|
} else {
|
||||||
|
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
base.rrule = Some(new_rrule);
|
||||||
|
|
||||||
|
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single event would be generated by a recurring event's RRULE
|
||||||
|
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
|
||||||
|
let Some(rrule) = &recurring_event.rrule else {
|
||||||
|
return false; // No RRULE to check against
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse basic RRULE patterns
|
||||||
|
if rrule.contains("FREQ=DAILY") {
|
||||||
|
// Daily recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
return recurring_time == single_time;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
|
// Weekly recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
|
// First check if it's the same day of week and time
|
||||||
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
let single_weekday = single_event.dtstart.weekday();
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
|
||||||
|
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
|
||||||
|
// Calculate how many weeks apart they are
|
||||||
|
let weeks_diff = days_diff / 7;
|
||||||
|
// Check if this falls on an interval boundary
|
||||||
|
return weeks_diff % interval as i64 == 0;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=MONTHLY") {
|
||||||
|
// Monthly recurrence - simplified check
|
||||||
|
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
|
||||||
|
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
|
||||||
|
|
||||||
|
if months_diff >= 0 {
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
|
||||||
|
if months_diff % interval == 0 {
|
||||||
|
// Same day of month and time
|
||||||
|
return recurring_event.dtstart.day() == single_event.dtstart.day()
|
||||||
|
&& recurring_event.dtstart.time() == single_event.dtstart.time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
|
||||||
|
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
|
||||||
|
for part in rrule.split(';') {
|
||||||
|
if part.starts_with("INTERVAL=") {
|
||||||
|
return part.strip_prefix("INTERVAL=")
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(1) // Default interval is 1 if not specified
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate a completeness score for an event based on how many optional fields are filled
|
||||||
|
fn event_completeness_score(event: &VEvent) -> u32 {
|
||||||
|
let mut score = 0;
|
||||||
|
|
||||||
|
if event.summary.is_some() { score += 1; }
|
||||||
|
if event.description.is_some() { score += 1; }
|
||||||
|
if event.location.is_some() { score += 1; }
|
||||||
|
if event.dtend.is_some() { score += 1; }
|
||||||
|
if event.rrule.is_some() { score += 1; }
|
||||||
|
if !event.categories.is_empty() { score += 1; }
|
||||||
|
if !event.alarms.is_empty() { score += 1; }
|
||||||
|
if event.organizer.is_some() { score += 1; }
|
||||||
|
if !event.attendees.is_empty() { score += 1; }
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
15
backend/src/handlers/mod.rs
Normal file
15
backend/src/handlers/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod events;
|
||||||
|
pub mod external_calendars;
|
||||||
|
pub mod ics_fetcher;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod series;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use calendar::*;
|
||||||
|
pub use events::*;
|
||||||
|
pub use external_calendars::*;
|
||||||
|
pub use ics_fetcher::*;
|
||||||
|
pub use preferences::*;
|
||||||
|
pub use series::*;
|
||||||
133
backend/src/handlers/preferences.rs
Normal file
133
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::PreferencesRepository,
|
||||||
|
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get user preferences
|
||||||
|
pub async fn get_preferences(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Validate session and get user ID
|
||||||
|
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||||
|
|
||||||
|
// Get preferences from database
|
||||||
|
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||||
|
let preferences = prefs_repo
|
||||||
|
.get_or_create(&user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user preferences
|
||||||
|
pub async fn update_preferences(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<UpdatePreferencesRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Validate session and get user ID
|
||||||
|
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||||
|
|
||||||
|
// Update preferences in database
|
||||||
|
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||||
|
|
||||||
|
let mut preferences = prefs_repo
|
||||||
|
.get_or_create(&user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
|
// Update only provided fields
|
||||||
|
if request.calendar_selected_date.is_some() {
|
||||||
|
preferences.calendar_selected_date = request.calendar_selected_date;
|
||||||
|
}
|
||||||
|
if request.calendar_time_increment.is_some() {
|
||||||
|
preferences.calendar_time_increment = request.calendar_time_increment;
|
||||||
|
}
|
||||||
|
if request.calendar_view_mode.is_some() {
|
||||||
|
preferences.calendar_view_mode = request.calendar_view_mode;
|
||||||
|
}
|
||||||
|
if request.calendar_theme.is_some() {
|
||||||
|
preferences.calendar_theme = request.calendar_theme;
|
||||||
|
}
|
||||||
|
if request.calendar_style.is_some() {
|
||||||
|
preferences.calendar_style = request.calendar_style;
|
||||||
|
}
|
||||||
|
if request.calendar_colors.is_some() {
|
||||||
|
preferences.calendar_colors = request.calendar_colors;
|
||||||
|
}
|
||||||
|
if request.last_used_calendar.is_some() {
|
||||||
|
preferences.last_used_calendar = request.last_used_calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs_repo
|
||||||
|
.update(&preferences)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
// Extract session token from headers
|
||||||
|
let session_token = headers
|
||||||
|
.get("X-Session-Token")
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
state.auth_service.logout(session_token).await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"message": "Logged out successfully"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
1165
backend/src/handlers/series.rs
Normal file
1165
backend/src/handlers/series.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,45 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
mod auth;
|
pub mod auth;
|
||||||
mod models;
|
pub mod calendar;
|
||||||
mod handlers;
|
pub mod config;
|
||||||
mod calendar;
|
pub mod db;
|
||||||
mod config;
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth_service: AuthService,
|
pub auth_service: AuthService,
|
||||||
|
pub db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
println!("🚀 Starting Calendar Backend Server");
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||||
|
|
||||||
|
let db = Database::new(&database_url).await?;
|
||||||
|
println!("✅ Database initialized");
|
||||||
|
|
||||||
// Create auth service
|
// Create auth service
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
let auth_service = AuthService::new(jwt_secret);
|
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||||
|
|
||||||
let app_state = AppState { auth_service };
|
let app_state = AppState { auth_service, db };
|
||||||
|
|
||||||
// Build our application with routes
|
// Build our application with routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -38,8 +48,36 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/auth/login", post(handlers::login))
|
.route("/api/auth/login", post(handlers::login))
|
||||||
.route("/api/auth/verify", get(handlers::verify_token))
|
.route("/api/auth/verify", get(handlers::verify_token))
|
||||||
.route("/api/user/info", get(handlers::get_user_info))
|
.route("/api/user/info", get(handlers::get_user_info))
|
||||||
|
.route("/api/calendar/create", post(handlers::create_calendar))
|
||||||
|
.route("/api/calendar/delete", post(handlers::delete_calendar))
|
||||||
.route("/api/calendar/events", get(handlers::get_calendar_events))
|
.route("/api/calendar/events", get(handlers::get_calendar_events))
|
||||||
|
.route("/api/calendar/events/create", post(handlers::create_event))
|
||||||
|
.route("/api/calendar/events/update", post(handlers::update_event))
|
||||||
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
|
// Event series-specific endpoints
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/create",
|
||||||
|
post(handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(handlers::delete_event_series),
|
||||||
|
)
|
||||||
|
// User preferences endpoints
|
||||||
|
.route("/api/preferences", get(handlers::get_preferences))
|
||||||
|
.route("/api/preferences", post(handlers::update_preferences))
|
||||||
|
.route("/api/auth/logout", post(handlers::logout))
|
||||||
|
// External calendars endpoints
|
||||||
|
.route("/api/external-calendars", get(handlers::get_external_calendars))
|
||||||
|
.route("/api/external-calendars", post(handlers::create_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id", post(handlers::update_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id", delete(handlers::delete_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events))
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -51,7 +89,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Start server
|
// Start server
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("📡 Server listening on http://0.0.0.0:3000");
|
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -67,4 +105,4 @@ async fn health_check() -> Json<serde_json::Value> {
|
|||||||
"service": "calendar-backend",
|
"service": "calendar-backend",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ use calendar_backend::*;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
run_server().await
|
run_server().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use calendar_models::VAlarm;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// API request/response types
|
// API request/response types
|
||||||
@@ -16,8 +17,32 @@ pub struct CalDAVLoginRequest {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdatePreferencesRequest {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -31,6 +56,211 @@ pub struct UserInfo {
|
|||||||
pub struct CalendarInfo {
|
pub struct CalendarInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateCalendarResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeleteCalendarRequest {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DeleteCalendarResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeleteEventRequest {
|
||||||
|
pub calendar_path: String,
|
||||||
|
pub event_href: String,
|
||||||
|
pub delete_action: String, // "delete_this", "delete_following", or "delete_series"
|
||||||
|
pub occurrence_date: Option<String>, // ISO date string for the specific occurrence
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DeleteEventResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateEventRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
|
pub start_time: String, // HH:MM format
|
||||||
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
|
pub end_time: String, // HH:MM format
|
||||||
|
pub location: String,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
|
pub class: String, // public, private, confidential
|
||||||
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
|
pub organizer: String, // organizer email
|
||||||
|
pub attendees: String, // comma-separated attendee emails
|
||||||
|
pub categories: String, // comma-separated categories
|
||||||
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
pub recurrence: String, // recurrence type
|
||||||
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateEventResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub event_href: Option<String>, // The created event's href/filename
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateEventRequest {
|
||||||
|
pub uid: String, // Event UID to identify which event to update
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
|
pub start_time: String, // HH:MM format
|
||||||
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
|
pub end_time: String, // HH:MM format
|
||||||
|
pub location: String,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
|
pub class: String, // public, private, confidential
|
||||||
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
|
pub organizer: String, // organizer email
|
||||||
|
pub attendees: String, // comma-separated attendee emails
|
||||||
|
pub categories: String, // comma-separated categories
|
||||||
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
pub recurrence: String, // recurrence type
|
||||||
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdateEventResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EVENT SERIES MODELS ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateEventSeriesRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
|
pub start_time: String, // HH:MM format
|
||||||
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
|
pub end_time: String, // HH:MM format
|
||||||
|
pub location: String,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
|
pub class: String, // public, private, confidential
|
||||||
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
|
pub organizer: String, // organizer email
|
||||||
|
pub attendees: String, // comma-separated attendee emails
|
||||||
|
pub categories: String, // comma-separated categories
|
||||||
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
|
||||||
|
// Series-specific fields
|
||||||
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub series_uid: Option<String>, // The base UID for the series
|
||||||
|
pub occurrences_created: Option<u32>, // Number of individual events created
|
||||||
|
pub event_href: Option<String>, // The created series' href/filename
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateEventSeriesRequest {
|
||||||
|
pub series_uid: String, // Series UID to identify which series to update
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
|
pub start_time: String, // HH:MM format
|
||||||
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
|
pub end_time: String, // HH:MM format
|
||||||
|
pub location: String,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
|
pub class: String, // public, private, confidential
|
||||||
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
|
pub organizer: String, // organizer email
|
||||||
|
pub attendees: String, // comma-separated attendee emails
|
||||||
|
pub categories: String, // comma-separated categories
|
||||||
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
|
||||||
|
// Series-specific fields
|
||||||
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
|
||||||
|
// Update scope control
|
||||||
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdateEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub series_uid: Option<String>,
|
||||||
|
pub occurrences_affected: Option<u32>, // Number of events updated
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeleteEventSeriesRequest {
|
||||||
|
pub series_uid: String, // Series UID to identify which series to delete
|
||||||
|
pub calendar_path: String,
|
||||||
|
pub event_href: String,
|
||||||
|
|
||||||
|
// Delete scope control
|
||||||
|
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DeleteEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub occurrences_affected: Option<u32>, // Number of events deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
@@ -77,4 +307,4 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|||||||
726
backend/tests/integration_tests.rs
Normal file
726
backend/tests/integration_tests.rs
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
use axum::{
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use calendar_backend::auth::AuthService;
|
||||||
|
use calendar_backend::AppState;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
/// Test utilities for integration testing
|
||||||
|
mod test_utils {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub struct TestServer {
|
||||||
|
pub base_url: String,
|
||||||
|
pub client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestServer {
|
||||||
|
pub async fn start() -> Self {
|
||||||
|
// Create auth service
|
||||||
|
let jwt_secret = "test-secret-key-for-integration-tests".to_string();
|
||||||
|
let auth_service = AuthService::new(jwt_secret);
|
||||||
|
let app_state = AppState { auth_service };
|
||||||
|
|
||||||
|
// Build application with routes
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/api/health", get(health_check))
|
||||||
|
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||||
|
.route(
|
||||||
|
"/api/auth/verify",
|
||||||
|
get(calendar_backend::handlers::verify_token),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/user/info",
|
||||||
|
get(calendar_backend::handlers::get_user_info),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/create",
|
||||||
|
post(calendar_backend::handlers::create_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/delete",
|
||||||
|
post(calendar_backend::handlers::delete_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events",
|
||||||
|
get(calendar_backend::handlers::get_calendar_events),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/create",
|
||||||
|
post(calendar_backend::handlers::create_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/update",
|
||||||
|
post(calendar_backend::handlers::update_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/:uid",
|
||||||
|
get(calendar_backend::handlers::refresh_event),
|
||||||
|
)
|
||||||
|
// Event series-specific endpoints
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/create",
|
||||||
|
post(calendar_backend::handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(calendar_backend::handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event_series),
|
||||||
|
)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.with_state(Arc::new(app_state));
|
||||||
|
|
||||||
|
// Start server on a random port
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
TestServer { base_url, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self) -> String {
|
||||||
|
let login_payload = json!({
|
||||||
|
"username": "test".to_string(),
|
||||||
|
"password": "test".to_string(),
|
||||||
|
"server_url": "https://example.com".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&format!("{}/api/auth/login", self.base_url))
|
||||||
|
.json(&login_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send login request");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"Calendar Backend API v0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "calendar-backend",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::test_utils::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test the health endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_health_endpoint() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let health_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert_eq!(health_response["status"], "healthy");
|
||||||
|
assert_eq!(health_response["service"], "calendar-backend");
|
||||||
|
|
||||||
|
println!("✓ Health endpoint test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test authentication login endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_login() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// Use test credentials
|
||||||
|
let username = "test".to_string();
|
||||||
|
let password = "test".to_string();
|
||||||
|
let server_url = "https://example.com".to_string();
|
||||||
|
|
||||||
|
let login_payload = json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"server_url": server_url
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!("{}/api/auth/login", server.base_url))
|
||||||
|
.json(&login_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
login_response["token"].is_string(),
|
||||||
|
"Login response should contain a token"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
login_response["username"].is_string(),
|
||||||
|
"Login response should contain username"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("✓ Authentication login test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test authentication verify endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_verify() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let verify_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
||||||
|
|
||||||
|
println!("✓ Authentication verify test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test user info endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_info() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server discovery fails, which can happen
|
||||||
|
if response.status().is_success() {
|
||||||
|
let user_info: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(user_info["username"].is_string());
|
||||||
|
println!("✓ User info test passed");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test calendar events listing endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_calendar_events() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events?year=2024&month=12",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Get events failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let events: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(events.is_array());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"✓ Get calendar events test passed (found {} events)",
|
||||||
|
events.as_array().unwrap().len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event creation endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_event() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let create_payload = json!({
|
||||||
|
"title": "Integration Test Event",
|
||||||
|
"description": "Created by integration test",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 5,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "test",
|
||||||
|
"reminder": "15min",
|
||||||
|
"recurrence": "none",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&create_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Create event response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(create_response["success"].as_bool().unwrap_or(false));
|
||||||
|
println!("✓ Create event test passed");
|
||||||
|
} else {
|
||||||
|
println!("⚠ Create event test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event refresh endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_refresh_event() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
||||||
|
let test_uid = "test-event-uid";
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events/{}",
|
||||||
|
server.base_url, test_uid
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||||
|
assert!(
|
||||||
|
response.status() == 200 || response.status() == 404,
|
||||||
|
"Refresh event failed with unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("✓ Refresh event endpoint test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test invalid authentication
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalid_auth() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.header("Authorization", "Bearer invalid-token")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||||
|
assert!(
|
||||||
|
response.status() == 401 || response.status() == 400,
|
||||||
|
"Expected 401 or 400 for invalid token, got {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
println!("✓ Invalid authentication test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test missing authentication
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_missing_auth() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 401);
|
||||||
|
println!("✓ Missing authentication test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EVENT SERIES TESTS ====================
|
||||||
|
|
||||||
|
/// Test event series creation endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let create_payload = json!({
|
||||||
|
"title": "Integration Test Series",
|
||||||
|
"description": "Created by integration test for series",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test Series Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 5,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "test-series",
|
||||||
|
"reminder": "15min",
|
||||||
|
"recurrence": "weekly",
|
||||||
|
"recurrence_days": [false, true, false, false, false, false, false], // Monday only
|
||||||
|
"recurrence_interval": 1,
|
||||||
|
"recurrence_count": 4,
|
||||||
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&create_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Create series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(create_response["success"].as_bool().unwrap_or(false));
|
||||||
|
assert!(create_response["series_uid"].is_string());
|
||||||
|
println!("✓ Create event series test passed");
|
||||||
|
} else {
|
||||||
|
println!("⚠ Create event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event series update endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let update_payload = json!({
|
||||||
|
"series_uid": "test-series-uid",
|
||||||
|
"title": "Updated Series Title",
|
||||||
|
"description": "Updated by integration test",
|
||||||
|
"start_date": "2024-12-26",
|
||||||
|
"start_time": "14:00",
|
||||||
|
"end_date": "2024-12-26",
|
||||||
|
"end_time": "15:00",
|
||||||
|
"location": "Updated Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 3,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "attendee@example.com",
|
||||||
|
"categories": "updated-series",
|
||||||
|
"reminder": "30min",
|
||||||
|
"recurrence": "daily",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
|
"recurrence_interval": 2,
|
||||||
|
"recurrence_count": 10,
|
||||||
|
"update_scope": "all_in_series",
|
||||||
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&update_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Update series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(update_response["success"].as_bool().unwrap_or(false));
|
||||||
|
assert_eq!(
|
||||||
|
update_response["series_uid"].as_str().unwrap(),
|
||||||
|
"test-series-uid"
|
||||||
|
);
|
||||||
|
println!("✓ Update event series test passed");
|
||||||
|
} else if status == 404 {
|
||||||
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event series deletion endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let delete_payload = json!({
|
||||||
|
"series_uid": "test-series-to-delete",
|
||||||
|
"calendar_path": "/calendars/test/default/",
|
||||||
|
"event_href": "test-series.ics",
|
||||||
|
"delete_scope": "all_in_series"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/delete",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&delete_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Delete series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let delete_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||||
|
println!("✓ Delete event series test passed");
|
||||||
|
} else if status == 404 {
|
||||||
|
println!(
|
||||||
|
"⚠ Delete event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test invalid update scope
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalid_update_scope() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let invalid_payload = json!({
|
||||||
|
"series_uid": "test-series-uid",
|
||||||
|
"title": "Test Title",
|
||||||
|
"description": "Test",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "",
|
||||||
|
"reminder": "none",
|
||||||
|
"recurrence": "none",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&invalid_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for invalid update scope"
|
||||||
|
);
|
||||||
|
println!("✓ Invalid update scope test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test non-recurring event rejection in series endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_non_recurring_series_rejection() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let non_recurring_payload = json!({
|
||||||
|
"title": "Non-recurring Event",
|
||||||
|
"description": "This should be rejected",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "",
|
||||||
|
"reminder": "none",
|
||||||
|
"recurrence": "none", // This should cause rejection
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&non_recurring_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for non-recurring event in series endpoint"
|
||||||
|
);
|
||||||
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
calendar-models/Cargo.toml
Normal file
13
calendar-models/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "calendar-models"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
wasm = ["chrono/wasm-bindgen", "uuid/wasm-bindgen"]
|
||||||
220
calendar-models/src/common.rs
Normal file
220
calendar-models/src/common.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum EventStatus {
|
||||||
|
Tentative,
|
||||||
|
Confirmed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum EventClass {
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum TimeTransparency {
|
||||||
|
Opaque, // OPAQUE - time is not available
|
||||||
|
Transparent, // TRANSPARENT - time is available
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum TodoStatus {
|
||||||
|
NeedsAction,
|
||||||
|
Completed,
|
||||||
|
InProcess,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AttendeeRole {
|
||||||
|
Chair,
|
||||||
|
ReqParticipant,
|
||||||
|
OptParticipant,
|
||||||
|
NonParticipant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum ParticipationStatus {
|
||||||
|
NeedsAction,
|
||||||
|
Accepted,
|
||||||
|
Declined,
|
||||||
|
Tentative,
|
||||||
|
Delegated,
|
||||||
|
Completed,
|
||||||
|
InProcess,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AlarmAction {
|
||||||
|
Audio,
|
||||||
|
Display,
|
||||||
|
Email,
|
||||||
|
Procedure,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STRUCTURED TYPES ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CalendarUser {
|
||||||
|
pub cal_address: String, // Calendar user address (usually email)
|
||||||
|
pub common_name: Option<String>, // CN parameter - display name
|
||||||
|
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||||
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Attendee {
|
||||||
|
pub cal_address: String, // Calendar user address
|
||||||
|
pub common_name: Option<String>, // CN parameter
|
||||||
|
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||||
|
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||||
|
pub rsvp: Option<bool>, // RSVP parameter
|
||||||
|
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||||
|
pub member: Vec<String>, // MEMBER parameter
|
||||||
|
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||||
|
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||||
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
|
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||||
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VAlarm {
|
||||||
|
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||||
|
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||||
|
pub duration: Option<Duration>, // Duration (DURATION)
|
||||||
|
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||||
|
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||||
|
pub summary: Option<String>, // Summary for EMAIL
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||||
|
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlarmTrigger {
|
||||||
|
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||||
|
Duration(Duration), // Duration relative to start/end
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||||
|
pub encoding: Option<String>, // ENCODING parameter
|
||||||
|
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||||
|
pub uri: Option<String>, // URI reference
|
||||||
|
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct GeographicPosition {
|
||||||
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VTimeZone {
|
||||||
|
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||||
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||||
|
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||||
|
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TimeZoneComponent {
|
||||||
|
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||||
|
pub tzoffset_to: String, // UTC offset for this component
|
||||||
|
pub tzoffset_from: String, // UTC offset before this component
|
||||||
|
pub rrule: Option<String>, // Recurrence rule
|
||||||
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||||
|
pub tzname: Vec<String>, // Time zone names
|
||||||
|
pub comment: Vec<String>, // Comments
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VJournal {
|
||||||
|
// Required properties
|
||||||
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
|
// Optional properties
|
||||||
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
|
// Classification and status
|
||||||
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
|
// People and organization
|
||||||
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
|
// Categorization
|
||||||
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
|
||||||
|
// Versioning and modification
|
||||||
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
|
// Recurrence
|
||||||
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VFreeBusy {
|
||||||
|
// Required properties
|
||||||
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
|
// Optional date-time properties
|
||||||
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
|
||||||
|
// People
|
||||||
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
|
// Free/busy time
|
||||||
|
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||||
|
pub url: Option<String>, // URL (URL)
|
||||||
|
pub comment: Vec<String>, // Comments (COMMENT)
|
||||||
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct FreeBusyTime {
|
||||||
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
|
pub periods: Vec<Period>, // Time periods
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum FreeBusyType {
|
||||||
|
Free,
|
||||||
|
Busy,
|
||||||
|
BusyUnavailable,
|
||||||
|
BusyTentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Period {
|
||||||
|
pub start: DateTime<Utc>, // Period start
|
||||||
|
pub end: Option<DateTime<Utc>>, // Period end
|
||||||
|
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||||
|
}
|
||||||
10
calendar-models/src/lib.rs
Normal file
10
calendar-models/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! RFC 5545 Compliant Calendar Models
|
||||||
|
//!
|
||||||
|
//! This crate provides shared data structures for calendar applications
|
||||||
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
|
pub use vevent::*;
|
||||||
199
calendar-models/src/vevent.rs
Normal file
199
calendar-models/src/vevent.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
|
use crate::common::*;
|
||||||
|
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ==================== VEVENT COMPONENT ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VEvent {
|
||||||
|
// Required properties
|
||||||
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED (always UTC)
|
||||||
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
pub dtstart: NaiveDateTime, // Start date-time (DTSTART) - REQUIRED (local time)
|
||||||
|
pub dtstart_tzid: Option<String>, // Timezone ID for DTSTART (TZID parameter)
|
||||||
|
|
||||||
|
// Optional properties (commonly used)
|
||||||
|
pub dtend: Option<NaiveDateTime>, // End date-time (DTEND) (local time)
|
||||||
|
pub dtend_tzid: Option<String>, // Timezone ID for DTEND (TZID parameter)
|
||||||
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
|
// Classification and status
|
||||||
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
|
// People and organization
|
||||||
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
|
// Categorization and relationships
|
||||||
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
|
// Geographical
|
||||||
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
|
// Versioning and modification
|
||||||
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
|
pub created: Option<NaiveDateTime>, // Creation time (CREATED) (local time)
|
||||||
|
pub created_tzid: Option<String>, // Timezone ID for CREATED
|
||||||
|
pub last_modified: Option<NaiveDateTime>, // Last modified (LAST-MODIFIED) (local time)
|
||||||
|
pub last_modified_tzid: Option<String>, // Timezone ID for LAST-MODIFIED
|
||||||
|
|
||||||
|
// Recurrence
|
||||||
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
|
pub rdate: Vec<NaiveDateTime>, // Recurrence dates (RDATE) (local time)
|
||||||
|
pub rdate_tzid: Option<String>, // Timezone ID for RDATE
|
||||||
|
pub exdate: Vec<NaiveDateTime>, // Exception dates (EXDATE) (local time)
|
||||||
|
pub exdate_tzid: Option<String>, // Timezone ID for EXDATE
|
||||||
|
pub recurrence_id: Option<NaiveDateTime>, // Recurrence ID (RECURRENCE-ID) (local time)
|
||||||
|
pub recurrence_id_tzid: Option<String>, // Timezone ID for RECURRENCE-ID
|
||||||
|
|
||||||
|
// Alarms and attachments
|
||||||
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
|
// CalDAV specific (for implementation)
|
||||||
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
|
pub href: Option<String>, // Href for CalDAV
|
||||||
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
|
pub all_day: bool, // All-day event flag
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VEvent {
|
||||||
|
/// Create a new VEvent with required fields (local time)
|
||||||
|
pub fn new(uid: String, dtstart: NaiveDateTime) -> Self {
|
||||||
|
Self {
|
||||||
|
dtstamp: Utc::now(),
|
||||||
|
uid,
|
||||||
|
dtstart,
|
||||||
|
dtstart_tzid: None,
|
||||||
|
dtend: None,
|
||||||
|
dtend_tzid: None,
|
||||||
|
duration: None,
|
||||||
|
summary: None,
|
||||||
|
description: None,
|
||||||
|
location: None,
|
||||||
|
class: None,
|
||||||
|
status: None,
|
||||||
|
transp: None,
|
||||||
|
priority: None,
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
contact: None,
|
||||||
|
categories: Vec::new(),
|
||||||
|
comment: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
related_to: None,
|
||||||
|
url: None,
|
||||||
|
geo: None,
|
||||||
|
sequence: None,
|
||||||
|
created: Some(chrono::Local::now().naive_local()),
|
||||||
|
created_tzid: None,
|
||||||
|
last_modified: Some(chrono::Local::now().naive_local()),
|
||||||
|
last_modified_tzid: None,
|
||||||
|
rrule: None,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
|
exdate: Vec::new(),
|
||||||
|
exdate_tzid: None,
|
||||||
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
etag: None,
|
||||||
|
href: None,
|
||||||
|
calendar_path: None,
|
||||||
|
all_day: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get effective end time (dtend or dtstart + duration)
|
||||||
|
pub fn get_end_time(&self) -> NaiveDateTime {
|
||||||
|
if let Some(dtend) = self.dtend {
|
||||||
|
dtend
|
||||||
|
} else if let Some(duration) = self.duration {
|
||||||
|
self.dtstart + duration
|
||||||
|
} else {
|
||||||
|
// Default to 1 hour if no end or duration specified
|
||||||
|
self.dtstart + Duration::hours(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get event duration
|
||||||
|
pub fn get_duration(&self) -> Duration {
|
||||||
|
if let Some(duration) = self.duration {
|
||||||
|
duration
|
||||||
|
} else if let Some(dtend) = self.dtend {
|
||||||
|
dtend - self.dtstart
|
||||||
|
} else {
|
||||||
|
Duration::hours(1) // Default duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get display title (summary or "Untitled Event")
|
||||||
|
pub fn get_title(&self) -> String {
|
||||||
|
self.summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled Event".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get start date for UI compatibility
|
||||||
|
pub fn get_date(&self) -> chrono::NaiveDate {
|
||||||
|
self.dtstart.date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if event is recurring
|
||||||
|
pub fn is_recurring(&self) -> bool {
|
||||||
|
self.rrule.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is an exception to a recurring series
|
||||||
|
pub fn is_exception(&self) -> bool {
|
||||||
|
self.recurrence_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for status
|
||||||
|
pub fn get_status_display(&self) -> &'static str {
|
||||||
|
match &self.status {
|
||||||
|
Some(EventStatus::Tentative) => "Tentative",
|
||||||
|
Some(EventStatus::Confirmed) => "Confirmed",
|
||||||
|
Some(EventStatus::Cancelled) => "Cancelled",
|
||||||
|
None => "Confirmed", // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for class
|
||||||
|
pub fn get_class_display(&self) -> &'static str {
|
||||||
|
match &self.class {
|
||||||
|
Some(EventClass::Public) => "Public",
|
||||||
|
Some(EventClass::Private) => "Private",
|
||||||
|
Some(EventClass::Confidential) => "Confidential",
|
||||||
|
None => "Public", // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for priority
|
||||||
|
pub fn get_priority_display(&self) -> String {
|
||||||
|
match self.priority {
|
||||||
|
None => "Not set".to_string(),
|
||||||
|
Some(0) => "Undefined".to_string(),
|
||||||
|
Some(1) => "High".to_string(),
|
||||||
|
Some(p) if p <= 4 => "High".to_string(),
|
||||||
|
Some(5) => "Medium".to_string(),
|
||||||
|
Some(p) if p <= 8 => "Low".to_string(),
|
||||||
|
Some(9) => "Low".to_string(),
|
||||||
|
Some(p) => format!("Priority {}", p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
calendar.db
BIN
calendar.db
Binary file not shown.
22
compose.yml
Normal file
22
compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
calendar-backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./backend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data/db:/db
|
||||||
|
|
||||||
|
calendar-frontend:
|
||||||
|
image: caddy
|
||||||
|
environment:
|
||||||
|
- BACKEND_API_URL=http://localhost:3000/api
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/dist:/srv/www:ro
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./data/caddy/data:/data
|
||||||
|
- ./data/caddy/config:/config
|
||||||
6
deploy_frontend.sh
Executable file
6
deploy_frontend.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
|
||||||
|
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/
|
||||||
|
unset BACKEND_API_URL
|
||||||
BIN
favicon_big.png
Normal file
BIN
favicon_big.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
95
frontend/Cargo.toml
Normal file
95
frontend/Cargo.toml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
[package]
|
||||||
|
name = "runway"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# Frontend binary only
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
calendar-models = { workspace = true, features = ["wasm"] }
|
||||||
|
yew = { version = "0.21", features = ["csr"] }
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"console",
|
||||||
|
"HtmlSelectElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"HtmlTextAreaElement",
|
||||||
|
"HtmlLinkElement",
|
||||||
|
"HtmlHeadElement",
|
||||||
|
"Event",
|
||||||
|
"MouseEvent",
|
||||||
|
"InputEvent",
|
||||||
|
"Element",
|
||||||
|
"Document",
|
||||||
|
"Window",
|
||||||
|
"Location",
|
||||||
|
"Navigator",
|
||||||
|
"DomTokenList",
|
||||||
|
"Headers",
|
||||||
|
"Request",
|
||||||
|
"RequestInit",
|
||||||
|
"RequestMode",
|
||||||
|
"Response",
|
||||||
|
"CssStyleDeclaration",
|
||||||
|
"MediaQueryList",
|
||||||
|
"MediaQueryListEvent",
|
||||||
|
# Notification API for browser notifications
|
||||||
|
"Notification",
|
||||||
|
"NotificationOptions",
|
||||||
|
"NotificationPermission",
|
||||||
|
# Service Worker API for background processing
|
||||||
|
"ServiceWorkerContainer",
|
||||||
|
"ServiceWorkerRegistration",
|
||||||
|
"MessageEvent",
|
||||||
|
# IndexedDB API for persistent alarm storage
|
||||||
|
"IdbDatabase",
|
||||||
|
"IdbObjectStore",
|
||||||
|
"IdbTransaction",
|
||||||
|
"IdbRequest",
|
||||||
|
"IdbKeyRange",
|
||||||
|
"IdbFactory",
|
||||||
|
"IdbOpenDbRequest",
|
||||||
|
"IdbVersionChangeEvent",
|
||||||
|
] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
js-sys = "0.3"
|
||||||
|
|
||||||
|
# HTTP client for CalDAV requests
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
# Calendar and iCal parsing
|
||||||
|
ical = "0.7"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
|
||||||
|
# Date and time handling
|
||||||
|
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||||
|
chrono-tz = "0.8"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log = "0.4"
|
||||||
|
console_log = "1.0"
|
||||||
|
|
||||||
|
# UUID generation for calendar events
|
||||||
|
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
|
||||||
|
# Environment variable handling
|
||||||
|
dotenvy = "0.15"
|
||||||
|
base64 = "0.21"
|
||||||
|
|
||||||
|
# XML/Regex parsing
|
||||||
|
regex = "1.0"
|
||||||
|
|
||||||
|
# Yew routing and local storage (WASM only)
|
||||||
|
yew-router = "0.18"
|
||||||
|
gloo-storage = "0.3"
|
||||||
|
gloo-timers = "0.3"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
||||||
|
# IndexedDB for persistent alarm storage
|
||||||
|
indexed_db_futures = "0.4"
|
||||||
|
|
||||||
16
frontend/Trunk.toml
Normal file
16
frontend/Trunk.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[build]
|
||||||
|
target = "index.html"
|
||||||
|
dist = "dist"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
BACKEND_API_URL = "http://localhost:3000/api"
|
||||||
|
|
||||||
|
[watch]
|
||||||
|
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
|
||||||
|
ignore = ["../backend/", "../target/"]
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
addresses = ["127.0.0.1"]
|
||||||
|
port = 8080
|
||||||
|
open = false
|
||||||
|
|
||||||
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
36
frontend/index.html
Normal file
36
frontend/index.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<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="css" href="print-preview.css">
|
||||||
|
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||||
|
<link data-trunk rel="copy-file" href="styles/apple.css">
|
||||||
|
<link data-trunk rel="copy-file" href="service-worker.js">
|
||||||
|
<link data-trunk rel="icon" href="favicon.ico">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('TrunkApplicationStarted', () => {
|
||||||
|
// Application loaded successfully
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register service worker for alarm background processing
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js')
|
||||||
|
.then((registration) => {
|
||||||
|
// Service worker registered successfully
|
||||||
|
})
|
||||||
|
.catch((registrationError) => {
|
||||||
|
console.log('SW registration failed: ', registrationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1216
frontend/print-preview.css
Normal file
1216
frontend/print-preview.css
Normal file
File diff suppressed because it is too large
Load Diff
150
frontend/service-worker.js
Normal file
150
frontend/service-worker.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Calendar Alarms Service Worker
|
||||||
|
// Handles background alarm checking when the main app is not active
|
||||||
|
|
||||||
|
const SW_VERSION = 'v1.0.0';
|
||||||
|
const CACHE_NAME = `calendar-alarms-${SW_VERSION}`;
|
||||||
|
const STORAGE_KEY = 'calendar_alarms';
|
||||||
|
|
||||||
|
// Install event
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
self.skipWaiting(); // Activate immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(self.clients.claim()); // Take control immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message handler for communication with main app
|
||||||
|
self.addEventListener('message', event => {
|
||||||
|
const { type, data } = event.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'CHECK_ALARMS':
|
||||||
|
handleCheckAlarms(event, data);
|
||||||
|
break;
|
||||||
|
case 'SCHEDULE_ALARM':
|
||||||
|
handleScheduleAlarm(data, event);
|
||||||
|
break;
|
||||||
|
case 'REMOVE_ALARM':
|
||||||
|
handleRemoveAlarm(data, event);
|
||||||
|
break;
|
||||||
|
case 'PING':
|
||||||
|
event.ports[0].postMessage({ type: 'PONG', version: SW_VERSION });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown message type:', type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle alarm checking request
|
||||||
|
function handleCheckAlarms(event, data) {
|
||||||
|
try {
|
||||||
|
// Main app sends alarms data to check
|
||||||
|
const allAlarms = data?.alarms || [];
|
||||||
|
const dueAlarms = checkProvidedAlarms(allAlarms);
|
||||||
|
|
||||||
|
// Send results back to main app
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARMS_DUE',
|
||||||
|
data: dueAlarms
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking alarms:', error);
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_CHECK_ERROR',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process alarms sent from main app
|
||||||
|
function checkProvidedAlarms(alarms) {
|
||||||
|
const now = new Date();
|
||||||
|
const nowStr = formatDateTimeForComparison(now);
|
||||||
|
|
||||||
|
// Filter alarms that should trigger and are pending
|
||||||
|
const dueAlarms = alarms.filter(alarm => {
|
||||||
|
return alarm.status === 'Pending' && alarm.trigger_time <= nowStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dueAlarms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle schedule alarm request (not needed with localStorage approach)
|
||||||
|
function handleScheduleAlarm(alarmData, event) {
|
||||||
|
// Service worker doesn't handle storage with localStorage approach
|
||||||
|
// Main app handles all storage operations
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_SCHEDULED',
|
||||||
|
data: { success: true, alarmId: alarmData.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remove alarm request (not needed with localStorage approach)
|
||||||
|
function handleRemoveAlarm(alarmData, event) {
|
||||||
|
// Service worker doesn't handle storage with localStorage approach
|
||||||
|
// Main app handles all storage operations
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_REMOVED',
|
||||||
|
data: { success: true, eventUid: alarmData.eventUid }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Format date time for comparison (YYYY-MM-DDTHH:MM:SS)
|
||||||
|
function formatDateTimeForComparison(date) {
|
||||||
|
return date.getFullYear() + '-' +
|
||||||
|
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(date.getDate()).padStart(2, '0') + 'T' +
|
||||||
|
String(date.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(date.getSeconds()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background alarm checking (runs periodically)
|
||||||
|
// Note: Service worker can't access localStorage, so this just pings the main app
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// Notify all clients to check their alarms
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'BACKGROUND_ALARM_CHECK_REQUEST'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background alarm check failed:', error);
|
||||||
|
}
|
||||||
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
|
// Handle push notifications (for future enhancement)
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
console.log('Push notification received:', event);
|
||||||
|
// Future: Handle server-sent alarm notifications
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification clicks
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
console.log('Notification clicked:', event);
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
// Focus or open the calendar app
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll().then(clients => {
|
||||||
|
// Try to focus existing client
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.includes('localhost') || client.url.includes(self.location.origin)) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new window if no client exists
|
||||||
|
return self.clients.openWindow('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
2008
frontend/src/app.rs
Normal file
2008
frontend/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -34,14 +45,58 @@ impl AuthService {
|
|||||||
let base_url = option_env!("BACKEND_API_URL")
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
.unwrap_or("http://localhost:3000/api")
|
.unwrap_or("http://localhost:3000/api")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
Self { base_url }
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(&self, token: &str) -> Result<bool, String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let url = format!("{}/auth/verify", self.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
let text = JsFuture::from(
|
||||||
|
resp.text()
|
||||||
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
|
|
||||||
|
// Parse the response to get the "valid" field
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&text_string)
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||||
|
} else {
|
||||||
|
Ok(false) // Invalid token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
@@ -49,9 +104,9 @@ impl AuthService {
|
|||||||
body: &T,
|
body: &T,
|
||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +117,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
@@ -92,4 +151,4 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
670
frontend/src/components/calendar.rs
Normal file
670
frontend/src/components/calendar.rs
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
use crate::components::{
|
||||||
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate, Weekday};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarProps {
|
||||||
|
#[prop_or_default]
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub view: ViewMode,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::NaiveDateTime>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub context_menus_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
|
let selected_date = use_state(|| {
|
||||||
|
// Try to load saved selected date from localStorage
|
||||||
|
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
|
||||||
|
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||||
|
saved_date
|
||||||
|
} else {
|
||||||
|
today
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check for old key for backward compatibility
|
||||||
|
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
|
||||||
|
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||||
|
saved_date
|
||||||
|
} else {
|
||||||
|
today
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the display date (what to show in the view)
|
||||||
|
let current_date = use_state(|| match props.view {
|
||||||
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
|
ViewMode::Week => *selected_date,
|
||||||
|
});
|
||||||
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
|
// State for create event modal
|
||||||
|
let show_create_modal = use_state(|| false);
|
||||||
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
|
let time_increment = use_state(|| {
|
||||||
|
// Try to load saved time increment from localStorage
|
||||||
|
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
|
||||||
|
if saved_increment == 15 || saved_increment == 30 {
|
||||||
|
saved_increment
|
||||||
|
} else {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
15
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
let external_events = props.external_calendar_events.clone(); // Clone before the effect
|
||||||
|
let view = props.view.clone(); // Clone before the effect
|
||||||
|
|
||||||
|
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
let view_mode = _view.clone(); // Clone the view mode to avoid lifetime issues
|
||||||
|
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
|
||||||
|
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which months to fetch based on view mode
|
||||||
|
let months_to_fetch = match view_mode {
|
||||||
|
ViewMode::Month => {
|
||||||
|
// For month view, just fetch the current month
|
||||||
|
vec![(date.year(), date.month())]
|
||||||
|
}
|
||||||
|
ViewMode::Week => {
|
||||||
|
// For week view, calculate the week bounds and fetch all months that intersect
|
||||||
|
let start_of_week = get_start_of_week(date);
|
||||||
|
let end_of_week = start_of_week + Duration::days(6);
|
||||||
|
|
||||||
|
let mut months = vec![(start_of_week.year(), start_of_week.month())];
|
||||||
|
|
||||||
|
// If the week spans into a different month, add that month too
|
||||||
|
if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() {
|
||||||
|
months.push((end_of_week.year(), end_of_week.month()));
|
||||||
|
}
|
||||||
|
|
||||||
|
months
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch events for all required months
|
||||||
|
let mut all_events = Vec::new();
|
||||||
|
for (year, month) in months_to_fetch {
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut month_events) => {
|
||||||
|
all_events.append(&mut month_events);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events for {}-{}: {}", year, month, err)));
|
||||||
|
loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate events that may appear in multiple month fetches
|
||||||
|
// This happens when a recurring event spans across month boundaries
|
||||||
|
all_events.sort_by(|a, b| {
|
||||||
|
// Sort by UID first, then by start time
|
||||||
|
match a.uid.cmp(&b.uid) {
|
||||||
|
std::cmp::Ordering::Equal => a.dtstart.cmp(&b.dtstart),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
all_events.dedup_by(|a, b| {
|
||||||
|
// Remove duplicates with same UID and start time
|
||||||
|
a.uid == b.uid && a.dtstart == b.dtstart
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the combined events
|
||||||
|
match Ok(all_events) as Result<Vec<VEvent>, String>
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
// Filter CalDAV events based on calendar visibility
|
||||||
|
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
|
||||||
|
vevents.into_iter()
|
||||||
|
.filter(|event| {
|
||||||
|
if let Some(calendar_path) = event.calendar_path.as_ref() {
|
||||||
|
// Find the calendar info for this event
|
||||||
|
user_info.calendars.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
.map(|cal| cal.is_visible)
|
||||||
|
.unwrap_or(true) // Default to visible if not found
|
||||||
|
} else {
|
||||||
|
true // Show events without calendar path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vevents // Show all events if no user info
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark external events as external by adding a special category
|
||||||
|
let marked_external_events: Vec<VEvent> = external_events
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut event| {
|
||||||
|
// Add a special category to identify external events
|
||||||
|
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
|
||||||
|
event
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
filtered_events.extend(marked_external_events);
|
||||||
|
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(filtered_events);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.clone()));
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
|
{
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
let selected_date = selected_date.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
use_effect_with(view, move |view_mode| {
|
||||||
|
let selected = *selected_date;
|
||||||
|
let new_display_date = match view_mode {
|
||||||
|
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
|
||||||
|
ViewMode::Week => selected, // Show the week containing the selected date
|
||||||
|
};
|
||||||
|
current_date.set(new_display_date);
|
||||||
|
|| {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_prev = {
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
let selected_date = selected_date.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let (new_selected, new_display) = match view {
|
||||||
|
ViewMode::Month => {
|
||||||
|
// Go to previous month, select the 1st day
|
||||||
|
let prev_month = *current_date - Duration::days(1);
|
||||||
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
|
(first_of_prev, first_of_prev)
|
||||||
|
}
|
||||||
|
ViewMode::Week => {
|
||||||
|
// Go to previous week
|
||||||
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
|
(prev_week, prev_week)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
selected_date.set(new_selected);
|
||||||
|
current_date.set(new_display);
|
||||||
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_next = {
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
let selected_date = selected_date.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let (new_selected, new_display) = match view {
|
||||||
|
ViewMode::Month => {
|
||||||
|
// Go to next month, select the 1st day
|
||||||
|
let next_month = if current_date.month() == 12 {
|
||||||
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
|
} else {
|
||||||
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
(next_month, next_month)
|
||||||
|
}
|
||||||
|
ViewMode::Week => {
|
||||||
|
// Go to next week
|
||||||
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
|
(next_week, next_week)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
selected_date.set(new_selected);
|
||||||
|
current_date.set(new_display);
|
||||||
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_today = {
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
let selected_date = selected_date.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let (new_selected, new_display) = match view {
|
||||||
|
ViewMode::Month => {
|
||||||
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
|
(today, first_of_today) // Select today, but display the month
|
||||||
|
}
|
||||||
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
|
};
|
||||||
|
selected_date.set(new_selected);
|
||||||
|
current_date.set(new_display);
|
||||||
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle time increment toggle
|
||||||
|
let on_time_increment_toggle = {
|
||||||
|
let time_increment = time_increment.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let current = *time_increment;
|
||||||
|
let next = if current == 15 { 30 } else { 15 };
|
||||||
|
time_increment.set(next);
|
||||||
|
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle print calendar preview
|
||||||
|
let show_print_preview = use_state(|| false);
|
||||||
|
let on_print = {
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
show_print_preview.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag-to-create event
|
||||||
|
let on_create_event = {
|
||||||
|
let show_create_modal = show_create_modal.clone();
|
||||||
|
let create_event_data = create_event_data.clone();
|
||||||
|
Callback::from(
|
||||||
|
move |(_date, start_datetime, end_datetime): (
|
||||||
|
NaiveDate,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag-to-move event
|
||||||
|
let on_event_update = {
|
||||||
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
|
Callback::from(
|
||||||
|
move |(
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::NaiveDateTime>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
|
<CalendarHeader
|
||||||
|
current_date={*current_date}
|
||||||
|
view_mode={props.view.clone()}
|
||||||
|
on_prev={on_prev}
|
||||||
|
on_next={on_next}
|
||||||
|
on_today={on_today}
|
||||||
|
time_increment={Some(*time_increment)}
|
||||||
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
|
on_print={Some(on_print)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
|
ViewMode::Month => {
|
||||||
|
let on_day_select = {
|
||||||
|
let selected_date = selected_date.clone();
|
||||||
|
Callback::from(move |date: NaiveDate| {
|
||||||
|
selected_date.set(date);
|
||||||
|
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<MonthView
|
||||||
|
current_month={*current_date}
|
||||||
|
today={today}
|
||||||
|
events={(*events).clone()}
|
||||||
|
on_event_click={on_event_click.clone()}
|
||||||
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
|
selected_date={Some(*selected_date)}
|
||||||
|
on_day_select={Some(on_day_select)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ViewMode::Week => html! {
|
||||||
|
<WeekView
|
||||||
|
current_date={*current_date}
|
||||||
|
today={today}
|
||||||
|
events={(*events).clone()}
|
||||||
|
on_event_click={on_event_click.clone()}
|
||||||
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
|
on_create_event={Some(on_create_event)}
|
||||||
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
|
on_event_update={Some(on_event_update)}
|
||||||
|
context_menus_open={props.context_menus_open}
|
||||||
|
time_increment={*time_increment}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event details modal
|
||||||
|
<EventModal
|
||||||
|
event={(*selected_event).clone()}
|
||||||
|
on_close={{
|
||||||
|
let selected_event_clone = selected_event.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
selected_event_clone.set(None);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Create event modal
|
||||||
|
<CreateEventModal
|
||||||
|
is_open={*show_create_modal}
|
||||||
|
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
||||||
|
event_to_edit={None}
|
||||||
|
available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()}
|
||||||
|
initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)}
|
||||||
|
initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)}
|
||||||
|
on_close={{
|
||||||
|
let show_create_modal = show_create_modal.clone();
|
||||||
|
let create_event_data = create_event_data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_create_modal.set(false);
|
||||||
|
create_event_data.set(None);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
on_create={{
|
||||||
|
let show_create_modal = show_create_modal.clone();
|
||||||
|
let create_event_data = create_event_data.clone();
|
||||||
|
let on_create_event_request = props.on_create_event_request.clone();
|
||||||
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
|
show_create_modal.set(false);
|
||||||
|
create_event_data.set(None);
|
||||||
|
|
||||||
|
// Emit the create event request to parent
|
||||||
|
if let Some(callback) = &on_create_event_request {
|
||||||
|
callback.emit(event_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Print preview modal
|
||||||
|
{
|
||||||
|
if *show_print_preview {
|
||||||
|
html! {
|
||||||
|
<PrintPreviewModal
|
||||||
|
on_close={{
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_print_preview.set(false);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
view_mode={props.view.clone()}
|
||||||
|
current_date={*current_date}
|
||||||
|
selected_date={*selected_date}
|
||||||
|
events={(*events).clone()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={*time_increment}
|
||||||
|
today={today}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate the start of the week (Sunday) for a given date
|
||||||
|
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
|
||||||
|
let weekday = date.weekday();
|
||||||
|
let days_from_sunday = match weekday {
|
||||||
|
Weekday::Sun => 0,
|
||||||
|
Weekday::Mon => 1,
|
||||||
|
Weekday::Tue => 2,
|
||||||
|
Weekday::Wed => 3,
|
||||||
|
Weekday::Thu => 4,
|
||||||
|
Weekday::Fri => 5,
|
||||||
|
Weekday::Sat => 6,
|
||||||
|
};
|
||||||
|
date - Duration::days(days_from_sunday)
|
||||||
|
}
|
||||||
83
frontend/src/components/calendar_context_menu.rs
Normal file
83
frontend/src/components/calendar_context_menu.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarContextMenuProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create_event: Callback<MouseEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarContextMenu)]
|
||||||
|
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||||
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;",
|
||||||
|
x, y
|
||||||
|
);
|
||||||
|
|
||||||
|
let on_create_event_click = {
|
||||||
|
let on_create_event = props.on_create_event.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
on_create_event.emit(e);
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
ref={menu_ref}
|
||||||
|
class="context-menu"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||||
|
<span class="context-menu-icon">{"+"}</span>
|
||||||
|
{"Create Event"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
81
frontend/src/components/calendar_header.rs
Normal file
81
frontend/src/components/calendar_header.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarHeaderProps {
|
||||||
|
pub current_date: NaiveDate,
|
||||||
|
pub view_mode: ViewMode,
|
||||||
|
pub on_prev: Callback<MouseEvent>,
|
||||||
|
pub on_next: Callback<MouseEvent>,
|
||||||
|
pub on_today: Callback<MouseEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub time_increment: Option<u32>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_print: Option<Callback<MouseEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarHeader)]
|
||||||
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<button class="nav-button" onclick={props.on_prev.clone()}>{"‹"}</button>
|
||||||
|
{
|
||||||
|
if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) {
|
||||||
|
html! {
|
||||||
|
<button class="time-increment-button" onclick={callback.clone()}>
|
||||||
|
{format!("{}", increment)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if let Some(print_callback) = &props.on_print {
|
||||||
|
html! {
|
||||||
|
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
|
||||||
|
<i class="fas fa-print"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h2 class="month-year">{title}</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button>
|
||||||
|
<button class="nav-button" onclick={props.on_next.clone()}>{"›"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_month_name(month: u32) -> &'static str {
|
||||||
|
match month {
|
||||||
|
1 => "January",
|
||||||
|
2 => "February",
|
||||||
|
3 => "March",
|
||||||
|
4 => "April",
|
||||||
|
5 => "May",
|
||||||
|
6 => "June",
|
||||||
|
7 => "July",
|
||||||
|
8 => "August",
|
||||||
|
9 => "September",
|
||||||
|
10 => "October",
|
||||||
|
11 => "November",
|
||||||
|
12 => "December",
|
||||||
|
_ => "Invalid",
|
||||||
|
}
|
||||||
|
}
|
||||||
104
frontend/src/components/calendar_list_item.rs
Normal file
104
frontend/src/components/calendar_list_item.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarListItemProps {
|
||||||
|
pub calendar: CalendarInfo,
|
||||||
|
pub color_picker_open: bool,
|
||||||
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
|
||||||
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
|
pub on_visibility_toggle: Callback<String>, // calendar_path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarListItem)]
|
||||||
|
pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
||||||
|
let on_color_click = {
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_picker_toggle.emit(cal_path.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_context_menu = {
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_context_menu = props.on_context_menu.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
on_context_menu.emit((e, cal_path.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_visibility_toggle = {
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_visibility_toggle = props.on_visibility_toggle.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_visibility_toggle.emit(cal_path.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||||
|
<div class="calendar-info">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.calendar.is_visible}
|
||||||
|
onchange={on_visibility_toggle}
|
||||||
|
/>
|
||||||
|
<span class="calendar-color"
|
||||||
|
style={format!("background-color: {}", props.calendar.color)}
|
||||||
|
onclick={on_color_click}>
|
||||||
|
{
|
||||||
|
if props.color_picker_open {
|
||||||
|
html! {
|
||||||
|
<div class="color-picker-dropdown">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let color_str = color.clone();
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_color_right_click = {
|
||||||
|
let on_color_editor_open = props.on_color_editor_open.clone();
|
||||||
|
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
|
||||||
|
let color_str = color.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_editor_open.emit((color_index, color_str.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_selected = props.calendar.color == *color;
|
||||||
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class={class_name}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
|
oncontextmenu={on_color_right_click}>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
449
frontend/src/components/calendar_management_modal.rs
Normal file
449
frontend/src/components/calendar_management_modal.rs
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use crate::services::calendar_service::CalendarService;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum CalendarTab {
|
||||||
|
Create,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarManagementModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||||
|
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarManagementModal)]
|
||||||
|
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
||||||
|
let active_tab = use_state(|| CalendarTab::Create);
|
||||||
|
|
||||||
|
// Create Calendar state
|
||||||
|
let calendar_name = use_state(|| String::new());
|
||||||
|
let description = use_state(|| String::new());
|
||||||
|
let selected_color = use_state(|| None::<String>);
|
||||||
|
let create_error_message = use_state(|| None::<String>);
|
||||||
|
let is_creating = use_state(|| false);
|
||||||
|
|
||||||
|
// External Calendar state
|
||||||
|
let external_name = use_state(|| String::new());
|
||||||
|
let external_url = use_state(|| String::new());
|
||||||
|
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
|
||||||
|
let external_is_loading = use_state(|| false);
|
||||||
|
let external_error_message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
use_effect_with(props.is_open, {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
|
||||||
|
move |is_open| {
|
||||||
|
if *is_open {
|
||||||
|
// Reset all state when modal opens
|
||||||
|
calendar_name.set(String::new());
|
||||||
|
description.set(String::new());
|
||||||
|
selected_color.set(None);
|
||||||
|
create_error_message.set(None);
|
||||||
|
is_creating.set(false);
|
||||||
|
external_name.set(String::new());
|
||||||
|
external_url.set(String::new());
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(None);
|
||||||
|
external_selected_color.set(Some("#4285f4".to_string()));
|
||||||
|
active_tab.set(CalendarTab::Create);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_tab_click = {
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
Callback::from(move |tab: CalendarTab| {
|
||||||
|
active_tab.set(tab);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Calendar handlers
|
||||||
|
let on_name_change = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
calendar_name.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let description = description.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
description.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_select = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_color_select = {
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
external_selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_create_submit = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let on_create_calendar = props.on_create_calendar.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*calendar_name).trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
create_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_creating.set(true);
|
||||||
|
create_error_message.set(None);
|
||||||
|
|
||||||
|
let desc = if description.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((*description).clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External Calendar handlers
|
||||||
|
let on_external_submit = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let on_external_success = props.on_external_success.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*external_name).trim().to_string();
|
||||||
|
let url = (*external_url).trim().to_string();
|
||||||
|
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
|
||||||
|
|
||||||
|
// Debug logging to understand the issue
|
||||||
|
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty name".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar URL is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty URL".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
external_is_loading.set(true);
|
||||||
|
external_error_message.set(None);
|
||||||
|
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_external_success = on_external_success.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
|
Ok(calendar) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
on_close.emit(());
|
||||||
|
on_external_success.emit(calendar.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External input change handlers
|
||||||
|
let on_external_name_change = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_name.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_url_change = {
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_url.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content calendar-management-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Add Calendar"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-management-tabs">
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Create Calendar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Add External"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
match *active_tab {
|
||||||
|
CalendarTab::Create => html! {
|
||||||
|
<form onsubmit={on_create_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="calendar-name"
|
||||||
|
value={(*calendar_name).clone()}
|
||||||
|
oninput={on_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-description">{"Description (optional)"}</label>
|
||||||
|
<textarea
|
||||||
|
id="calendar-description"
|
||||||
|
value={(*description).clone()}
|
||||||
|
oninput={on_description_change}
|
||||||
|
placeholder="Enter calendar description"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_color_select = on_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *create_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{if *is_creating { "Creating..." } else { "Create Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
},
|
||||||
|
CalendarTab::External => html! {
|
||||||
|
<form onsubmit={on_external_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="external-name"
|
||||||
|
value={(*external_name).clone()}
|
||||||
|
onchange={on_external_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-url">{"Calendar URL"}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="external-url"
|
||||||
|
value={(*external_url).clone()}
|
||||||
|
onchange={on_external_url_change}
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
<small class="help-text">
|
||||||
|
{"Enter the ICS/CalDAV URL for your external calendar"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = external_selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_external_color_select = on_external_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_external_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *external_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
176
frontend/src/components/color_editor_modal.rs
Normal file
176
frontend/src/components/color_editor_modal.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ColorEditorModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub current_color: String,
|
||||||
|
pub color_index: usize,
|
||||||
|
pub default_color: String, // Default color for this index
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_save: Callback<(usize, String)>, // (index, new_color)
|
||||||
|
pub on_reset_all: Callback<()>, // Reset all colors to defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ColorEditorModal)]
|
||||||
|
pub fn color_editor_modal(props: &ColorEditorModalProps) -> Html {
|
||||||
|
let selected_color = use_state(|| props.current_color.clone());
|
||||||
|
|
||||||
|
// Reset selected color when modal opens with new color
|
||||||
|
{
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
use_effect_with(props.current_color.clone(), move |current_color| {
|
||||||
|
selected_color.set(current_color.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_color_input = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
selected_color.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let on_save = props.on_save.clone();
|
||||||
|
let color_index = props.color_index;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_save.emit((color_index, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only close if clicking the backdrop, not the modal content
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Some(element) = target.dyn_ref::<web_sys::Element>() {
|
||||||
|
if element.class_list().contains("color-editor-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined color suggestions
|
||||||
|
let suggested_colors = vec![
|
||||||
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4",
|
||||||
|
"#84CC16", "#F97316", "#EC4899", "#6366F1", "#14B8A6", "#F3B806",
|
||||||
|
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", "#F87171", "#34D399",
|
||||||
|
"#FBBF24", "#A78BFA", "#60A5FA", "#2DD4BF", "#FB7185", "#FDBA74",
|
||||||
|
];
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="color-editor-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="color-editor-modal">
|
||||||
|
<div class="color-editor-header">
|
||||||
|
<h3>{"Edit Color"}</h3>
|
||||||
|
<button class="close-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-editor-content">
|
||||||
|
<div class="current-color-preview">
|
||||||
|
<div
|
||||||
|
class="color-preview-large"
|
||||||
|
style={format!("background-color: {}", *selected_color)}
|
||||||
|
></div>
|
||||||
|
<div class="color-preview-info">
|
||||||
|
<span class="color-value">{&*selected_color}</span>
|
||||||
|
<button class="reset-this-color-button" onclick={{
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let default_color = props.default_color.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
selected_color.set(default_color.clone());
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{"Reset This Color"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-input-section">
|
||||||
|
<label for="color-picker">{"Custom Color:"}</label>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color-picker"
|
||||||
|
value={(*selected_color).clone()}
|
||||||
|
oninput={on_color_input.clone()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="color-text-input"
|
||||||
|
value={(*selected_color).clone()}
|
||||||
|
oninput={on_color_input}
|
||||||
|
placeholder="#000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggested-colors-section">
|
||||||
|
<label>{"Suggested Colors:"}</label>
|
||||||
|
<div class="suggested-colors-grid">
|
||||||
|
{
|
||||||
|
suggested_colors.iter().map(|color| {
|
||||||
|
let color = color.to_string();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let onclick = {
|
||||||
|
let color = color.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
selected_color.set(color.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="suggested-color"
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={onclick}
|
||||||
|
title={color.clone()}
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-editor-footer">
|
||||||
|
<button class="cancel-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="reset-all-button" onclick={Callback::from({
|
||||||
|
let on_reset_all = props.on_reset_all.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| {
|
||||||
|
on_reset_all.emit(());
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
{"Reset All Colors"}
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onclick={on_save_click}>
|
||||||
|
{"Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
84
frontend/src/components/context_menu.rs
Normal file
84
frontend/src/components/context_menu.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ContextMenuProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub on_delete: Callback<MouseEvent>,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ContextMenu)]
|
||||||
|
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||||
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
|
// Close menu when clicking outside (handled by parent component)
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
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;",
|
||||||
|
x, y
|
||||||
|
);
|
||||||
|
|
||||||
|
let on_delete_click = {
|
||||||
|
let on_delete = props.on_delete.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
on_delete.emit(e);
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
ref={menu_ref}
|
||||||
|
class="context-menu"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||||
|
{"Delete Calendar"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
198
frontend/src/components/create_calendar_modal.rs
Normal file
198
frontend/src/components/create_calendar_modal.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CreateCalendarModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
||||||
|
let calendar_name = use_state(|| String::new());
|
||||||
|
let description = use_state(|| String::new());
|
||||||
|
let selected_color = use_state(|| None::<String>);
|
||||||
|
let error_message = use_state(|| None::<String>);
|
||||||
|
let is_creating = use_state(|| false);
|
||||||
|
|
||||||
|
let on_name_change = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||||
|
calendar_name.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let description = description.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
description.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let on_create = props.on_create.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*calendar_name).trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.len() > 100 {
|
||||||
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_message.set(None);
|
||||||
|
is_creating.set(true);
|
||||||
|
|
||||||
|
let desc = if (*description).trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((*description).clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only close if clicking the backdrop, not the modal content
|
||||||
|
if e.target() == e.current_target() {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="create-calendar-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Create New Calendar"}</h2>
|
||||||
|
<button class="close-button" onclick={props.on_close.reform(|_| ())}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="modal-body" onsubmit={on_submit}>
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||||
|
<input
|
||||||
|
id="calendar-name"
|
||||||
|
type="text"
|
||||||
|
value={(*calendar_name).clone()}
|
||||||
|
oninput={on_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
maxlength="100"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-description">{"Description"}</label>
|
||||||
|
<textarea
|
||||||
|
id="calendar-description"
|
||||||
|
value={(*description).clone()}
|
||||||
|
oninput={on_description_change}
|
||||||
|
placeholder="Optional calendar description"
|
||||||
|
rows="3"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-grid">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().enumerate().map(|(index, color)| {
|
||||||
|
let color = color.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let is_selected = selected_color.as_ref() == Some(&color);
|
||||||
|
let on_color_select = {
|
||||||
|
let color = color.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
selected_color.set(Some(color.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let class_name = if is_selected {
|
||||||
|
"color-option selected"
|
||||||
|
} else {
|
||||||
|
"color-option"
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
class={class_name}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={props.on_close.reform(|_| ())}
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if *is_creating {
|
||||||
|
"Creating..."
|
||||||
|
} else {
|
||||||
|
"Create Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
465
frontend/src/components/create_event_modal.rs
Normal file
465
frontend/src/components/create_event_modal.rs
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
use crate::components::event_form::*;
|
||||||
|
use crate::components::EditAction;
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
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(CreateEventModal)]
|
||||||
|
pub fn create_event_modal(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 {
|
||||||
|
// Convert VEvent to EventCreationData for editing
|
||||||
|
vevent_to_creation_data(event, &available_calendars)
|
||||||
|
} 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() {
|
||||||
|
// For new events, try to use the last used calendar
|
||||||
|
if event_to_edit.is_none() {
|
||||||
|
// Try to get last used calendar from localStorage
|
||||||
|
if let Ok(last_used_calendar) = LocalStorage::get::<String>("last_used_calendar") {
|
||||||
|
// Check if the last used calendar is still available
|
||||||
|
if available_calendars.iter().any(|cal| cal.path == last_used_calendar) {
|
||||||
|
data.selected_calendar = Some(last_used_calendar);
|
||||||
|
} else {
|
||||||
|
// Fall back to first available calendar
|
||||||
|
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No last used calendar, use first available
|
||||||
|
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For editing existing events, keep the current calendar as default
|
||||||
|
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();
|
||||||
|
let event_to_edit = props.event_to_edit.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let mut data = (*event_data).clone();
|
||||||
|
|
||||||
|
// If we're editing an existing event, mark it as an update operation
|
||||||
|
if let Some(ref original_event) = event_to_edit {
|
||||||
|
// Set the original UID so the backend knows to update instead of create
|
||||||
|
data.original_uid = Some(original_event.uid.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
on_create.emit(data);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert VEvent to EventCreationData for editing
|
||||||
|
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
||||||
|
|
||||||
|
// VEvent fields are already local time (NaiveDateTime)
|
||||||
|
let start_local = event.dtstart;
|
||||||
|
let end_local = if let Some(dtend) = event.dtend {
|
||||||
|
dtend
|
||||||
|
} else {
|
||||||
|
// Default to 1 hour after start if no end time
|
||||||
|
start_local + chrono::Duration::hours(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
EventCreationData {
|
||||||
|
// Basic event info
|
||||||
|
title: event.summary.clone().unwrap_or_default(),
|
||||||
|
description: event.description.clone().unwrap_or_default(),
|
||||||
|
location: event.location.clone().unwrap_or_default(),
|
||||||
|
all_day: event.all_day,
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
start_date: start_local.date(),
|
||||||
|
end_date: if event.all_day {
|
||||||
|
// For all-day events, subtract one day to convert from exclusive to inclusive end date
|
||||||
|
// (UI expects inclusive dates, but iCalendar stores exclusive end dates)
|
||||||
|
end_local.date() - chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
end_local.date()
|
||||||
|
},
|
||||||
|
start_time: start_local.time(),
|
||||||
|
end_time: end_local.time(),
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
status: match event.status {
|
||||||
|
Some(crate::models::ical::EventStatus::Tentative) => EventStatus::Tentative,
|
||||||
|
Some(crate::models::ical::EventStatus::Confirmed) => EventStatus::Confirmed,
|
||||||
|
Some(crate::models::ical::EventStatus::Cancelled) => EventStatus::Cancelled,
|
||||||
|
None => EventStatus::Confirmed,
|
||||||
|
},
|
||||||
|
class: match event.class {
|
||||||
|
Some(crate::models::ical::EventClass::Public) => EventClass::Public,
|
||||||
|
Some(crate::models::ical::EventClass::Private) => EventClass::Private,
|
||||||
|
Some(crate::models::ical::EventClass::Confidential) => EventClass::Confidential,
|
||||||
|
None => EventClass::Public,
|
||||||
|
},
|
||||||
|
priority: event.priority,
|
||||||
|
|
||||||
|
// People
|
||||||
|
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||||
|
attendees: event.attendees.iter()
|
||||||
|
.map(|a| a.cal_address.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
|
||||||
|
// Categorization
|
||||||
|
categories: event.categories.join(","),
|
||||||
|
|
||||||
|
// Reminders - Use VAlarms from the event
|
||||||
|
alarms: event.alarms.clone(),
|
||||||
|
|
||||||
|
// Recurrence - Parse RRULE if present
|
||||||
|
recurrence: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
|
||||||
|
parse_rrule_frequency(rrule_str)
|
||||||
|
} else {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
|
||||||
|
RecurrenceType::None
|
||||||
|
},
|
||||||
|
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
parse_rrule_interval(rrule_str)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
},
|
||||||
|
recurrence_until: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
parse_rrule_until(rrule_str)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
recurrence_count: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
parse_rrule_count(rrule_str)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
recurrence_days: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
parse_rrule_days(rrule_str)
|
||||||
|
} else {
|
||||||
|
vec![false; 7]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Advanced recurrence
|
||||||
|
monthly_by_day: None,
|
||||||
|
monthly_by_monthday: None,
|
||||||
|
yearly_by_month: vec![false; 12],
|
||||||
|
|
||||||
|
// Calendar selection - try to find the calendar this event belongs to
|
||||||
|
selected_calendar: if let Some(ref calendar_path) = event.calendar_path {
|
||||||
|
if available_calendars.iter().any(|cal| cal.path == *calendar_path) {
|
||||||
|
Some(calendar_path.clone())
|
||||||
|
} else if let Some(first_calendar) = available_calendars.first() {
|
||||||
|
Some(first_calendar.path.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if let Some(first_calendar) = available_calendars.first() {
|
||||||
|
Some(first_calendar.path.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edit tracking
|
||||||
|
edit_scope: {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
|
||||||
|
None // Will be set by the modal after creation
|
||||||
|
},
|
||||||
|
changed_fields: vec![],
|
||||||
|
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
||||||
|
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse RRULE frequency component
|
||||||
|
fn parse_rrule_frequency(rrule: &str) -> RecurrenceType {
|
||||||
|
if rrule.contains("FREQ=DAILY") {
|
||||||
|
RecurrenceType::Daily
|
||||||
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
|
RecurrenceType::Weekly
|
||||||
|
} else if rrule.contains("FREQ=MONTHLY") {
|
||||||
|
RecurrenceType::Monthly
|
||||||
|
} else if rrule.contains("FREQ=YEARLY") {
|
||||||
|
RecurrenceType::Yearly
|
||||||
|
} else {
|
||||||
|
RecurrenceType::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse RRULE interval component
|
||||||
|
fn parse_rrule_interval(rrule: &str) -> u32 {
|
||||||
|
if let Some(start) = rrule.find("INTERVAL=") {
|
||||||
|
let interval_part = &rrule[start + 9..];
|
||||||
|
if let Some(end) = interval_part.find(';') {
|
||||||
|
interval_part[..end].parse().unwrap_or(1)
|
||||||
|
} else {
|
||||||
|
interval_part.parse().unwrap_or(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse RRULE count component
|
||||||
|
fn parse_rrule_count(rrule: &str) -> Option<u32> {
|
||||||
|
if let Some(start) = rrule.find("COUNT=") {
|
||||||
|
let count_part = &rrule[start + 6..];
|
||||||
|
if let Some(end) = count_part.find(';') {
|
||||||
|
count_part[..end].parse().ok()
|
||||||
|
} else {
|
||||||
|
count_part.parse().ok()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse RRULE until component
|
||||||
|
fn parse_rrule_until(rrule: &str) -> Option<chrono::NaiveDate> {
|
||||||
|
if let Some(start) = rrule.find("UNTIL=") {
|
||||||
|
let until_part = &rrule[start + 6..];
|
||||||
|
let until_str = if let Some(end) = until_part.find(';') {
|
||||||
|
&until_part[..end]
|
||||||
|
} else {
|
||||||
|
until_part
|
||||||
|
};
|
||||||
|
|
||||||
|
// UNTIL can be in format YYYYMMDD or YYYYMMDDTHHMMSSZ
|
||||||
|
let date_part = if until_str.len() >= 8 {
|
||||||
|
&until_str[..8]
|
||||||
|
} else {
|
||||||
|
until_str
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse YYYYMMDD format
|
||||||
|
if date_part.len() == 8 {
|
||||||
|
if let (Ok(year), Ok(month), Ok(day)) = (
|
||||||
|
date_part[0..4].parse::<i32>(),
|
||||||
|
date_part[4..6].parse::<u32>(),
|
||||||
|
date_part[6..8].parse::<u32>(),
|
||||||
|
) {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(year, month, day)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse RRULE BYDAY component for weekly recurrence
|
||||||
|
fn parse_rrule_days(rrule: &str) -> Vec<bool> {
|
||||||
|
let mut days = vec![false; 7]; // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||||
|
|
||||||
|
if let Some(start) = rrule.find("BYDAY=") {
|
||||||
|
let byday_part = &rrule[start + 6..];
|
||||||
|
let byday_str = if let Some(end) = byday_part.find(';') {
|
||||||
|
&byday_part[..end]
|
||||||
|
} else {
|
||||||
|
byday_part
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse comma-separated day codes: SU,MO,TU,WE,TH,FR,SA
|
||||||
|
for day_code in byday_str.split(',') {
|
||||||
|
match day_code.trim() {
|
||||||
|
"SU" => days[0] = true, // Sunday
|
||||||
|
"MO" => days[1] = true, // Monday
|
||||||
|
"TU" => days[2] = true, // Tuesday
|
||||||
|
"WE" => days[3] = true, // Wednesday
|
||||||
|
"TH" => days[4] = true, // Thursday
|
||||||
|
"FR" => days[5] = true, // Friday
|
||||||
|
"SA" => days[6] = true, // Saturday
|
||||||
|
_ => {} // Ignore unknown day codes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
days
|
||||||
|
}
|
||||||
215
frontend/src/components/event_context_menu.rs
Normal file
215
frontend/src/components/event_context_menu.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum DeleteAction {
|
||||||
|
DeleteThis,
|
||||||
|
DeleteFollowing,
|
||||||
|
DeleteSeries,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum EditAction {
|
||||||
|
EditThis,
|
||||||
|
EditFuture,
|
||||||
|
EditAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct EventContextMenuProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub event: Option<VEvent>,
|
||||||
|
pub on_edit: Callback<EditAction>,
|
||||||
|
pub on_delete: Callback<DeleteAction>,
|
||||||
|
pub on_view_details: Callback<VEvent>,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(EventContextMenu)]
|
||||||
|
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||||
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
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;",
|
||||||
|
x, y
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the event is recurring
|
||||||
|
let is_recurring = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.map(|event| event.rrule.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if the event is from an external calendar (read-only)
|
||||||
|
let is_external = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|event| event.calendar_path.as_ref())
|
||||||
|
.map(|path| path.starts_with("external_"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let create_edit_callback = |action: EditAction| {
|
||||||
|
let on_edit = props.on_edit.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
on_edit.emit(action.clone());
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_singleton_edit_callback = {
|
||||||
|
let on_edit_singleton = props.on_edit_singleton.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_edit_singleton.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_delete_callback = |action: DeleteAction| {
|
||||||
|
let on_delete = props.on_delete.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
on_delete.emit(action.clone());
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_view_details_callback = {
|
||||||
|
let on_view_details = props.on_view_details.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_view_details.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
ref={menu_ref}
|
||||||
|
class="context-menu"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if is_external {
|
||||||
|
// External calendar events are read-only - only show "View Details"
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_view_details_callback}>
|
||||||
|
{"View Event Details"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if is_recurring {
|
||||||
|
// Regular recurring events - show edit options
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
|
{"Edit This Event"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}>
|
||||||
|
{"Edit This and Future Events"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}>
|
||||||
|
{"Edit All Events in Series"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular single events - show edit option without setting edit scope
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
|
||||||
|
{"Edit Event"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if !is_external {
|
||||||
|
// Only show delete options for non-external events
|
||||||
|
if is_recurring {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
|
{"Delete This Event"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||||
|
{"Delete Following Events"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||||
|
{"Delete Entire Series"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
|
{"Delete Event"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No delete options for external events
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
296
frontend/src/components/event_form/add_alarm_modal.rs
Normal file
296
frontend/src/components/event_form/add_alarm_modal.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger};
|
||||||
|
use chrono::{Duration, DateTime, Utc, NaiveTime};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlSelectElement, HtmlInputElement};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum TriggerType {
|
||||||
|
Relative, // Duration before/after event
|
||||||
|
Absolute, // Specific date/time
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum RelativeTo {
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum TimeUnit {
|
||||||
|
Minutes,
|
||||||
|
Hours,
|
||||||
|
Days,
|
||||||
|
Weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct AddAlarmModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub editing_index: Option<usize>, // If editing an existing alarm
|
||||||
|
pub initial_alarm: Option<VAlarm>, // For editing mode
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_save: Callback<VAlarm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AddAlarmModal)]
|
||||||
|
pub fn add_alarm_modal(props: &AddAlarmModalProps) -> Html {
|
||||||
|
// Form state
|
||||||
|
let trigger_type = use_state(|| TriggerType::Relative);
|
||||||
|
let relative_to = use_state(|| RelativeTo::Start);
|
||||||
|
let time_unit = use_state(|| TimeUnit::Minutes);
|
||||||
|
let time_value = use_state(|| 15i32);
|
||||||
|
let before_after = use_state(|| true); // true = before, false = after
|
||||||
|
let absolute_date = use_state(|| chrono::Local::now().date_naive());
|
||||||
|
let absolute_time = use_state(|| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
|
||||||
|
|
||||||
|
// Initialize form with existing alarm data if editing
|
||||||
|
{
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
|
||||||
|
use_effect_with(props.initial_alarm.clone(), move |initial_alarm| {
|
||||||
|
if let Some(alarm) = initial_alarm {
|
||||||
|
match &alarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
trigger_type.set(TriggerType::Relative);
|
||||||
|
let minutes = duration.num_minutes().abs();
|
||||||
|
time_value.set(minutes as i32);
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(_) => {
|
||||||
|
trigger_type.set(TriggerType::Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_trigger_type_change = {
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_type = match target.value().as_str() {
|
||||||
|
"absolute" => TriggerType::Absolute,
|
||||||
|
_ => TriggerType::Relative,
|
||||||
|
};
|
||||||
|
trigger_type.set(new_type);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let on_relative_to_change = {
|
||||||
|
let relative_to = relative_to.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_relative = match target.value().as_str() {
|
||||||
|
"end" => RelativeTo::End,
|
||||||
|
_ => RelativeTo::Start,
|
||||||
|
};
|
||||||
|
relative_to.set(new_relative);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_time_unit_change = {
|
||||||
|
let time_unit = time_unit.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_unit = match target.value().as_str() {
|
||||||
|
"hours" => TimeUnit::Hours,
|
||||||
|
"days" => TimeUnit::Days,
|
||||||
|
"weeks" => TimeUnit::Weeks,
|
||||||
|
_ => TimeUnit::Minutes,
|
||||||
|
};
|
||||||
|
time_unit.set(new_unit);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_time_value_change = {
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(value) = target.value().parse::<i32>() {
|
||||||
|
time_value.set(value.max(1)); // Minimum 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_before_after_change = {
|
||||||
|
let before_after = before_after.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let is_before = target.value() == "before";
|
||||||
|
before_after.set(is_before);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
let time_unit = time_unit.clone();
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
let before_after = before_after.clone();
|
||||||
|
let absolute_date = absolute_date.clone();
|
||||||
|
let absolute_time = absolute_time.clone();
|
||||||
|
let on_save = props.on_save.clone();
|
||||||
|
|
||||||
|
Callback::from(move |_| {
|
||||||
|
// Create the alarm trigger
|
||||||
|
let trigger = match *trigger_type {
|
||||||
|
TriggerType::Relative => {
|
||||||
|
let minutes = match *time_unit {
|
||||||
|
TimeUnit::Minutes => *time_value,
|
||||||
|
TimeUnit::Hours => *time_value * 60,
|
||||||
|
TimeUnit::Days => *time_value * 60 * 24,
|
||||||
|
TimeUnit::Weeks => *time_value * 60 * 24 * 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
let signed_minutes = if *before_after { -minutes } else { minutes } as i64;
|
||||||
|
AlarmTrigger::Duration(Duration::minutes(signed_minutes))
|
||||||
|
}
|
||||||
|
TriggerType::Absolute => {
|
||||||
|
// Combine date and time to create a DateTime<Utc>
|
||||||
|
let naive_datetime = absolute_date.and_time(*absolute_time);
|
||||||
|
let utc_datetime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc);
|
||||||
|
AlarmTrigger::DateTime(utc_datetime)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the VAlarm - always use Display action, no custom description
|
||||||
|
let alarm = VAlarm {
|
||||||
|
action: AlarmAction::Display,
|
||||||
|
trigger,
|
||||||
|
duration: None,
|
||||||
|
repeat: None,
|
||||||
|
description: None,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
on_save.emit(alarm);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Some(element) = target.dyn_ref::<web_sys::Element>() {
|
||||||
|
if element.class_list().contains("add-alarm-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="add-alarm-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="add-alarm-modal">
|
||||||
|
<div class="add-alarm-header">
|
||||||
|
<h3>{
|
||||||
|
if props.editing_index.is_some() {
|
||||||
|
"Edit Reminder"
|
||||||
|
} else {
|
||||||
|
"Add Reminder"
|
||||||
|
}
|
||||||
|
}</h3>
|
||||||
|
<button class="close-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-alarm-content">
|
||||||
|
// Trigger Type Selection
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="trigger-type">{"Trigger Type"}</label>
|
||||||
|
<select id="trigger-type" class="form-input" onchange={on_trigger_type_change}>
|
||||||
|
<option value="relative" selected={matches!(*trigger_type, TriggerType::Relative)}>
|
||||||
|
{"Relative to event time"}
|
||||||
|
</option>
|
||||||
|
<option value="absolute" selected={matches!(*trigger_type, TriggerType::Absolute)}>
|
||||||
|
{"Specific date and time"}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Relative Trigger Configuration
|
||||||
|
if matches!(*trigger_type, TriggerType::Relative) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"When"}</label>
|
||||||
|
<div class="relative-time-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input time-value-input"
|
||||||
|
value={time_value.to_string()}
|
||||||
|
min="1"
|
||||||
|
onchange={on_time_value_change}
|
||||||
|
/>
|
||||||
|
<select class="form-input time-unit-select" onchange={on_time_unit_change}>
|
||||||
|
<option value="minutes" selected={matches!(*time_unit, TimeUnit::Minutes)}>{"minute(s)"}</option>
|
||||||
|
<option value="hours" selected={matches!(*time_unit, TimeUnit::Hours)}>{"hour(s)"}</option>
|
||||||
|
<option value="days" selected={matches!(*time_unit, TimeUnit::Days)}>{"day(s)"}</option>
|
||||||
|
<option value="weeks" selected={matches!(*time_unit, TimeUnit::Weeks)}>{"week(s)"}</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-input before-after-select" onchange={on_before_after_change}>
|
||||||
|
<option value="before" selected={*before_after}>{"before"}</option>
|
||||||
|
<option value="after" selected={!*before_after}>{"after"}</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-input relative-to-select" onchange={on_relative_to_change}>
|
||||||
|
<option value="start" selected={matches!(*relative_to, RelativeTo::Start)}>{"event start"}</option>
|
||||||
|
<option value="end" selected={matches!(*relative_to, RelativeTo::End)}>{"event end"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute Trigger Configuration
|
||||||
|
if matches!(*trigger_type, TriggerType::Absolute) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Date and Time"}</label>
|
||||||
|
<div class="absolute-time-inputs">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-input"
|
||||||
|
value={absolute_date.format("%Y-%m-%d").to_string()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-input"
|
||||||
|
value={absolute_time.format("%H:%M").to_string()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-alarm-footer">
|
||||||
|
<button class="cancel-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onclick={on_save_click}>
|
||||||
|
{if props.editing_index.is_some() { "Update" } else { "Add Reminder" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
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::*;
|
||||||
|
// Types are already imported from super::types::*
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::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>
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/components/event_form/alarm_list.rs
Normal file
133
frontend/src/components/event_form/alarm_list.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger};
|
||||||
|
use chrono::Duration;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct AlarmListProps {
|
||||||
|
pub alarms: Vec<VAlarm>,
|
||||||
|
pub on_alarm_delete: Callback<usize>, // Index of alarm to delete
|
||||||
|
pub on_alarm_edit: Callback<usize>, // Index of alarm to edit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AlarmList)]
|
||||||
|
pub fn alarm_list(props: &AlarmListProps) -> Html {
|
||||||
|
if props.alarms.is_empty() {
|
||||||
|
return html! {
|
||||||
|
<div class="alarm-list-empty">
|
||||||
|
<p class="text-muted">{"No reminders set"}</p>
|
||||||
|
<p class="text-small">{"Click 'Add Reminder' to create your first reminder"}</p>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="alarm-list">
|
||||||
|
<h6>{"Configured Reminders"}</h6>
|
||||||
|
<div class="alarm-items">
|
||||||
|
{
|
||||||
|
props.alarms.iter().enumerate().map(|(index, alarm)| {
|
||||||
|
let alarm_description = format_alarm_description(alarm);
|
||||||
|
let action_icon = get_action_icon(&alarm.action);
|
||||||
|
|
||||||
|
let on_delete = {
|
||||||
|
let on_alarm_delete = props.on_alarm_delete.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_alarm_delete.emit(index);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_edit = {
|
||||||
|
let on_alarm_edit = props.on_alarm_edit.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_alarm_edit.emit(index);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div key={index} class="alarm-item">
|
||||||
|
<div class="alarm-content">
|
||||||
|
<span class="alarm-icon">{action_icon}</span>
|
||||||
|
<span class="alarm-description">{alarm_description}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-actions">
|
||||||
|
<button
|
||||||
|
class="alarm-action-btn edit-btn"
|
||||||
|
title="Edit reminder"
|
||||||
|
onclick={on_edit}
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="alarm-action-btn delete-btn"
|
||||||
|
title="Delete reminder"
|
||||||
|
onclick={on_delete}
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format alarm description for display
|
||||||
|
fn format_alarm_description(alarm: &VAlarm) -> String {
|
||||||
|
match &alarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
format_duration_description(duration)
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(datetime) => {
|
||||||
|
format!("At {}", datetime.format("%Y-%m-%d %H:%M UTC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get icon for alarm action - always use bell for consistent notification type
|
||||||
|
fn get_action_icon(_action: &AlarmAction) -> Html {
|
||||||
|
html! { <i class="fas fa-bell"></i> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format duration for human-readable description
|
||||||
|
fn format_duration_description(duration: &Duration) -> String {
|
||||||
|
let minutes = duration.num_minutes();
|
||||||
|
|
||||||
|
if minutes == 0 {
|
||||||
|
return "At event time".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_minutes = minutes.abs();
|
||||||
|
let before_or_after = if minutes < 0 { "before" } else { "after" };
|
||||||
|
|
||||||
|
// Convert to human-readable format
|
||||||
|
if abs_minutes >= 60 * 24 * 7 {
|
||||||
|
let weeks = abs_minutes / (60 * 24 * 7);
|
||||||
|
let remainder = abs_minutes % (60 * 24 * 7);
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} week{} {}", weeks, if weeks == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else if abs_minutes >= 60 * 24 {
|
||||||
|
let days = abs_minutes / (60 * 24);
|
||||||
|
let remainder = abs_minutes % (60 * 24);
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} day{} {}", days, if days == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else if abs_minutes >= 60 {
|
||||||
|
let hours = abs_minutes / 60;
|
||||||
|
let remainder = abs_minutes % 60;
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} hour{} {}", hours, if hours == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{} minute{} {}", abs_minutes, if abs_minutes == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
}
|
||||||
|
}
|
||||||
703
frontend/src/components/event_form/basic_details.rs
Normal file
703
frontend/src/components/event_form/basic_details.rs
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
use super::types::*;
|
||||||
|
// Types are already imported from super::types::*
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Replace with new alarm management UI
|
||||||
|
// let on_reminder_change = {
|
||||||
|
// let data = data.clone();
|
||||||
|
// Callback::from(move |e: Event| {
|
||||||
|
// // Will be replaced with VAlarm management
|
||||||
|
// })
|
||||||
|
// };
|
||||||
|
|
||||||
|
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-row">
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// All Day checkbox above date/time fields
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.all_day}
|
||||||
|
onchange={on_all_day_change}
|
||||||
|
/>
|
||||||
|
{" All Day"}
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/src/components/event_form/mod.rs
Normal file
20
frontend/src/components/event_form/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Event form components module
|
||||||
|
pub mod types;
|
||||||
|
pub mod alarm_list;
|
||||||
|
pub mod add_alarm_modal;
|
||||||
|
pub mod basic_details;
|
||||||
|
pub mod advanced;
|
||||||
|
pub mod people;
|
||||||
|
pub mod categories;
|
||||||
|
pub mod location;
|
||||||
|
pub mod reminders;
|
||||||
|
|
||||||
|
pub use types::*;
|
||||||
|
pub use alarm_list::AlarmList;
|
||||||
|
pub use add_alarm_modal::AddAlarmModal;
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
116
frontend/src/components/event_form/reminders.rs
Normal file
116
frontend/src/components/event_form/reminders.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use super::{types::*, AlarmList, AddAlarmModal};
|
||||||
|
use calendar_models::VAlarm;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(RemindersTab)]
|
||||||
|
pub fn reminders_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
let is_modal_open = use_state(|| false);
|
||||||
|
let editing_index = use_state(|| None::<usize>);
|
||||||
|
|
||||||
|
// Add alarm callback
|
||||||
|
let on_add_alarm = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
editing_index.set(None);
|
||||||
|
is_modal_open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit alarm callback
|
||||||
|
let on_alarm_edit = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |index: usize| {
|
||||||
|
editing_index.set(Some(index));
|
||||||
|
is_modal_open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete alarm callback
|
||||||
|
let on_alarm_delete = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |index: usize| {
|
||||||
|
let mut current_data = (*data).clone();
|
||||||
|
if index < current_data.alarms.len() {
|
||||||
|
current_data.alarms.remove(index);
|
||||||
|
data.set(current_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal callback
|
||||||
|
let on_modal_close = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
is_modal_open.set(false);
|
||||||
|
editing_index.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save alarm callback
|
||||||
|
let on_alarm_save = {
|
||||||
|
let data = data.clone();
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |alarm: VAlarm| {
|
||||||
|
let mut current_data = (*data).clone();
|
||||||
|
|
||||||
|
if let Some(index) = *editing_index {
|
||||||
|
// Edit existing alarm
|
||||||
|
if index < current_data.alarms.len() {
|
||||||
|
current_data.alarms[index] = alarm;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new alarm
|
||||||
|
current_data.alarms.push(alarm);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.set(current_data);
|
||||||
|
is_modal_open.set(false);
|
||||||
|
editing_index.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get initial alarm for editing
|
||||||
|
let initial_alarm = (*editing_index).and_then(|index| {
|
||||||
|
data.alarms.get(index).cloned()
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="alarm-management-header">
|
||||||
|
<h5>{"Event Reminders"}</h5>
|
||||||
|
<button
|
||||||
|
class="add-alarm-button"
|
||||||
|
onclick={on_add_alarm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
{" Add Reminder"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlarmList
|
||||||
|
alarms={data.alarms.clone()}
|
||||||
|
on_alarm_delete={on_alarm_delete}
|
||||||
|
on_alarm_edit={on_alarm_edit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddAlarmModal
|
||||||
|
is_open={*is_modal_open}
|
||||||
|
editing_index={*editing_index}
|
||||||
|
initial_alarm={initial_alarm}
|
||||||
|
on_close={on_modal_close}
|
||||||
|
on_save={on_alarm_save}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
241
frontend/src/components/event_form/types.rs
Normal file
241
frontend/src/components/event_form/types.rs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use chrono::{Local, NaiveDate, NaiveTime};
|
||||||
|
use yew::prelude::*;
|
||||||
|
use calendar_models::VAlarm;
|
||||||
|
|
||||||
|
#[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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditAction is now imported from event_context_menu - this duplicate removed
|
||||||
|
|
||||||
|
#[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/Alarms
|
||||||
|
pub alarms: Vec<VAlarm>,
|
||||||
|
|
||||||
|
// 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<crate::components::EditAction>,
|
||||||
|
pub changed_fields: Vec<String>,
|
||||||
|
pub original_uid: Option<String>, // Set when editing existing events
|
||||||
|
pub occurrence_date: Option<NaiveDate>, // The specific occurrence date being edited
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventCreationData {
|
||||||
|
pub fn to_create_event_params(&self) -> (
|
||||||
|
String, // title
|
||||||
|
String, // description
|
||||||
|
String, // start_date
|
||||||
|
String, // start_time
|
||||||
|
String, // end_date
|
||||||
|
String, // end_time
|
||||||
|
String, // location
|
||||||
|
bool, // all_day
|
||||||
|
String, // status
|
||||||
|
String, // class
|
||||||
|
Option<u8>, // priority
|
||||||
|
String, // organizer
|
||||||
|
String, // attendees
|
||||||
|
String, // categories
|
||||||
|
Vec<VAlarm>, // alarms
|
||||||
|
String, // recurrence
|
||||||
|
Vec<bool>, // recurrence_days
|
||||||
|
u32, // recurrence_interval
|
||||||
|
Option<u32>, // recurrence_count
|
||||||
|
Option<String>, // recurrence_until
|
||||||
|
Option<String>, // calendar_path
|
||||||
|
String, // timezone
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Use local date/times and timezone - no UTC conversion
|
||||||
|
let effective_end_date = if self.all_day {
|
||||||
|
// For all-day events, add one day to convert from inclusive to exclusive end date
|
||||||
|
// (iCalendar spec requires exclusive end dates for all-day events)
|
||||||
|
self.end_date + chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
self.end_date
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the local timezone
|
||||||
|
let timezone = {
|
||||||
|
use js_sys::Date;
|
||||||
|
let date = Date::new_0();
|
||||||
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start_date, start_time, end_date, end_time) = (
|
||||||
|
self.start_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.start_time.format("%H:%M").to_string(),
|
||||||
|
effective_end_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.end_time.format("%H:%M").to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
self.title.clone(),
|
||||||
|
self.description.clone(),
|
||||||
|
start_date,
|
||||||
|
start_time,
|
||||||
|
end_date,
|
||||||
|
end_time,
|
||||||
|
self.location.clone(),
|
||||||
|
self.all_day,
|
||||||
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
|
format!("{:?}", self.class).to_uppercase(),
|
||||||
|
self.priority,
|
||||||
|
self.organizer.clone(),
|
||||||
|
self.attendees.clone(),
|
||||||
|
self.categories.clone(),
|
||||||
|
self.alarms.clone(),
|
||||||
|
format!("{:?}", self.recurrence),
|
||||||
|
self.recurrence_days.clone(),
|
||||||
|
self.recurrence_interval,
|
||||||
|
self.recurrence_count,
|
||||||
|
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
self.selected_calendar.clone(),
|
||||||
|
timezone,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
alarms: Vec::new(),
|
||||||
|
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![],
|
||||||
|
original_uid: None,
|
||||||
|
occurrence_date: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common props for all tab components
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct TabProps {
|
||||||
|
pub data: UseStateHandle<EventCreationData>,
|
||||||
|
pub available_calendars: Vec<CalendarInfo>,
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
use crate::models::ical::VEvent;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
pub event: Option<CalendarEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
on_close.emit(());
|
on_close.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let backdrop_click = {
|
let backdrop_click = {
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
@@ -39,7 +38,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
<strong>{"Title:"}</strong>
|
<strong>{"Title:"}</strong>
|
||||||
<span>{event.get_title()}</span>
|
<span>{event.get_title()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref description) = event.description {
|
if let Some(ref description) = event.description {
|
||||||
html! {
|
html! {
|
||||||
@@ -52,30 +51,30 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Start:"}</strong>
|
<strong>{"Start:"}</strong>
|
||||||
<span>{format_datetime(&event.start, event.all_day)}</span>
|
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref end) = event.end {
|
if let Some(ref end) = event.dtend {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"End:"}</strong>
|
<strong>{"End:"}</strong>
|
||||||
<span>{format_datetime(end, event.all_day)}</span>
|
<span>{format_datetime_end(end, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"All Day:"}</strong>
|
<strong>{"All Day:"}</strong>
|
||||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref location) = event.location {
|
if let Some(ref location) = event.location {
|
||||||
html! {
|
html! {
|
||||||
@@ -88,48 +87,48 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Status:"}</strong>
|
<strong>{"Status:"}</strong>
|
||||||
<span>{event.get_status_display()}</span>
|
<span>{event.get_status_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Privacy:"}</strong>
|
<strong>{"Privacy:"}</strong>
|
||||||
<span>{event.get_class_display()}</span>
|
<span>{event.get_class_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Priority:"}</strong>
|
<strong>{"Priority:"}</strong>
|
||||||
<span>{event.get_priority_display()}</span>
|
<span>{event.get_priority_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref organizer) = event.organizer {
|
if let Some(ref organizer) = event.organizer {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Organizer:"}</strong>
|
<strong>{"Organizer:"}</strong>
|
||||||
<span>{organizer}</span>
|
<span>{organizer.cal_address.clone()}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.attendees.is_empty() {
|
if !event.attendees.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Attendees:"}</strong>
|
<strong>{"Attendees:"}</strong>
|
||||||
<span>{event.attendees.join(", ")}</span>
|
<span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -142,9 +141,9 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref recurrence) = event.recurrence_rule {
|
if let Some(ref recurrence) = event.rrule {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Repeats:"}</strong>
|
<strong>{"Repeats:"}</strong>
|
||||||
@@ -160,13 +159,13 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.reminders.is_empty() {
|
if !event.alarms.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Reminders:"}</strong>
|
<strong>{"Reminders:"}</strong>
|
||||||
<span>{format_reminders(&event.reminders)}</span>
|
<span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -178,7 +177,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref created) = event.created {
|
if let Some(ref created) = event.created {
|
||||||
html! {
|
html! {
|
||||||
@@ -191,7 +190,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref modified) = event.last_modified {
|
if let Some(ref modified) = event.last_modified {
|
||||||
html! {
|
html! {
|
||||||
@@ -213,7 +212,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
fn format_datetime(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
|
||||||
if all_day {
|
if all_day {
|
||||||
dt.format("%B %d, %Y").to_string()
|
dt.format("%B %d, %Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -221,6 +220,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_datetime_end(dt: &chrono::NaiveDateTime, 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 {
|
fn format_recurrence_rule(rrule: &str) -> String {
|
||||||
// Basic parsing of RRULE to display user-friendly text
|
// Basic parsing of RRULE to display user-friendly text
|
||||||
if rrule.contains("FREQ=DAILY") {
|
if rrule.contains("FREQ=DAILY") {
|
||||||
@@ -236,54 +246,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_reminders(reminders: &[EventReminder]) -> String {
|
|
||||||
if reminders.is_empty() {
|
|
||||||
return "None".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let formatted_reminders: Vec<String> = reminders
|
|
||||||
.iter()
|
|
||||||
.map(|reminder| {
|
|
||||||
let time_text = if reminder.minutes_before == 0 {
|
|
||||||
"At event time".to_string()
|
|
||||||
} else if reminder.minutes_before < 60 {
|
|
||||||
format!("{} minutes before", reminder.minutes_before)
|
|
||||||
} else if reminder.minutes_before == 60 {
|
|
||||||
"1 hour before".to_string()
|
|
||||||
} else if reminder.minutes_before % 60 == 0 {
|
|
||||||
format!("{} hours before", reminder.minutes_before / 60)
|
|
||||||
} else if reminder.minutes_before < 1440 {
|
|
||||||
let hours = reminder.minutes_before / 60;
|
|
||||||
let minutes = reminder.minutes_before % 60;
|
|
||||||
format!("{}h {}m before", hours, minutes)
|
|
||||||
} else if reminder.minutes_before == 1440 {
|
|
||||||
"1 day before".to_string()
|
|
||||||
} else if reminder.minutes_before % 1440 == 0 {
|
|
||||||
format!("{} days before", reminder.minutes_before / 1440)
|
|
||||||
} else {
|
|
||||||
let days = reminder.minutes_before / 1440;
|
|
||||||
let remaining_minutes = reminder.minutes_before % 1440;
|
|
||||||
let hours = remaining_minutes / 60;
|
|
||||||
let minutes = remaining_minutes % 60;
|
|
||||||
if hours > 0 {
|
|
||||||
format!("{}d {}h before", days, hours)
|
|
||||||
} else if minutes > 0 {
|
|
||||||
format!("{}d {}m before", days, minutes)
|
|
||||||
} else {
|
|
||||||
format!("{} days before", days)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let action_text = match reminder.action {
|
|
||||||
ReminderAction::Display => "notification",
|
|
||||||
ReminderAction::Email => "email",
|
|
||||||
ReminderAction::Audio => "sound",
|
|
||||||
};
|
|
||||||
|
|
||||||
format!("{} ({})", time_text, action_text)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
formatted_reminders.join(", ")
|
|
||||||
}
|
|
||||||
222
frontend/src/components/external_calendar_modal.rs
Normal file
222
frontend/src/components/external_calendar_modal.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::services::calendar_service::CalendarService;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ExternalCalendarModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_success: Callback<i32>, // Pass the newly created calendar ID
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ExternalCalendarModal)]
|
||||||
|
pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html {
|
||||||
|
let name_ref = use_node_ref();
|
||||||
|
let url_ref = use_node_ref();
|
||||||
|
let color_ref = use_node_ref();
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
let error_message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let name_ref = name_ref.clone();
|
||||||
|
let url_ref = url_ref.clone();
|
||||||
|
let color_ref = color_ref.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let on_success = props.on_success.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = name_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let url = url_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let color = color_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_else(|| "#4285f4".to_string());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
error_message.set(Some("Calendar URL is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_message.set(None);
|
||||||
|
is_loading.set(true);
|
||||||
|
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_success = on_success.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
|
Ok(new_calendar) => {
|
||||||
|
is_loading.set(false);
|
||||||
|
on_success.emit(new_calendar.id);
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
is_loading.set(false);
|
||||||
|
error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel_clone = on_cancel.clone();
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_cancel_clone}>
|
||||||
|
<div class="external-calendar-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Add External Calendar"}</h3>
|
||||||
|
<button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).as_ref() {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-help" style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #495057;">{"Setting up External Calendars"}</h4>
|
||||||
|
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; line-height: 1.4;">
|
||||||
|
{"Currently tested with Outlook 365 and Google Calendar. To get your calendar link:"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<strong style="font-size: 0.8rem; color: #495057;">{"Outlook 365:"}</strong>
|
||||||
|
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||||
|
<li>{"Go to Outlook Settings"}</li>
|
||||||
|
<li>{"Navigate to Calendar → Shared Calendars"}</li>
|
||||||
|
<li>{"Click \"Publish a calendar\""}</li>
|
||||||
|
<li>{"Select your calendar and choose \"Can view all details\""}</li>
|
||||||
|
<li>{"Copy the ICS link and paste it below"}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong style="font-size: 0.8rem; color: #495057;">{"Google Calendar:"}</strong>
|
||||||
|
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||||
|
<li>{"Hover over your calendar name in the left sidebar"}</li>
|
||||||
|
<li>{"Click the three dots that appear"}</li>
|
||||||
|
<li>{"Select \"Settings and sharing\""}</li>
|
||||||
|
<li>{"Scroll to \"Integrate calendar\""}</li>
|
||||||
|
<li>{"Copy the \"Public address in iCal format\" link"}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
ref={name_ref}
|
||||||
|
id="external-calendar-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="My External Calendar"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-url">{"ICS URL"}</label>
|
||||||
|
<input
|
||||||
|
ref={url_ref}
|
||||||
|
id="external-calendar-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
<small class="form-help">
|
||||||
|
{"Enter the public ICS URL for the calendar you want to add"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-color">{"Color"}</label>
|
||||||
|
<input
|
||||||
|
ref={color_ref}
|
||||||
|
id="external-calendar-color"
|
||||||
|
type="color"
|
||||||
|
value="#4285f4"
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={on_cancel}
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Adding..."
|
||||||
|
} else {
|
||||||
|
"Add Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
351
frontend/src/components/login.rs
Normal file
351
frontend/src/components/login.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct LoginProps {
|
||||||
|
pub on_login: Callback<String>, // Callback with JWT token
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn Login(props: &LoginProps) -> Html {
|
||||||
|
// Load remembered values from LocalStorage on mount
|
||||||
|
let server_url = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||||
|
});
|
||||||
|
let username = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||||
|
});
|
||||||
|
let password = use_state(String::new);
|
||||||
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
|
// Remember checkboxes state - default to checked
|
||||||
|
let remember_server = use_state(|| true);
|
||||||
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
|
// Password visibility toggle
|
||||||
|
let show_password = use_state(|| false);
|
||||||
|
|
||||||
|
let server_url_ref = use_node_ref();
|
||||||
|
let username_ref = use_node_ref();
|
||||||
|
let password_ref = use_node_ref();
|
||||||
|
|
||||||
|
let on_server_url_change = {
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let new_value = target.value();
|
||||||
|
server_url.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_server {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", new_value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_username_change = {
|
||||||
|
let username = username.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let new_value = target.value();
|
||||||
|
username.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_username {
|
||||||
|
let _ = LocalStorage::set("remembered_username", new_value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_password_change = {
|
||||||
|
let password = password.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
password.set(target.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remember_server_change = {
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_server.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_server_url");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remember_username_change = {
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_username.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_username", (*username).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_username");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_toggle_password_visibility = {
|
||||||
|
let show_password = show_password.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_password.set(!*show_password);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
let password = password.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
|
let on_login = props.on_login.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let server_url = (*server_url).clone();
|
||||||
|
let username = (*username).clone();
|
||||||
|
let password = (*password).clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server_value = *remember_server;
|
||||||
|
let remember_username_value = *remember_username;
|
||||||
|
let on_login = on_login.clone();
|
||||||
|
|
||||||
|
// Basic client-side validation
|
||||||
|
if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() {
|
||||||
|
error_message.set(Some("Please fill in all fields".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(true);
|
||||||
|
error_message.set(None);
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||||
|
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||||
|
Ok((token, session_token, credentials, preferences)) => {
|
||||||
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
|
// Store token and credentials in local storage
|
||||||
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||||
|
error_message
|
||||||
|
.set(Some("Failed to store session token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
|
||||||
|
error_message.set(Some("Failed to store credentials".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store preferences from database
|
||||||
|
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||||
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save server URL and username to LocalStorage if remember checkboxes are checked
|
||||||
|
if remember_server_value {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
|
||||||
|
}
|
||||||
|
if remember_username_value {
|
||||||
|
let _ = LocalStorage::set("remembered_username", username.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
is_loading.set(false);
|
||||||
|
on_login.emit(token);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||||
|
// Clear any existing invalid tokens
|
||||||
|
let _ = LocalStorage::delete("auth_token");
|
||||||
|
let _ = LocalStorage::delete("session_token");
|
||||||
|
let _ = LocalStorage::delete("caldav_credentials");
|
||||||
|
error_message.set(Some(err));
|
||||||
|
is_loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-form">
|
||||||
|
<h2>{"Sign In to CalDAV"}</h2>
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||||
|
<div class="input-with-checkbox">
|
||||||
|
<input
|
||||||
|
ref={server_url_ref}
|
||||||
|
type="text"
|
||||||
|
id="server_url"
|
||||||
|
placeholder="https://your-caldav-server.com/dav/"
|
||||||
|
value={(*server_url).clone()}
|
||||||
|
onchange={on_server_url_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="1"
|
||||||
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_server">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
tabindex="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">{"Username"}</label>
|
||||||
|
<div class="input-with-checkbox">
|
||||||
|
<input
|
||||||
|
ref={username_ref}
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="2"
|
||||||
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_username">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
tabindex="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">{"Password"}</label>
|
||||||
|
<div class="password-input-container">
|
||||||
|
<input
|
||||||
|
ref={password_ref}
|
||||||
|
type={if *show_password { "text" } else { "password" }}
|
||||||
|
id="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={(*password).clone()}
|
||||||
|
onchange={on_password_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="password-toggle-btn"
|
||||||
|
onclick={on_toggle_password_visibility}
|
||||||
|
tabindex="6"
|
||||||
|
title={if *show_password { "Hide password" } else { "Show password" }}
|
||||||
|
>
|
||||||
|
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).clone() {
|
||||||
|
html! { <div class="error-message">{error}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" disabled={*is_loading} class="login-button">
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Signing in..."
|
||||||
|
} else {
|
||||||
|
"Sign In"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>{"Enter your CalDAV server credentials to connect to your calendar"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform login using the CalDAV auth service
|
||||||
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||||
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
|
use serde_json;
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||||
|
|
||||||
|
let auth_service = AuthService::new();
|
||||||
|
let request = CalDAVLoginRequest {
|
||||||
|
server_url: server_url.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
password: password.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
|
|
||||||
|
match auth_service.login(request).await {
|
||||||
|
Ok(response) => {
|
||||||
|
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||||
|
// Create credentials object to store
|
||||||
|
let credentials = serde_json::json!({
|
||||||
|
"server_url": server_url,
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract preferences as JSON
|
||||||
|
let preferences = serde_json::json!({
|
||||||
|
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||||
|
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||||
|
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||||
|
"calendar_theme": response.preferences.calendar_theme,
|
||||||
|
"calendar_colors": response.preferences.calendar_colors,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/src/components/mobile_warning_modal.rs
Normal file
96
frontend/src/components/mobile_warning_modal.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::window;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MobileWarningModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MobileWarningModal)]
|
||||||
|
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-overlay") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content mobile-warning-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Desktop Application"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mobile-warning-icon">
|
||||||
|
{"💻"}
|
||||||
|
</div>
|
||||||
|
<p class="mobile-warning-title">
|
||||||
|
{"This calendar application is designed for desktop usage"}
|
||||||
|
</p>
|
||||||
|
<p class="mobile-warning-description">
|
||||||
|
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
|
||||||
|
</p>
|
||||||
|
<ul class="mobile-warning-apps">
|
||||||
|
<li>
|
||||||
|
<strong>{"iOS:"}</strong>
|
||||||
|
{" Calendar (built-in), Calendars 5, Fantastical"}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{"Android:"}</strong>
|
||||||
|
{" Google Calendar, DAVx5, CalDAV Sync"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mobile-warning-note">
|
||||||
|
{"These apps can sync with the same CalDAV server you're using here."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="continue-anyway-button" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>
|
||||||
|
{"Continue Anyway"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to detect mobile devices
|
||||||
|
pub fn is_mobile_device() -> bool {
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let navigator = window.navigator();
|
||||||
|
let user_agent = navigator.user_agent().unwrap_or_default();
|
||||||
|
let user_agent = user_agent.to_lowercase();
|
||||||
|
|
||||||
|
// Check for mobile device indicators
|
||||||
|
user_agent.contains("mobile")
|
||||||
|
|| user_agent.contains("android")
|
||||||
|
|| user_agent.contains("iphone")
|
||||||
|
|| user_agent.contains("ipad")
|
||||||
|
|| user_agent.contains("ipod")
|
||||||
|
|| user_agent.contains("blackberry")
|
||||||
|
|| user_agent.contains("webos")
|
||||||
|
|| user_agent.contains("opera mini")
|
||||||
|
|| user_agent.contains("windows phone")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/src/components/mod.rs
Normal file
42
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
pub mod calendar;
|
||||||
|
pub mod calendar_context_menu;
|
||||||
|
pub mod calendar_management_modal;
|
||||||
|
pub mod calendar_header;
|
||||||
|
pub mod calendar_list_item;
|
||||||
|
pub mod color_editor_modal;
|
||||||
|
pub mod context_menu;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
pub mod create_event_modal;
|
||||||
|
pub mod event_context_menu;
|
||||||
|
pub mod event_form;
|
||||||
|
pub mod event_modal;
|
||||||
|
pub mod external_calendar_modal;
|
||||||
|
pub mod login;
|
||||||
|
pub mod mobile_warning_modal;
|
||||||
|
pub mod month_view;
|
||||||
|
pub mod print_preview_modal;
|
||||||
|
pub mod recurring_edit_modal;
|
||||||
|
pub mod route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
|
pub use calendar::Calendar;
|
||||||
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
|
pub use calendar_management_modal::CalendarManagementModal;
|
||||||
|
pub use calendar_header::CalendarHeader;
|
||||||
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use color_editor_modal::ColorEditorModal;
|
||||||
|
pub use context_menu::ContextMenu;
|
||||||
|
pub use create_event_modal::CreateEventModal;
|
||||||
|
// Re-export event form types for backwards compatibility
|
||||||
|
pub use event_form::EventCreationData;
|
||||||
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
|
pub use event_modal::EventModal;
|
||||||
|
pub use login::Login;
|
||||||
|
pub use mobile_warning_modal::MobileWarningModal;
|
||||||
|
pub use month_view::MonthView;
|
||||||
|
pub use print_preview_modal::PrintPreviewModal;
|
||||||
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
|
pub use route_handler::RouteHandler;
|
||||||
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
326
frontend/src/components/month_view.rs
Normal file
326
frontend/src/components/month_view.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
|
use web_sys::window;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MonthViewProps {
|
||||||
|
pub current_month: NaiveDate,
|
||||||
|
pub today: NaiveDate,
|
||||||
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||||
|
pub on_event_click: Callback<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub refreshing_event_uid: Option<String>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub selected_date: Option<NaiveDate>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_day_select: Option<Callback<NaiveDate>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MonthView)]
|
||||||
|
pub fn month_view(props: &MonthViewProps) -> Html {
|
||||||
|
let max_events_per_day = use_state(|| 4); // Default to 4 events max
|
||||||
|
let first_day_of_month = props.current_month.with_day(1).unwrap();
|
||||||
|
let days_in_month = get_days_in_month(props.current_month);
|
||||||
|
let first_weekday = first_day_of_month.weekday();
|
||||||
|
let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday);
|
||||||
|
|
||||||
|
// Calculate maximum events that can fit based on available height
|
||||||
|
let calculate_max_events = {
|
||||||
|
let max_events_per_day = max_events_per_day.clone();
|
||||||
|
move || {
|
||||||
|
// Since we're using CSS Grid with equal row heights,
|
||||||
|
// we can estimate based on typical calendar dimensions
|
||||||
|
// Typical calendar height is around 600-800px for 6 rows
|
||||||
|
// Each row gets ~100-133px, minus day number and padding leaves ~70-100px
|
||||||
|
// Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator
|
||||||
|
max_events_per_day.set(3);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup resize handler and initial calculation
|
||||||
|
{
|
||||||
|
let calculate_max_events = calculate_max_events.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
let calculate_max_events_clone = calculate_max_events.clone();
|
||||||
|
|
||||||
|
// Initial calculation with a slight delay to ensure DOM is ready
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||||
|
calculate_max_events_clone();
|
||||||
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
timeout_closure.as_ref().unchecked_ref(),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
timeout_closure.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup resize listener
|
||||||
|
let resize_closure = Closure::wrap(Box::new(move || {
|
||||||
|
calculate_max_events();
|
||||||
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
resize_closure.forget(); // Keep the closure alive
|
||||||
|
}
|
||||||
|
|
||||||
|
|| {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get calendar color for an event
|
||||||
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
|
// Check external calendars first (path format: "external_{id}")
|
||||||
|
if calendar_path.starts_with("external_") {
|
||||||
|
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||||
|
if let Some(external_calendar) = props.external_calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| cal.id == id_str)
|
||||||
|
{
|
||||||
|
return external_calendar.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check regular calendars
|
||||||
|
else if let Some(user_info) = &props.user_info {
|
||||||
|
if let Some(calendar) = user_info
|
||||||
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
|
return calendar.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#3B82F6".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let weeks_needed = calculate_minimum_weeks_needed(first_weekday, days_in_month);
|
||||||
|
|
||||||
|
// Use calculated weeks with height-based container sizing for proper fit
|
||||||
|
let dynamic_style = format!("grid-template-rows: var(--weekday-header-height, 50px) repeat({}, 1fr);", weeks_needed);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="calendar-grid" style={dynamic_style}>
|
||||||
|
// Weekday headers
|
||||||
|
<div class="weekday-header">{"Sun"}</div>
|
||||||
|
<div class="weekday-header">{"Mon"}</div>
|
||||||
|
<div class="weekday-header">{"Tue"}</div>
|
||||||
|
<div class="weekday-header">{"Wed"}</div>
|
||||||
|
<div class="weekday-header">{"Thu"}</div>
|
||||||
|
<div class="weekday-header">{"Fri"}</div>
|
||||||
|
<div class="weekday-header">{"Sat"}</div>
|
||||||
|
|
||||||
|
// Days from previous month (grayed out)
|
||||||
|
{
|
||||||
|
days_from_prev_month.iter().map(|day| {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-day prev-month">{*day}</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days of the current month
|
||||||
|
{
|
||||||
|
(1..=days_in_month).map(|day| {
|
||||||
|
let date = props.current_month.with_day(day).unwrap();
|
||||||
|
let is_today = date == props.today;
|
||||||
|
let is_selected = props.selected_date == Some(date);
|
||||||
|
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
// Calculate visible events and overflow
|
||||||
|
let max_events = *max_events_per_day as usize;
|
||||||
|
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||||
|
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class={classes!(
|
||||||
|
"calendar-day",
|
||||||
|
if is_today { Some("today") } else { None },
|
||||||
|
if is_selected { Some("selected") } else { None }
|
||||||
|
)}
|
||||||
|
onclick={
|
||||||
|
if let Some(callback) = &props.on_day_select {
|
||||||
|
let callback = callback.clone();
|
||||||
|
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
|
e.stop_propagation(); // Prevent other handlers
|
||||||
|
callback.emit(date);
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oncontextmenu={
|
||||||
|
if let Some(callback) = &props.on_calendar_context_menu {
|
||||||
|
let callback = callback.clone();
|
||||||
|
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
callback.emit((e, date));
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="day-number">{day}</div>
|
||||||
|
<div class="day-events">
|
||||||
|
{
|
||||||
|
visible_events.iter().map(|event| {
|
||||||
|
let event_color = get_event_color(event);
|
||||||
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
|
let onclick = {
|
||||||
|
let on_event_click = props.on_event_click.clone();
|
||||||
|
let event = (*event).clone();
|
||||||
|
Callback::from(move |_: web_sys::MouseEvent| {
|
||||||
|
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();
|
||||||
|
callback.emit((e, event.clone()));
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||||
|
style={format!("background-color: {}", event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
|
{onclick}
|
||||||
|
{oncontextmenu}
|
||||||
|
>
|
||||||
|
<span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span>
|
||||||
|
if !event.alarms.is_empty() {
|
||||||
|
<i class="fas fa-bell event-reminder-icon" title="Has reminders"></i>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if hidden_count > 0 {
|
||||||
|
html! {
|
||||||
|
<div class="more-events-indicator">
|
||||||
|
{format!("+{} more", hidden_count)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
|
||||||
|
{ render_next_month_days(days_from_prev_month.len(), days_in_month, calculate_minimum_weeks_needed(first_weekday, days_in_month)) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_minimum_weeks_needed(first_weekday: Weekday, days_in_month: u32) -> u32 {
|
||||||
|
let days_before = match first_weekday {
|
||||||
|
Weekday::Sun => 0,
|
||||||
|
Weekday::Mon => 1,
|
||||||
|
Weekday::Tue => 2,
|
||||||
|
Weekday::Wed => 3,
|
||||||
|
Weekday::Thu => 4,
|
||||||
|
Weekday::Fri => 5,
|
||||||
|
Weekday::Sat => 6,
|
||||||
|
};
|
||||||
|
let total_days_needed = days_before + days_in_month;
|
||||||
|
(total_days_needed + 6) / 7 // Round up to get number of weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32, weeks_needed: u32) -> Html {
|
||||||
|
let total_slots = (weeks_needed * 7) as usize; // Dynamic based on weeks needed
|
||||||
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
(1..=remaining_slots)
|
||||||
|
.map(|day| {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-day next-month">{day}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
|
NaiveDate::from_ymd_opt(
|
||||||
|
if date.month() == 12 {
|
||||||
|
date.year() + 1
|
||||||
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.pred_opt()
|
||||||
|
.unwrap()
|
||||||
|
.day()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
|
||||||
|
let days_before = match first_weekday {
|
||||||
|
Weekday::Sun => 0,
|
||||||
|
Weekday::Mon => 1,
|
||||||
|
Weekday::Tue => 2,
|
||||||
|
Weekday::Wed => 3,
|
||||||
|
Weekday::Thu => 4,
|
||||||
|
Weekday::Fri => 5,
|
||||||
|
Weekday::Sat => 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
if days_before == 0 {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
let prev_month = if current_month.month() == 1 {
|
||||||
|
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
|
||||||
|
} else {
|
||||||
|
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_month_days = get_days_in_month(prev_month);
|
||||||
|
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
377
frontend/src/components/print_preview_modal.rs
Normal file
377
frontend/src/components/print_preview_modal.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use crate::components::{ViewMode, WeekView, MonthView, CalendarHeader};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct PrintPreviewModalProps {
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub view_mode: ViewMode,
|
||||||
|
pub current_date: NaiveDate,
|
||||||
|
pub selected_date: NaiveDate,
|
||||||
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub time_increment: u32,
|
||||||
|
pub today: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
||||||
|
let start_hour = use_state(|| 6u32);
|
||||||
|
let end_hour = use_state(|| 22u32);
|
||||||
|
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
|
||||||
|
|
||||||
|
let close_modal = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if e.target() == e.current_target() {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_start_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour < *end_hour {
|
||||||
|
start_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_end_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour > *start_hour && hour <= 24 {
|
||||||
|
end_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let format_hour = |hour: u32| -> String {
|
||||||
|
if hour == 0 {
|
||||||
|
"12 AM".to_string()
|
||||||
|
} else if hour < 12 {
|
||||||
|
format!("{} AM", hour)
|
||||||
|
} else if hour == 12 {
|
||||||
|
"12 PM".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} PM", hour - 12)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate dynamic base unit for print preview
|
||||||
|
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
|
||||||
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
|
let calendar_header_height = 80.0; // Calendar header height in print preview
|
||||||
|
let week_header_height = 50.0; // Fixed week header height in print preview
|
||||||
|
let header_border = 2.0; // Week header bottom border (2px solid)
|
||||||
|
let container_spacing = 8.0; // Additional container spacing/margins
|
||||||
|
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
|
||||||
|
let available_height = 720.0 - total_overhead; // Available for time content
|
||||||
|
let base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let pixels_per_hour = base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
(base_unit, pixels_per_hour, available_height)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate print dimensions for the current hour range
|
||||||
|
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
|
||||||
|
|
||||||
|
// Effect to update print copy whenever modal renders or content changes
|
||||||
|
{
|
||||||
|
let start_hour = *start_hour;
|
||||||
|
let end_hour = *end_hour;
|
||||||
|
let time_increment = props.time_increment;
|
||||||
|
let original_base_unit = base_unit;
|
||||||
|
use_effect(move || {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
// Set CSS variables on document root
|
||||||
|
if let Some(document_element) = document.document_element() {
|
||||||
|
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let style = html_element.style();
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy content from print-preview-content to the hidden print-preview-copy div
|
||||||
|
let copy_content = move || {
|
||||||
|
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
|
||||||
|
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
|
||||||
|
// Clone the preview content
|
||||||
|
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
|
||||||
|
// Clear the print copy div and add the cloned content
|
||||||
|
print_copy.set_inner_html("");
|
||||||
|
let _ = print_copy.append_child(&content_clone);
|
||||||
|
|
||||||
|
// Get the actual rendered height of the print copy div and recalculate base-unit
|
||||||
|
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
// Temporarily make visible to measure height, then hide again
|
||||||
|
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
|
||||||
|
let _ = print_copy_html.style().set_property("display", "block");
|
||||||
|
let _ = print_copy_html.style().set_property("visibility", "hidden");
|
||||||
|
let _ = print_copy_html.style().set_property("position", "absolute");
|
||||||
|
let _ = print_copy_html.style().set_property("top", "-9999px");
|
||||||
|
|
||||||
|
// Now measure the height
|
||||||
|
let actual_height = print_copy_html.client_height() as f64;
|
||||||
|
|
||||||
|
// Restore original display
|
||||||
|
let _ = print_copy_html.style().set_property("display", &original_display);
|
||||||
|
let _ = print_copy_html.style().remove_property("visibility");
|
||||||
|
let _ = print_copy_html.style().remove_property("position");
|
||||||
|
let _ = print_copy_html.style().remove_property("top");
|
||||||
|
|
||||||
|
// Recalculate base-unit and pixels-per-hour based on actual height
|
||||||
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
|
let calendar_header_height = 80.0; // Calendar header height
|
||||||
|
let week_header_height = 50.0; // Week header height
|
||||||
|
let header_border = 2.0;
|
||||||
|
let container_spacing = 8.0;
|
||||||
|
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
|
||||||
|
let available_height = actual_height - total_overhead;
|
||||||
|
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
|
||||||
|
// Set CSS variables with recalculated values
|
||||||
|
let style = print_copy_html.style();
|
||||||
|
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
|
||||||
|
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Copy data attributes
|
||||||
|
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
|
||||||
|
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Recalculate event positions using the new base-unit
|
||||||
|
let events = print_copy.query_selector_all(".week-event").unwrap();
|
||||||
|
let scale_factor = actual_base_unit / original_base_unit;
|
||||||
|
|
||||||
|
for i in 0..events.length() {
|
||||||
|
if let Some(event_element) = events.get(i) {
|
||||||
|
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let event_style = event_html.style();
|
||||||
|
|
||||||
|
// Get current positioning values and recalculate
|
||||||
|
if let Ok(current_top) = event_style.get_property_value("top") {
|
||||||
|
if current_top.ends_with("px") {
|
||||||
|
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
|
||||||
|
let new_top = top_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(current_height) = event_style.get_property_value("height") {
|
||||||
|
if current_height.ends_with("px") {
|
||||||
|
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
|
||||||
|
let new_height = height_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
|
||||||
|
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy content immediately
|
||||||
|
copy_content();
|
||||||
|
|
||||||
|
// Also set up a small delay to catch any async rendering
|
||||||
|
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
copy_callback.as_ref().unchecked_ref(),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
copy_callback.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_print = {
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
// Print copy is already updated by the use_effect, just trigger print
|
||||||
|
let _ = window.print();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
|
||||||
|
<div class="modal-content print-preview-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Print Preview"}</h3>
|
||||||
|
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body print-preview-body">
|
||||||
|
<div class="print-preview-controls">
|
||||||
|
{
|
||||||
|
if props.view_mode == ViewMode::Week {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="start-hour">{"Start Hour:"}</label>
|
||||||
|
<select id="start-hour" onchange={on_start_hour_change}>
|
||||||
|
{
|
||||||
|
(0..24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *start_hour}>
|
||||||
|
{format_hour(hour)}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="end-hour">{"End Hour:"}</label>
|
||||||
|
<select id="end-hour" onchange={on_end_hour_change}>
|
||||||
|
{
|
||||||
|
(1..=24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *end_hour}>
|
||||||
|
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hour-range-info">
|
||||||
|
{format!("Will print from {} to {}",
|
||||||
|
format_hour(*start_hour),
|
||||||
|
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<div class="month-info">
|
||||||
|
{"Will print entire month view"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="zoom-display-info">
|
||||||
|
<label>{"Zoom: "}</label>
|
||||||
|
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
|
||||||
|
<span class="zoom-hint">{"(scroll to zoom)"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
|
||||||
|
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-preview-display" onwheel={{
|
||||||
|
let zoom_level = zoom_level.clone();
|
||||||
|
Callback::from(move |e: WheelEvent| {
|
||||||
|
e.prevent_default(); // Prevent page scroll
|
||||||
|
let delta_y = e.delta_y();
|
||||||
|
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||||
|
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
|
||||||
|
zoom_level.set(new_zoom);
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<div class="print-preview-paper"
|
||||||
|
data-start-hour={start_hour.to_string()}
|
||||||
|
data-end-hour={end_hour.to_string()}
|
||||||
|
style={format!(
|
||||||
|
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
|
||||||
|
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
||||||
|
)}>
|
||||||
|
<div class="print-preview-content">
|
||||||
|
<div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
|
<CalendarHeader
|
||||||
|
current_date={props.current_date}
|
||||||
|
view_mode={props.view_mode.clone()}
|
||||||
|
on_prev={Callback::from(|_: MouseEvent| {})}
|
||||||
|
on_next={Callback::from(|_: MouseEvent| {})}
|
||||||
|
on_today={Callback::from(|_: MouseEvent| {})}
|
||||||
|
time_increment={Some(props.time_increment)}
|
||||||
|
on_time_increment_toggle={None::<Callback<MouseEvent>>}
|
||||||
|
on_print={None::<Callback<MouseEvent>>}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
match props.view_mode {
|
||||||
|
ViewMode::Week => html! {
|
||||||
|
<WeekView
|
||||||
|
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_date={props.current_date}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={props.time_increment}
|
||||||
|
print_mode={true}
|
||||||
|
print_pixels_per_hour={Some(pixels_per_hour)}
|
||||||
|
print_start_hour={Some(*start_hour)}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
ViewMode::Month => html! {
|
||||||
|
<MonthView
|
||||||
|
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_month={props.current_date}
|
||||||
|
selected_date={Some(props.selected_date)}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_day_select={None::<Callback<NaiveDate>>}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
98
frontend/src/components/recurring_edit_modal.rs
Normal file
98
frontend/src/components/recurring_edit_modal.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum RecurringEditAction {
|
||||||
|
ThisEvent,
|
||||||
|
FutureEvents,
|
||||||
|
AllEvents,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct RecurringEditModalProps {
|
||||||
|
pub show: bool,
|
||||||
|
pub event: VEvent,
|
||||||
|
pub new_start: NaiveDateTime,
|
||||||
|
pub new_end: NaiveDateTime,
|
||||||
|
pub on_choice: Callback<RecurringEditAction>,
|
||||||
|
pub on_cancel: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(RecurringEditModal)]
|
||||||
|
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
||||||
|
if !props.show {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
|
let on_this_event = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_future_events = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_all_events = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::AllEvents);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel = {
|
||||||
|
let on_cancel = props.on_cancel.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_cancel.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-content recurring-edit-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Edit Recurring Event"}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||||
|
<p>{"How would you like to apply this change?"}</p>
|
||||||
|
|
||||||
|
<div class="recurring-edit-options">
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||||
|
<div class="option-title">{"This event only"}</div>
|
||||||
|
<div class="option-description">{"Change only this occurrence"}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||||
|
<div class="option-title">{"This and future events"}</div>
|
||||||
|
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||||
|
<div class="option-title">{"All events in series"}</div>
|
||||||
|
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick={on_cancel}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
167
frontend/src/components/route_handler.rs
Normal file
167
frontend/src/components/route_handler.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
|
pub enum Route {
|
||||||
|
#[at("/")]
|
||||||
|
Home,
|
||||||
|
#[at("/login")]
|
||||||
|
Login,
|
||||||
|
#[at("/calendar")]
|
||||||
|
Calendar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct RouteHandlerProps {
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub on_login: Callback<String>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub view: ViewMode,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::NaiveDateTime>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub context_menus_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(RouteHandler)]
|
||||||
|
pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||||
|
let auth_token = props.auth_token.clone();
|
||||||
|
let user_info = props.user_info.clone();
|
||||||
|
let on_login = props.on_login.clone();
|
||||||
|
let external_calendar_events = props.external_calendar_events.clone();
|
||||||
|
let external_calendars = props.external_calendars.clone();
|
||||||
|
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||||
|
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
let on_create_event_request = props.on_create_event_request.clone();
|
||||||
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
|
let context_menus_open = props.context_menus_open;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<Switch<Route> render={move |route| {
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let user_info = user_info.clone();
|
||||||
|
let on_login = on_login.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let on_event_context_menu = on_event_context_menu.clone();
|
||||||
|
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||||
|
let view = view.clone();
|
||||||
|
let on_create_event_request = on_create_event_request.clone();
|
||||||
|
let on_event_update_request = on_event_update_request.clone();
|
||||||
|
let context_menus_open = context_menus_open;
|
||||||
|
|
||||||
|
match route {
|
||||||
|
Route::Home => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||||
|
} else {
|
||||||
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Login => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||||
|
} else {
|
||||||
|
html! { <Login {on_login} /> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Calendar => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! {
|
||||||
|
<CalendarView
|
||||||
|
user_info={user_info}
|
||||||
|
external_calendar_events={external_calendar_events}
|
||||||
|
external_calendars={external_calendars}
|
||||||
|
on_event_context_menu={on_event_context_menu}
|
||||||
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
|
view={view}
|
||||||
|
on_create_event_request={on_create_event_request}
|
||||||
|
on_event_update_request={on_event_update_request}
|
||||||
|
context_menus_open={context_menus_open}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarViewProps {
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub view: ViewMode,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::NaiveDateTime>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub context_menus_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::components::Calendar;
|
||||||
|
|
||||||
|
#[function_component(CalendarView)]
|
||||||
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-view">
|
||||||
|
<Calendar
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendar_events={props.external_calendar_events.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
|
view={props.view.clone()}
|
||||||
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
|
on_event_update_request={props.on_event_update_request.clone()}
|
||||||
|
context_menus_open={props.context_menus_open}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
464
frontend/src/components/sidebar.rs
Normal file
464
frontend/src/components/sidebar.rs
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
use crate::components::CalendarListItem;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum ViewMode {
|
||||||
|
Month,
|
||||||
|
Week,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Theme {
|
||||||
|
Default,
|
||||||
|
Ocean,
|
||||||
|
Forest,
|
||||||
|
Sunset,
|
||||||
|
Purple,
|
||||||
|
Dark,
|
||||||
|
Rose,
|
||||||
|
Mint,
|
||||||
|
Midnight,
|
||||||
|
Charcoal,
|
||||||
|
Nord,
|
||||||
|
Dracula,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Style {
|
||||||
|
Default,
|
||||||
|
Google,
|
||||||
|
Apple,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Default => "default",
|
||||||
|
Theme::Ocean => "ocean",
|
||||||
|
Theme::Forest => "forest",
|
||||||
|
Theme::Sunset => "sunset",
|
||||||
|
Theme::Purple => "purple",
|
||||||
|
Theme::Dark => "dark",
|
||||||
|
Theme::Rose => "rose",
|
||||||
|
Theme::Mint => "mint",
|
||||||
|
Theme::Midnight => "midnight",
|
||||||
|
Theme::Charcoal => "charcoal",
|
||||||
|
Theme::Nord => "nord",
|
||||||
|
Theme::Dracula => "dracula",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_value(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"ocean" => Theme::Ocean,
|
||||||
|
"forest" => Theme::Forest,
|
||||||
|
"sunset" => Theme::Sunset,
|
||||||
|
"purple" => Theme::Purple,
|
||||||
|
"dark" => Theme::Dark,
|
||||||
|
"rose" => Theme::Rose,
|
||||||
|
"mint" => Theme::Mint,
|
||||||
|
"midnight" => Theme::Midnight,
|
||||||
|
"charcoal" => Theme::Charcoal,
|
||||||
|
"nord" => Theme::Nord,
|
||||||
|
"dracula" => Theme::Dracula,
|
||||||
|
_ => Theme::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
pub fn value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Style::Default => "default",
|
||||||
|
Style::Google => "google",
|
||||||
|
Style::Apple => "apple",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_value(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"google" => Style::Google,
|
||||||
|
"apple" => Style::Apple,
|
||||||
|
_ => 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
|
||||||
|
Style::Apple => Some("apple.css"), // Trunk copies to root level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
ViewMode::Month
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct SidebarProps {
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub on_logout: Callback<()>,
|
||||||
|
pub on_add_calendar: Callback<()>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub on_external_calendar_toggle: Callback<i32>,
|
||||||
|
pub on_external_calendar_delete: Callback<i32>,
|
||||||
|
pub on_external_calendar_refresh: Callback<i32>,
|
||||||
|
pub color_picker_open: Option<String>,
|
||||||
|
pub on_color_change: Callback<(String, String)>,
|
||||||
|
pub on_color_picker_toggle: Callback<String>,
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
|
||||||
|
pub refreshing_calendar_id: Option<i32>,
|
||||||
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
|
pub on_calendar_visibility_toggle: Callback<String>,
|
||||||
|
pub current_view: ViewMode,
|
||||||
|
pub on_view_change: Callback<ViewMode>,
|
||||||
|
pub current_theme: Theme,
|
||||||
|
pub on_theme_change: Callback<Theme>,
|
||||||
|
pub current_style: Style,
|
||||||
|
pub on_style_change: Callback<Style>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Sidebar)]
|
||||||
|
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||||
|
let external_context_menu_open = use_state(|| None::<i32>);
|
||||||
|
let on_view_change = {
|
||||||
|
let on_view_change = props.on_view_change.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
let value = select.value();
|
||||||
|
let new_view = match value.as_str() {
|
||||||
|
"week" => ViewMode::Week,
|
||||||
|
_ => ViewMode::Month,
|
||||||
|
};
|
||||||
|
on_view_change.emit(new_view);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_theme_change = {
|
||||||
|
let on_theme_change = props.on_theme_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_theme = Theme::from_value(&value);
|
||||||
|
on_theme_change.emit(new_theme);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_calendar_context_menu = {
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
|
||||||
|
e.prevent_default();
|
||||||
|
external_context_menu_open.set(Some(cal_id));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_calendar_delete = {
|
||||||
|
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |cal_id: i32| {
|
||||||
|
on_external_calendar_delete.emit(cal_id);
|
||||||
|
external_context_menu_open.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let close_external_context_menu = {
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
external_context_menu_open.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1>{"Runway"}</h1>
|
||||||
|
{
|
||||||
|
if let Some(ref info) = props.user_info {
|
||||||
|
html! {
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="username">{&info.username}</div>
|
||||||
|
<div class="server-url">{&info.server_url}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <div class="user-info loading">{"Loading..."}</div> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
if let Some(ref info) = props.user_info {
|
||||||
|
if !info.calendars.is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-list">
|
||||||
|
<h3>{"My Calendars"}</h3>
|
||||||
|
<ul>
|
||||||
|
{
|
||||||
|
info.calendars.iter().map(|cal| {
|
||||||
|
html! {
|
||||||
|
<CalendarListItem
|
||||||
|
calendar={cal.clone()}
|
||||||
|
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
|
||||||
|
on_color_change={props.on_color_change.clone()}
|
||||||
|
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
||||||
|
available_colors={props.available_colors.clone()}
|
||||||
|
on_color_editor_open={props.on_color_editor_open.clone()}
|
||||||
|
on_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
|
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <div class="no-calendars">{"No calendars found"}</div> }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External calendars section
|
||||||
|
<div class="external-calendar-list">
|
||||||
|
<h3>{"External Calendars"}</h3>
|
||||||
|
{
|
||||||
|
if !props.external_calendars.is_empty() {
|
||||||
|
html! {
|
||||||
|
<ul class="external-calendar-items">
|
||||||
|
{
|
||||||
|
props.external_calendars.iter().map(|cal| {
|
||||||
|
let on_toggle = {
|
||||||
|
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_external_calendar_toggle.emit(cal_id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<li class="external-calendar-item">
|
||||||
|
<div
|
||||||
|
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
"external-calendar-info color-picker-active"
|
||||||
|
} else {
|
||||||
|
"external-calendar-info"
|
||||||
|
}}
|
||||||
|
oncontextmenu={{
|
||||||
|
let on_context_menu = on_external_calendar_context_menu.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
on_context_menu.emit((e, cal_id));
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cal.is_visible}
|
||||||
|
onchange={on_toggle}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="external-calendar-color"
|
||||||
|
style={format!("background-color: {}", cal.color)}
|
||||||
|
onclick={{
|
||||||
|
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_picker_toggle.emit(external_id.clone());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
html! {
|
||||||
|
<div class="color-picker-dropdown">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let color_str = color.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_change.emit((external_id.clone(), color_str.clone()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_color_right_click = {
|
||||||
|
let on_color_editor_open = props.on_color_editor_open.clone();
|
||||||
|
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
|
||||||
|
let color_str = color.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_editor_open.emit((color_index, color_str.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_selected = cal.color == *color;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
|
oncontextmenu={on_color_right_click}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="external-calendar-name">{&cal.name}</span>
|
||||||
|
<div class="external-calendar-actions">
|
||||||
|
{
|
||||||
|
if let Some(last_fetched) = cal.last_fetched {
|
||||||
|
let local_time = last_fetched.with_timezone(&chrono::Local);
|
||||||
|
html! {
|
||||||
|
<span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}>
|
||||||
|
{format!("{}", local_time.format("%H:%M"))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<span class="last-updated">{"Never"}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="external-calendar-refresh-btn"
|
||||||
|
title="Refresh calendar"
|
||||||
|
onclick={{
|
||||||
|
let on_refresh = props.on_external_calendar_refresh.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_refresh.emit(cal_id);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if props.refreshing_calendar_id == Some(cal.id) {
|
||||||
|
html! { <i class="fas fa-spinner fa-spin"></i> }
|
||||||
|
} else {
|
||||||
|
html! { <i class="fas fa-sync-alt"></i> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
if *external_context_menu_open == Some(cal.id) {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
class="context-menu-overlay"
|
||||||
|
onclick={close_external_context_menu.clone()}
|
||||||
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
|
||||||
|
/>
|
||||||
|
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
|
||||||
|
<div
|
||||||
|
class="context-menu-item"
|
||||||
|
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
|
||||||
|
onclick={{
|
||||||
|
let on_delete = on_external_calendar_delete.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_delete.emit(cal_id);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"Delete Calendar"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
|
||||||
|
{"+ Add Calendar"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="view-selector">
|
||||||
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||||
|
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||||
|
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-selector">
|
||||||
|
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||||
|
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||||
|
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||||
|
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"Forest"}</option>
|
||||||
|
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"Sunset"}</option>
|
||||||
|
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"Purple"}</option>
|
||||||
|
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
|
||||||
|
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
|
||||||
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
||||||
|
<option value="midnight" selected={matches!(props.current_theme, Theme::Midnight)}>{"Midnight"}</option>
|
||||||
|
<option value="charcoal" selected={matches!(props.current_theme, Theme::Charcoal)}>{"Charcoal"}</option>
|
||||||
|
<option value="nord" selected={matches!(props.current_theme, Theme::Nord)}>{"Nord"}</option>
|
||||||
|
<option value="dracula" selected={matches!(props.current_theme, Theme::Dracula)}>{"Dracula"}</option>
|
||||||
|
</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>
|
||||||
|
<option value="apple" selected={matches!(props.current_style, Style::Apple)}>{"Apple Calendar"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
}
|
||||||
1431
frontend/src/components/week_view.rs
Normal file
1431
frontend/src/components/week_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
yew::Renderer::<App>::new().render();
|
yew::Renderer::<App>::new().render();
|
||||||
}
|
}
|
||||||
2
frontend/src/models/ical.rs
Normal file
2
frontend/src/models/ical.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from shared calendar-models library for backward compatibility
|
||||||
|
pub use calendar_models::*;
|
||||||
5
frontend/src/models/mod.rs
Normal file
5
frontend/src/models/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// RFC 5545 Compliant iCalendar Models
|
||||||
|
pub mod ical;
|
||||||
|
|
||||||
|
// Re-export commonly used types
|
||||||
|
// pub use ical::VEvent;
|
||||||
299
frontend/src/services/alarm_scheduler.rs
Normal file
299
frontend/src/services/alarm_scheduler.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger, VEvent};
|
||||||
|
use chrono::{Duration, Local, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::services::{NotificationManager, AlarmNotification};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledAlarm {
|
||||||
|
pub id: String, // Unique alarm ID
|
||||||
|
pub event_uid: String, // Event this alarm belongs to
|
||||||
|
pub event_summary: String, // Event title for notification
|
||||||
|
pub event_location: Option<String>, // Event location for notification
|
||||||
|
pub event_start: NaiveDateTime, // Event start time (local)
|
||||||
|
pub trigger_time: NaiveDateTime, // When alarm should trigger (local)
|
||||||
|
pub alarm_action: AlarmAction, // Type of alarm
|
||||||
|
pub status: AlarmStatus, // Current status
|
||||||
|
pub created_at: NaiveDateTime, // When alarm was scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlarmStatus {
|
||||||
|
Pending, // Waiting to trigger
|
||||||
|
Triggered, // Has been triggered
|
||||||
|
Dismissed, // User dismissed
|
||||||
|
Expired, // Past due (event ended)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AlarmScheduler {
|
||||||
|
scheduled_alarms: HashMap<String, ScheduledAlarm>,
|
||||||
|
notification_manager: NotificationManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALARMS_STORAGE_KEY: &str = "scheduled_alarms";
|
||||||
|
|
||||||
|
impl AlarmScheduler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut scheduler = Self {
|
||||||
|
scheduled_alarms: HashMap::new(),
|
||||||
|
notification_manager: NotificationManager::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load alarms from localStorage
|
||||||
|
scheduler.load_alarms_from_storage();
|
||||||
|
scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load alarms from localStorage
|
||||||
|
fn load_alarms_from_storage(&mut self) {
|
||||||
|
if let Ok(alarms) = LocalStorage::get::<HashMap<String, ScheduledAlarm>>(ALARMS_STORAGE_KEY) {
|
||||||
|
self.scheduled_alarms = alarms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save alarms to localStorage
|
||||||
|
fn save_alarms_to_storage(&self) {
|
||||||
|
if let Err(e) = LocalStorage::set(ALARMS_STORAGE_KEY, &self.scheduled_alarms) {
|
||||||
|
web_sys::console::error_1(
|
||||||
|
&format!("Failed to save alarms to localStorage: {:?}", e).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule alarms for an event
|
||||||
|
pub fn schedule_event_alarms(&mut self, event: &VEvent) {
|
||||||
|
// Check notification permission before scheduling
|
||||||
|
let permission = NotificationManager::get_permission();
|
||||||
|
if permission != web_sys::NotificationPermission::Granted && !event.alarms.is_empty() {
|
||||||
|
// Try to force request permission asynchronously
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = NotificationManager::force_request_permission().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing alarms for this event
|
||||||
|
self.remove_event_alarms(&event.uid);
|
||||||
|
|
||||||
|
// Get event details
|
||||||
|
let event_summary = event.summary.as_ref().unwrap_or(&"Untitled Event".to_string()).clone();
|
||||||
|
let event_location = event.location.clone();
|
||||||
|
let event_start = event.dtstart;
|
||||||
|
|
||||||
|
// Schedule each alarm
|
||||||
|
for alarm in &event.alarms {
|
||||||
|
if let Some(scheduled_alarm) = self.create_scheduled_alarm(
|
||||||
|
event,
|
||||||
|
alarm,
|
||||||
|
&event_summary,
|
||||||
|
&event_location,
|
||||||
|
event_start,
|
||||||
|
) {
|
||||||
|
self.scheduled_alarms.insert(scheduled_alarm.id.clone(), scheduled_alarm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a scheduled alarm from a VAlarm
|
||||||
|
fn create_scheduled_alarm(
|
||||||
|
&self,
|
||||||
|
event: &VEvent,
|
||||||
|
valarm: &VAlarm,
|
||||||
|
event_summary: &str,
|
||||||
|
event_location: &Option<String>,
|
||||||
|
event_start: NaiveDateTime,
|
||||||
|
) -> Option<ScheduledAlarm> {
|
||||||
|
// Only handle Display alarms for now
|
||||||
|
if valarm.action != AlarmAction::Display {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trigger time
|
||||||
|
let trigger_time = match &valarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
// Duration relative to event start
|
||||||
|
let trigger_time = event_start + *duration;
|
||||||
|
|
||||||
|
// Ensure trigger time is not in the past (with 30 second tolerance)
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
if trigger_time < now - Duration::seconds(30) {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!("Skipping past alarm for event: {} (trigger: {})",
|
||||||
|
event_summary,
|
||||||
|
trigger_time.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
).into()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_time
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(datetime) => {
|
||||||
|
// Absolute datetime - convert to local time
|
||||||
|
let local_trigger = datetime.with_timezone(&Local).naive_local();
|
||||||
|
|
||||||
|
// Ensure trigger time is not in the past
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
if local_trigger < now - Duration::seconds(30) {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!("Skipping past absolute alarm for event: {} (trigger: {})",
|
||||||
|
event_summary,
|
||||||
|
local_trigger.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
).into()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
local_trigger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate unique alarm ID
|
||||||
|
let alarm_id = format!("{}_{}", event.uid, trigger_time.and_utc().timestamp());
|
||||||
|
|
||||||
|
Some(ScheduledAlarm {
|
||||||
|
id: alarm_id,
|
||||||
|
event_uid: event.uid.clone(),
|
||||||
|
event_summary: event_summary.to_string(),
|
||||||
|
event_location: event_location.clone(),
|
||||||
|
event_start,
|
||||||
|
trigger_time,
|
||||||
|
alarm_action: valarm.action.clone(),
|
||||||
|
status: AlarmStatus::Pending,
|
||||||
|
created_at: Local::now().naive_local(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all alarms for an event
|
||||||
|
pub fn remove_event_alarms(&mut self, event_uid: &str) {
|
||||||
|
let alarm_ids: Vec<String> = self.scheduled_alarms
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, alarm)| alarm.event_uid == event_uid)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm_id in alarm_ids {
|
||||||
|
self.scheduled_alarms.remove(&alarm_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also close any active notifications for this event
|
||||||
|
self.notification_manager.close_notification(event_uid);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for alarms that should trigger now and trigger them
|
||||||
|
pub fn check_and_trigger_alarms(&mut self) -> usize {
|
||||||
|
// Reload alarms from localStorage to ensure we have the latest data
|
||||||
|
self.load_alarms_from_storage();
|
||||||
|
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
let mut triggered_count = 0;
|
||||||
|
|
||||||
|
// Find alarms that should trigger (within 30 seconds tolerance)
|
||||||
|
let alarms_to_trigger: Vec<ScheduledAlarm> = self.scheduled_alarms
|
||||||
|
.values()
|
||||||
|
.filter(|alarm| {
|
||||||
|
alarm.status == AlarmStatus::Pending &&
|
||||||
|
alarm.trigger_time <= now + Duration::seconds(30) &&
|
||||||
|
alarm.trigger_time >= now - Duration::seconds(30)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm in alarms_to_trigger {
|
||||||
|
if self.trigger_alarm(&alarm) {
|
||||||
|
// Mark alarm as triggered
|
||||||
|
if let Some(scheduled_alarm) = self.scheduled_alarms.get_mut(&alarm.id) {
|
||||||
|
scheduled_alarm.status = AlarmStatus::Triggered;
|
||||||
|
}
|
||||||
|
triggered_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired alarms (events that ended more than 1 hour ago)
|
||||||
|
self.cleanup_expired_alarms();
|
||||||
|
|
||||||
|
// Save to localStorage if any changes were made
|
||||||
|
if triggered_count > 0 {
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggered_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a specific alarm
|
||||||
|
fn trigger_alarm(&mut self, alarm: &ScheduledAlarm) -> bool {
|
||||||
|
// Don't trigger if already showing notification for this event
|
||||||
|
if self.notification_manager.has_notification(&alarm.event_uid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alarm_notification = AlarmNotification {
|
||||||
|
event_uid: alarm.event_uid.clone(),
|
||||||
|
event_summary: alarm.event_summary.clone(),
|
||||||
|
event_location: alarm.event_location.clone(),
|
||||||
|
alarm_time: alarm.event_start,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.notification_manager.show_alarm_notification(alarm_notification) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(
|
||||||
|
&format!("Failed to trigger alarm: {:?}", err).into()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired alarms
|
||||||
|
fn cleanup_expired_alarms(&mut self) {
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
let cutoff_time = now - Duration::hours(1);
|
||||||
|
|
||||||
|
let expired_alarm_ids: Vec<String> = self.scheduled_alarms
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, alarm)| {
|
||||||
|
// Mark as expired if event ended more than 1 hour ago
|
||||||
|
alarm.event_start < cutoff_time
|
||||||
|
})
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm_id in &expired_alarm_ids {
|
||||||
|
if let Some(alarm) = self.scheduled_alarms.get_mut(alarm_id) {
|
||||||
|
alarm.status = AlarmStatus::Expired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired alarms from memory
|
||||||
|
let had_expired = !expired_alarm_ids.is_empty();
|
||||||
|
for alarm_id in expired_alarm_ids {
|
||||||
|
self.scheduled_alarms.remove(&alarm_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage if any expired alarms were removed
|
||||||
|
if had_expired {
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Request notification permission
|
||||||
|
pub async fn request_notification_permission(&self) -> Result<web_sys::NotificationPermission, wasm_bindgen::JsValue> {
|
||||||
|
NotificationManager::request_permission().await
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AlarmScheduler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
2104
frontend/src/services/calendar_service.rs
Normal file
2104
frontend/src/services/calendar_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
8
frontend/src/services/mod.rs
Normal file
8
frontend/src/services/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod calendar_service;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod notification_manager;
|
||||||
|
pub mod alarm_scheduler;
|
||||||
|
|
||||||
|
pub use calendar_service::CalendarService;
|
||||||
|
pub use notification_manager::{NotificationManager, AlarmNotification};
|
||||||
|
pub use alarm_scheduler::AlarmScheduler;
|
||||||
189
frontend/src/services/notification_manager.rs
Normal file
189
frontend/src/services/notification_manager.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use web_sys::{window, Notification, NotificationOptions, NotificationPermission};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NotificationManager {
|
||||||
|
// Track displayed notifications to prevent duplicates
|
||||||
|
active_notifications: HashMap<String, Notification>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AlarmNotification {
|
||||||
|
pub event_uid: String,
|
||||||
|
pub event_summary: String,
|
||||||
|
pub event_location: Option<String>,
|
||||||
|
pub alarm_time: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_notifications: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the browser supports notifications
|
||||||
|
pub fn is_supported() -> bool {
|
||||||
|
// Check if the Notification constructor exists on the window
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let has_notification = js_sys::Reflect::has(&window, &"Notification".into()).unwrap_or(false);
|
||||||
|
|
||||||
|
// Additional check - try to access Notification directly via JsValue
|
||||||
|
let window_js: &wasm_bindgen::JsValue = window.as_ref();
|
||||||
|
let direct_check = js_sys::Reflect::get(window_js, &"Notification".into()).unwrap_or(wasm_bindgen::JsValue::UNDEFINED);
|
||||||
|
let has_direct = !direct_check.is_undefined();
|
||||||
|
|
||||||
|
// Use either check
|
||||||
|
has_notification || has_direct
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current notification permission status
|
||||||
|
pub fn get_permission() -> NotificationPermission {
|
||||||
|
if Self::is_supported() {
|
||||||
|
Notification::permission()
|
||||||
|
} else {
|
||||||
|
NotificationPermission::Denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force request notification permission (even if previously denied)
|
||||||
|
pub async fn force_request_permission() -> Result<NotificationPermission, JsValue> {
|
||||||
|
if !Self::is_supported() {
|
||||||
|
return Ok(NotificationPermission::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always request permission, regardless of current status
|
||||||
|
let promise = Notification::request_permission()?;
|
||||||
|
let js_value = JsFuture::from(promise).await?;
|
||||||
|
|
||||||
|
// Convert JS string back to NotificationPermission
|
||||||
|
if let Some(permission_str) = js_value.as_string() {
|
||||||
|
match permission_str.as_str() {
|
||||||
|
"granted" => Ok(NotificationPermission::Granted),
|
||||||
|
"denied" => Ok(NotificationPermission::Denied),
|
||||||
|
_ => Ok(NotificationPermission::Default),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(NotificationPermission::Denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request notification permission from the user
|
||||||
|
pub async fn request_permission() -> Result<NotificationPermission, JsValue> {
|
||||||
|
if !Self::is_supported() {
|
||||||
|
return Ok(NotificationPermission::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current permission status
|
||||||
|
let current_permission = Notification::permission();
|
||||||
|
if current_permission != NotificationPermission::Default {
|
||||||
|
return Ok(current_permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission
|
||||||
|
let promise = Notification::request_permission()?;
|
||||||
|
let js_value = JsFuture::from(promise).await?;
|
||||||
|
|
||||||
|
// Convert JS string back to NotificationPermission
|
||||||
|
if let Some(permission_str) = js_value.as_string() {
|
||||||
|
match permission_str.as_str() {
|
||||||
|
"granted" => Ok(NotificationPermission::Granted),
|
||||||
|
"denied" => Ok(NotificationPermission::Denied),
|
||||||
|
_ => Ok(NotificationPermission::Default),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(NotificationPermission::Denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a notification for an alarm
|
||||||
|
pub fn show_alarm_notification(&mut self, alarm: AlarmNotification) -> Result<(), JsValue> {
|
||||||
|
// Check permission
|
||||||
|
if Self::get_permission() != NotificationPermission::Granted {
|
||||||
|
return Ok(()); // Don't error, just skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if notification already exists for this event
|
||||||
|
if self.active_notifications.contains_key(&alarm.event_uid) {
|
||||||
|
return Ok(()); // Already showing notification for this event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification options
|
||||||
|
let options = NotificationOptions::new();
|
||||||
|
|
||||||
|
// Set notification body with time and location
|
||||||
|
let body = if let Some(location) = &alarm.event_location {
|
||||||
|
format!("📅 {}\n📍 {}",
|
||||||
|
alarm.alarm_time.format("%H:%M"),
|
||||||
|
location
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("📅 {}", alarm.alarm_time.format("%H:%M"))
|
||||||
|
};
|
||||||
|
options.set_body(&body);
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
options.set_icon("/favicon.ico");
|
||||||
|
|
||||||
|
// Set tag to prevent duplicates
|
||||||
|
options.set_tag(&alarm.event_uid);
|
||||||
|
|
||||||
|
// Set require interaction to keep notification visible
|
||||||
|
options.set_require_interaction(true);
|
||||||
|
|
||||||
|
// Create and show notification
|
||||||
|
let notification = Notification::new_with_options(&alarm.event_summary, &options)?;
|
||||||
|
|
||||||
|
// Store reference to track active notifications
|
||||||
|
self.active_notifications.insert(alarm.event_uid.clone(), notification.clone());
|
||||||
|
|
||||||
|
// Set up click handler to focus the calendar app
|
||||||
|
let _event_uid = alarm.event_uid.clone();
|
||||||
|
let onclick_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
|
||||||
|
// Focus the window when notification is clicked
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let _ = window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
notification.set_onclick(Some(onclick_closure.as_ref().unchecked_ref()));
|
||||||
|
onclick_closure.forget(); // Keep closure alive
|
||||||
|
|
||||||
|
// Set up close handler to clean up tracking
|
||||||
|
let event_uid_close = alarm.event_uid.clone();
|
||||||
|
let mut active_notifications_close = self.active_notifications.clone();
|
||||||
|
let onclose_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
|
||||||
|
active_notifications_close.remove(&event_uid_close);
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
notification.set_onclose(Some(onclose_closure.as_ref().unchecked_ref()));
|
||||||
|
onclose_closure.forget(); // Keep closure alive
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close notification for a specific event
|
||||||
|
pub fn close_notification(&mut self, event_uid: &str) {
|
||||||
|
if let Some(notification) = self.active_notifications.remove(event_uid) {
|
||||||
|
notification.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Check if notification exists for event
|
||||||
|
pub fn has_notification(&self, event_uid: &str) -> bool {
|
||||||
|
self.active_notifications.contains_key(event_uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
206
frontend/src/services/preferences.rs
Normal file
206
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferences {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct UpdatePreferencesRequest {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct PreferencesService {
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl PreferencesService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
|
.unwrap_or("http://localhost:3000/api")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Self { base_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load preferences from LocalStorage (cached from login)
|
||||||
|
pub fn load_cached() -> Option<UserPreferences> {
|
||||||
|
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
|
||||||
|
serde_json::from_str(&prefs_json).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a single preference field and sync with backend
|
||||||
|
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
|
||||||
|
// Get session token
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
// Load current preferences
|
||||||
|
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: None,
|
||||||
|
calendar_view_mode: None,
|
||||||
|
calendar_theme: None,
|
||||||
|
calendar_colors: None,
|
||||||
|
last_used_calendar: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the specific field
|
||||||
|
match field {
|
||||||
|
"calendar_selected_date" => {
|
||||||
|
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_time_increment" => {
|
||||||
|
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
|
||||||
|
}
|
||||||
|
"calendar_view_mode" => {
|
||||||
|
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_theme" => {
|
||||||
|
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
"calendar_colors" => {
|
||||||
|
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
_ => return Err(format!("Unknown preference field: {}", field)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to LocalStorage cache
|
||||||
|
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||||
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync with backend
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date.clone(),
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
||||||
|
calendar_theme: preferences.calendar_theme.clone(),
|
||||||
|
calendar_colors: preferences.calendar_colors.clone(),
|
||||||
|
last_used_calendar: preferences.last_used_calendar.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.sync_preferences(&session_token, &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync all preferences with backend
|
||||||
|
async fn sync_preferences(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
request: &UpdatePreferencesRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let json_body = serde_json::to_string(request)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
|
||||||
|
|
||||||
|
let url = format!("{}/preferences", self.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("X-Session-Token", session_token)
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Failed to update preferences: {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate preferences from LocalStorage to backend (on first login after update)
|
||||||
|
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
|
||||||
|
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
|
||||||
|
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
||||||
|
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
||||||
|
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
||||||
|
last_used_calendar: LocalStorage::get::<String>("last_used_calendar").ok(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only migrate if we have some preferences to migrate
|
||||||
|
if request.calendar_selected_date.is_some()
|
||||||
|
|| request.calendar_time_increment.is_some()
|
||||||
|
|| request.calendar_view_mode.is_some()
|
||||||
|
|| request.calendar_theme.is_some()
|
||||||
|
|| request.calendar_colors.is_some()
|
||||||
|
|| request.last_used_calendar.is_some()
|
||||||
|
{
|
||||||
|
self.sync_preferences(&session_token, &request).await?;
|
||||||
|
|
||||||
|
// Clear old LocalStorage entries after successful migration
|
||||||
|
let _ = LocalStorage::delete("calendar_selected_date");
|
||||||
|
let _ = LocalStorage::delete("calendar_time_increment");
|
||||||
|
let _ = LocalStorage::delete("calendar_view_mode");
|
||||||
|
let _ = LocalStorage::delete("calendar_theme");
|
||||||
|
let _ = LocalStorage::delete("calendar_colors");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the last used calendar and sync with backend
|
||||||
|
pub async fn update_last_used_calendar(&self, calendar_path: &str) -> Result<(), String> {
|
||||||
|
// Get session token
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
// Create minimal update request with only the last used calendar
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: None,
|
||||||
|
calendar_view_mode: None,
|
||||||
|
calendar_theme: None,
|
||||||
|
calendar_colors: None,
|
||||||
|
last_used_calendar: Some(calendar_path.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync to backend
|
||||||
|
self.sync_preferences(&session_token, &request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
5588
frontend/styles.css
Normal file
5588
frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
691
frontend/styles/apple.css
Normal file
691
frontend/styles/apple.css
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
/* Apple Calendar-inspired styles */
|
||||||
|
|
||||||
|
/* Override CSS Variables for Apple Calendar Style */
|
||||||
|
:root {
|
||||||
|
/* Apple-style spacing */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 12px;
|
||||||
|
--spacing-lg: 16px;
|
||||||
|
--spacing-xl: 24px;
|
||||||
|
|
||||||
|
/* Apple-style borders and radius */
|
||||||
|
--border-radius-small: 6px;
|
||||||
|
--border-radius-medium: 10px;
|
||||||
|
--border-radius-large: 16px;
|
||||||
|
|
||||||
|
/* Apple-style shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15), 0 3px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-aware Apple style colors - use theme colors but with Apple aesthetic */
|
||||||
|
[data-style="apple"] {
|
||||||
|
/* Use theme background and text colors */
|
||||||
|
--apple-bg-primary: var(--background-secondary);
|
||||||
|
--apple-bg-secondary: var(--background-primary);
|
||||||
|
--apple-text-primary: var(--text-primary);
|
||||||
|
--apple-text-secondary: var(--text-secondary);
|
||||||
|
--apple-text-tertiary: var(--text-secondary);
|
||||||
|
--apple-text-inverse: var(--text-inverse);
|
||||||
|
--apple-border-primary: var(--border-primary);
|
||||||
|
--apple-border-secondary: var(--border-secondary);
|
||||||
|
--apple-accent: var(--primary-color);
|
||||||
|
--apple-hover-bg: var(--background-tertiary);
|
||||||
|
--apple-today-accent: var(--primary-color);
|
||||||
|
|
||||||
|
/* Apple font family */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-specific Apple style adjustments */
|
||||||
|
[data-style="apple"][data-theme="default"] {
|
||||||
|
--apple-bg-tertiary: rgba(248, 249, 250, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(246, 246, 246, 0.7);
|
||||||
|
--apple-accent-bg: rgba(102, 126, 234, 0.1);
|
||||||
|
--apple-today-bg: rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="ocean"] {
|
||||||
|
--apple-bg-tertiary: rgba(224, 247, 250, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(224, 247, 250, 0.7);
|
||||||
|
--apple-accent-bg: rgba(0, 105, 148, 0.1);
|
||||||
|
--apple-today-bg: rgba(0, 105, 148, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="forest"] {
|
||||||
|
--apple-bg-tertiary: rgba(232, 245, 232, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(232, 245, 232, 0.7);
|
||||||
|
--apple-accent-bg: rgba(6, 95, 70, 0.1);
|
||||||
|
--apple-today-bg: rgba(6, 95, 70, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="sunset"] {
|
||||||
|
--apple-bg-tertiary: rgba(255, 243, 224, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(255, 243, 224, 0.7);
|
||||||
|
--apple-accent-bg: rgba(234, 88, 12, 0.1);
|
||||||
|
--apple-today-bg: rgba(234, 88, 12, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="purple"] {
|
||||||
|
--apple-bg-tertiary: rgba(243, 229, 245, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(243, 229, 245, 0.7);
|
||||||
|
--apple-accent-bg: rgba(124, 58, 237, 0.1);
|
||||||
|
--apple-today-bg: rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="dark"] {
|
||||||
|
--apple-bg-tertiary: rgba(31, 41, 55, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(44, 44, 46, 0.8);
|
||||||
|
--apple-accent-bg: rgba(55, 65, 81, 0.3);
|
||||||
|
--apple-today-bg: rgba(55, 65, 81, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="rose"] {
|
||||||
|
--apple-bg-tertiary: rgba(252, 228, 236, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(252, 228, 236, 0.7);
|
||||||
|
--apple-accent-bg: rgba(225, 29, 72, 0.1);
|
||||||
|
--apple-today-bg: rgba(225, 29, 72, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="mint"] {
|
||||||
|
--apple-bg-tertiary: rgba(224, 242, 241, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(224, 242, 241, 0.7);
|
||||||
|
--apple-accent-bg: rgba(16, 185, 129, 0.1);
|
||||||
|
--apple-today-bg: rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="midnight"] {
|
||||||
|
--apple-bg-tertiary: rgba(21, 27, 38, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(21, 27, 38, 0.8);
|
||||||
|
--apple-accent-bg: rgba(76, 154, 255, 0.15);
|
||||||
|
--apple-today-bg: rgba(76, 154, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="charcoal"] {
|
||||||
|
--apple-bg-tertiary: rgba(26, 26, 26, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(26, 26, 26, 0.8);
|
||||||
|
--apple-accent-bg: rgba(74, 222, 128, 0.15);
|
||||||
|
--apple-today-bg: rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="nord"] {
|
||||||
|
--apple-bg-tertiary: rgba(59, 66, 82, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(59, 66, 82, 0.8);
|
||||||
|
--apple-accent-bg: rgba(136, 192, 208, 0.15);
|
||||||
|
--apple-today-bg: rgba(136, 192, 208, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="dracula"] {
|
||||||
|
--apple-bg-tertiary: rgba(68, 71, 90, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(68, 71, 90, 0.8);
|
||||||
|
--apple-accent-bg: rgba(189, 147, 249, 0.15);
|
||||||
|
--apple-today-bg: rgba(189, 147, 249, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style body and base styles */
|
||||||
|
[data-style="apple"] body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.47;
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style sidebar with glassmorphism */
|
||||||
|
[data-style="apple"] .app-sidebar {
|
||||||
|
background: var(--apple-bg-sidebar);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid var(--apple-border-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--apple-border-primary);
|
||||||
|
padding: 20px 16px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info .username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info .server-url {
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style buttons */
|
||||||
|
[data-style="apple"] .create-calendar-button {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .create-calendar-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background: var(--apple-accent);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .logout-button {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
color: var(--apple-accent);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .logout-button:hover {
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style navigation */
|
||||||
|
[data-style="apple"] .sidebar-nav .nav-link {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-nav .nav-link:hover {
|
||||||
|
color: var(--apple-accent);
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar list */
|
||||||
|
[data-style="apple"] .calendar-list h3 {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.024em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-list .calendar-name {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .no-calendars {
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style form elements */
|
||||||
|
[data-style="apple"] .sidebar-footer label,
|
||||||
|
[data-style="apple"] .view-selector label,
|
||||||
|
[data-style="apple"] .theme-selector label,
|
||||||
|
[data-style="apple"] .style-selector label {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-selector-dropdown,
|
||||||
|
[data-style="apple"] .theme-selector-dropdown,
|
||||||
|
[data-style="apple"] .style-selector-dropdown {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-selector-dropdown:focus,
|
||||||
|
[data-style="apple"] .theme-selector-dropdown:focus,
|
||||||
|
[data-style="apple"] .style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--apple-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar list items */
|
||||||
|
[data-style="apple"] .calendar-list .calendar-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-list .calendar-item:hover {
|
||||||
|
background-color: var(--apple-hover-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style main content area */
|
||||||
|
[data-style="apple"] .app-main {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar header */
|
||||||
|
[data-style="apple"] .calendar-header {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-header h2,
|
||||||
|
[data-style="apple"] .calendar-header h3,
|
||||||
|
[data-style="apple"] .month-header,
|
||||||
|
[data-style="apple"] .week-header {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style headings */
|
||||||
|
[data-style="apple"] h1,
|
||||||
|
[data-style="apple"] h2,
|
||||||
|
[data-style="apple"] h3,
|
||||||
|
[data-style="apple"] .month-title,
|
||||||
|
[data-style="apple"] .calendar-title,
|
||||||
|
[data-style="apple"] .current-month,
|
||||||
|
[data-style="apple"] .month-year,
|
||||||
|
[data-style="apple"] .header-title {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style navigation buttons */
|
||||||
|
[data-style="apple"] button,
|
||||||
|
[data-style="apple"] .nav-button,
|
||||||
|
[data-style="apple"] .calendar-nav-button,
|
||||||
|
[data-style="apple"] .prev-button,
|
||||||
|
[data-style="apple"] .next-button,
|
||||||
|
[data-style="apple"] .arrow-button {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] button:hover,
|
||||||
|
[data-style="apple"] .nav-button:hover,
|
||||||
|
[data-style="apple"] .calendar-nav-button:hover,
|
||||||
|
[data-style="apple"] .prev-button:hover,
|
||||||
|
[data-style="apple"] .next-button:hover,
|
||||||
|
[data-style="apple"] .arrow-button:hover {
|
||||||
|
background: var(--apple-accent-bg);
|
||||||
|
color: var(--apple-accent);
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar controls */
|
||||||
|
[data-style="apple"] .calendar-controls,
|
||||||
|
[data-style="apple"] .current-date,
|
||||||
|
[data-style="apple"] .date-display {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar grid */
|
||||||
|
[data-style="apple"] .calendar-grid,
|
||||||
|
[data-style="apple"] .calendar-container {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .month-header,
|
||||||
|
[data-style="apple"] .week-header {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar cells */
|
||||||
|
[data-style="apple"] .calendar-day,
|
||||||
|
[data-style="apple"] .day-cell {
|
||||||
|
border: 1px solid var(--apple-border-secondary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 120px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day:hover,
|
||||||
|
[data-style="apple"] .day-cell:hover {
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.today,
|
||||||
|
[data-style="apple"] .day-cell.today {
|
||||||
|
background: var(--apple-today-bg);
|
||||||
|
border-color: var(--apple-today-accent);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.today::before,
|
||||||
|
[data-style="apple"] .day-cell.today::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--apple-today-accent);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.other-month,
|
||||||
|
[data-style="apple"] .day-cell.other-month {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .day-number,
|
||||||
|
[data-style="apple"] .date-number {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style day headers */
|
||||||
|
[data-style="apple"] .day-header,
|
||||||
|
[data-style="apple"] .weekday-header {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--apple-border-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Calendar-style events */
|
||||||
|
[data-style="apple"] .event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 2px 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event * {
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event:hover {
|
||||||
|
transform: translateY(-1px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All-day events styling */
|
||||||
|
[data-style="apple"] .event.all-day {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event.all-day::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event time display */
|
||||||
|
[data-style="apple"] .event-time {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar table structure */
|
||||||
|
[data-style="apple"] .calendar-table,
|
||||||
|
[data-style="apple"] table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-table td,
|
||||||
|
[data-style="apple"] table td {
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid var(--apple-border-secondary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style view toggle */
|
||||||
|
[data-style="apple"] .view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-toggle button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-toggle button.active {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style today button */
|
||||||
|
[data-style="apple"] .today-button {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
border: none;
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .today-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style modals */
|
||||||
|
[data-style="apple"] .modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .modal-content {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .modal h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style form inputs */
|
||||||
|
[data-style="apple"] input[type="text"],
|
||||||
|
[data-style="apple"] input[type="email"],
|
||||||
|
[data-style="apple"] input[type="password"],
|
||||||
|
[data-style="apple"] input[type="url"],
|
||||||
|
[data-style="apple"] input[type="date"],
|
||||||
|
[data-style="apple"] input[type="time"],
|
||||||
|
[data-style="apple"] textarea,
|
||||||
|
[data-style="apple"] select {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] input:focus,
|
||||||
|
[data-style="apple"] textarea:focus,
|
||||||
|
[data-style="apple"] select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--apple-accent-bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style labels */
|
||||||
|
[data-style="apple"] label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth animations and transitions */
|
||||||
|
[data-style="apple"] * {
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for Apple style */
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--apple-text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
561
frontend/styles/google.css
Normal file
561
frontend/styles/google.css
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-aware Google style colors - use theme colors but with Google aesthetic */
|
||||||
|
[data-style="google"] {
|
||||||
|
/* Use theme background and text colors */
|
||||||
|
--google-bg-primary: var(--background-secondary);
|
||||||
|
--google-bg-secondary: var(--background-primary);
|
||||||
|
--google-bg-tertiary: var(--background-tertiary);
|
||||||
|
--google-text-primary: var(--text-primary);
|
||||||
|
--google-text-secondary: var(--text-secondary);
|
||||||
|
--google-text-inverse: var(--text-inverse);
|
||||||
|
--google-border-primary: var(--border-primary);
|
||||||
|
--google-border-secondary: var(--border-secondary);
|
||||||
|
--google-accent: var(--primary-color);
|
||||||
|
--google-hover-bg: var(--background-tertiary);
|
||||||
|
|
||||||
|
/* Google font family */
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-specific accent backgrounds for Google style */
|
||||||
|
[data-style="google"][data-theme="default"] {
|
||||||
|
--google-accent-bg: rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="ocean"] {
|
||||||
|
--google-accent-bg: rgba(0, 105, 148, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="forest"] {
|
||||||
|
--google-accent-bg: rgba(6, 95, 70, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="sunset"] {
|
||||||
|
--google-accent-bg: rgba(234, 88, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="purple"] {
|
||||||
|
--google-accent-bg: rgba(124, 58, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="dark"] {
|
||||||
|
--google-accent-bg: rgba(55, 65, 81, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="rose"] {
|
||||||
|
--google-accent-bg: rgba(225, 29, 72, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="mint"] {
|
||||||
|
--google-accent-bg: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="midnight"] {
|
||||||
|
--google-accent-bg: rgba(76, 154, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="charcoal"] {
|
||||||
|
--google-accent-bg: rgba(74, 222, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="nord"] {
|
||||||
|
--google-accent-bg: rgba(136, 192, 208, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="dracula"] {
|
||||||
|
--google-accent-bg: rgba(189, 147, 249, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style sidebar */
|
||||||
|
[data-style="google"] .app-sidebar {
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
border-right: 1px solid var(--google-border-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
box-shadow: 2px 0 8px rgba(60,64,67,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .sidebar-header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--google-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .sidebar-header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .user-info {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .user-info .username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .user-info .server-url {
|
||||||
|
color: var(--google-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style buttons */
|
||||||
|
[data-style="google"] .create-calendar-button {
|
||||||
|
background: var(--google-accent);
|
||||||
|
color: var(--google-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .create-calendar-button:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .logout-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--google-accent);
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .logout-button:hover {
|
||||||
|
background: var(--google-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style navigation */
|
||||||
|
[data-style="google"] .sidebar-nav .nav-link {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .sidebar-nav .nav-link:hover {
|
||||||
|
color: var(--google-accent);
|
||||||
|
background: var(--google-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar list styling */
|
||||||
|
[data-style="google"] .calendar-list h3 {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-list .calendar-name {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .no-calendars {
|
||||||
|
color: var(--google-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form labels and text */
|
||||||
|
[data-style="google"] .sidebar-footer label,
|
||||||
|
[data-style="google"] .view-selector label,
|
||||||
|
[data-style="google"] .theme-selector label,
|
||||||
|
[data-style="google"] .style-selector label {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style selectors */
|
||||||
|
[data-style="google"] .view-selector-dropdown,
|
||||||
|
[data-style="google"] .theme-selector-dropdown,
|
||||||
|
[data-style="google"] .style-selector-dropdown {
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .view-selector-dropdown:focus,
|
||||||
|
[data-style="google"] .theme-selector-dropdown:focus,
|
||||||
|
[data-style="google"] .style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--google-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar list items */
|
||||||
|
[data-style="google"] .calendar-list h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-list ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-list .calendar-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-list .calendar-item:hover {
|
||||||
|
background-color: var(--google-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-list .calendar-name {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style main content area */
|
||||||
|
[data-style="google"] .app-main {
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar header elements */
|
||||||
|
[data-style="google"] .calendar-header {
|
||||||
|
background: var(--google-bg-secondary);
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-header h2,
|
||||||
|
[data-style="google"] .calendar-header h3,
|
||||||
|
[data-style="google"] .month-header,
|
||||||
|
[data-style="google"] .week-header {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month name and title */
|
||||||
|
[data-style="google"] h1,
|
||||||
|
[data-style="google"] h2,
|
||||||
|
[data-style="google"] h3,
|
||||||
|
[data-style="google"] .month-title,
|
||||||
|
[data-style="google"] .calendar-title,
|
||||||
|
[data-style="google"] .current-month,
|
||||||
|
[data-style="google"] .month-year,
|
||||||
|
[data-style="google"] .header-title {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation arrows and buttons */
|
||||||
|
[data-style="google"] button,
|
||||||
|
[data-style="google"] .nav-button,
|
||||||
|
[data-style="google"] .calendar-nav-button,
|
||||||
|
[data-style="google"] .prev-button,
|
||||||
|
[data-style="google"] .next-button,
|
||||||
|
[data-style="google"] .arrow-button {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
background: var(--google-bg-secondary);
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] button:hover,
|
||||||
|
[data-style="google"] .nav-button:hover,
|
||||||
|
[data-style="google"] .calendar-nav-button:hover,
|
||||||
|
[data-style="google"] .prev-button:hover,
|
||||||
|
[data-style="google"] .next-button:hover,
|
||||||
|
[data-style="google"] .arrow-button:hover {
|
||||||
|
background: var(--google-accent-bg);
|
||||||
|
color: var(--google-accent);
|
||||||
|
border-color: var(--google-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar controls and date display */
|
||||||
|
[data-style="google"] .calendar-controls,
|
||||||
|
[data-style="google"] .current-date,
|
||||||
|
[data-style="google"] .date-display {
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar grid */
|
||||||
|
[data-style="google"] .calendar-grid,
|
||||||
|
[data-style="google"] .calendar-container {
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-header {
|
||||||
|
background: var(--google-bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--google-border-primary);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .month-header,
|
||||||
|
[data-style="google"] .week-header {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style calendar cells */
|
||||||
|
[data-style="google"] .calendar-day,
|
||||||
|
[data-style="google"] .day-cell {
|
||||||
|
border: 1px solid var(--google-border-secondary);
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-day:hover,
|
||||||
|
[data-style="google"] .day-cell:hover {
|
||||||
|
background: var(--google-hover-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--google-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-day.today,
|
||||||
|
[data-style="google"] .day-cell.today {
|
||||||
|
background: var(--google-accent-bg);
|
||||||
|
border-color: var(--google-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-day.other-month,
|
||||||
|
[data-style="google"] .day-cell.other-month {
|
||||||
|
background: var(--google-bg-tertiary);
|
||||||
|
color: var(--google-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .day-number,
|
||||||
|
[data-style="google"] .date-number {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day headers (Mon, Tue, Wed, etc.) */
|
||||||
|
[data-style="google"] .day-header,
|
||||||
|
[data-style="google"] .weekday-header {
|
||||||
|
background: var(--google-bg-secondary);
|
||||||
|
color: var(--google-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--google-border-primary);
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google Calendar-style events */
|
||||||
|
[data-style="google"] .event {
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 1px 0 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
box-shadow: 0 1px 3px rgba(60,64,67,.3);
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
display: block;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .event * {
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .event:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(60,64,67,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All-day events styling */
|
||||||
|
[data-style="google"] .event.all-day {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event time display */
|
||||||
|
[data-style="google"] .event-time {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week view events */
|
||||||
|
[data-style="google"] .week-view .event {
|
||||||
|
border-left: 3px solid rgba(255,255,255,0.8);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar table structure */
|
||||||
|
[data-style="google"] .calendar-table,
|
||||||
|
[data-style="google"] table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .calendar-table td,
|
||||||
|
[data-style="google"] table td {
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid var(--google-border-secondary);
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month/Week view toggle */
|
||||||
|
[data-style="google"] .view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--google-bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .view-toggle button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--google-text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .view-toggle button.active {
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
color: var(--google-accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today button */
|
||||||
|
[data-style="google"] .today-button {
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
color: var(--google-accent);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .today-button:hover {
|
||||||
|
background: var(--google-hover-bg);
|
||||||
|
border-color: var(--google-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style modals */
|
||||||
|
[data-style="google"] .modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .modal-content {
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: none;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] .modal h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
font-family: 'Google Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style form inputs */
|
||||||
|
[data-style="google"] input[type="text"],
|
||||||
|
[data-style="google"] input[type="email"],
|
||||||
|
[data-style="google"] input[type="password"],
|
||||||
|
[data-style="google"] input[type="url"],
|
||||||
|
[data-style="google"] input[type="date"],
|
||||||
|
[data-style="google"] input[type="time"],
|
||||||
|
[data-style="google"] textarea,
|
||||||
|
[data-style="google"] select {
|
||||||
|
border: 1px solid var(--google-border-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
background: var(--google-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"] input:focus,
|
||||||
|
[data-style="google"] textarea:focus,
|
||||||
|
[data-style="google"] select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--google-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Google-style labels */
|
||||||
|
[data-style="google"] label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--google-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme shadow adjustments */
|
||||||
|
[data-style="google"][data-theme="dark"] .calendar-grid,
|
||||||
|
[data-style="google"][data-theme="dark"] .calendar-container {
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,.3), 0 4px 8px 3px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="dark"] .event {
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="google"][data-theme="dark"] .event:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
10
index.html
10
index.html
@@ -1,10 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Calendar App</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>
|
|
||||||
BIN
sample.png
Normal file
BIN
sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
381
src/app.rs
381
src/app.rs
@@ -1,381 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use yew_router::prelude::*;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use crate::components::{Login, Calendar};
|
|
||||||
use crate::services::{CalendarService, CalendarEvent, UserInfo, CalendarInfo};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
|
||||||
enum Route {
|
|
||||||
#[at("/")]
|
|
||||||
Home,
|
|
||||||
#[at("/login")]
|
|
||||||
Login,
|
|
||||||
#[at("/calendar")]
|
|
||||||
Calendar,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
pub fn App() -> Html {
|
|
||||||
let auth_token = use_state(|| -> Option<String> {
|
|
||||||
LocalStorage::get("auth_token").ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let user_info = use_state(|| -> Option<UserInfo> { None });
|
|
||||||
|
|
||||||
let on_login = {
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
Callback::from(move |token: String| {
|
|
||||||
auth_token.set(Some(token));
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_logout = {
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
let user_info = user_info.clone();
|
|
||||||
Callback::from(move |_| {
|
|
||||||
let _ = LocalStorage::delete("auth_token");
|
|
||||||
auth_token.set(None);
|
|
||||||
user_info.set(None);
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch user info when token is available
|
|
||||||
{
|
|
||||||
let user_info = user_info.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((*auth_token).clone(), move |token| {
|
|
||||||
if let Some(token) = token {
|
|
||||||
let user_info = user_info.clone();
|
|
||||||
let token = token.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
// Get password from stored credentials
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
if !password.is_empty() {
|
|
||||||
match calendar_service.fetch_user_info(&token, &password).await {
|
|
||||||
Ok(info) => {
|
|
||||||
user_info.set(Some(info));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
user_info.set(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<BrowserRouter>
|
|
||||||
<div class="app">
|
|
||||||
{
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! {
|
|
||||||
<>
|
|
||||||
<aside class="app-sidebar">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h1>{"Calendar App"}</h1>
|
|
||||||
{
|
|
||||||
if let Some(ref info) = *user_info {
|
|
||||||
html! {
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="username">{&info.username}</div>
|
|
||||||
<div class="server-url">{&info.server_url}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! { <div class="user-info loading">{"Loading..."}</div> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<nav class="sidebar-nav">
|
|
||||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
|
||||||
</nav>
|
|
||||||
{
|
|
||||||
if let Some(ref info) = *user_info {
|
|
||||||
if !info.calendars.is_empty() {
|
|
||||||
html! {
|
|
||||||
<div class="calendar-list">
|
|
||||||
<h3>{"My Calendars"}</h3>
|
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
info.calendars.iter().map(|cal| {
|
|
||||||
html! {
|
|
||||||
<li key={cal.path.clone()}>
|
|
||||||
<span class="calendar-name">{&cal.display_name}</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! { <div class="no-calendars">{"No calendars found"}</div> }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<button onclick={on_logout} class="logout-button">{"Logout"}</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main class="app-main">
|
|
||||||
<Switch<Route> render={move |route| {
|
|
||||||
let auth_token = (*auth_token).clone();
|
|
||||||
let on_login = on_login.clone();
|
|
||||||
|
|
||||||
match route {
|
|
||||||
Route::Home => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Login => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Login {on_login} /> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Calendar => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <CalendarView /> }
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<div class="login-layout">
|
|
||||||
<Switch<Route> render={move |route| {
|
|
||||||
let auth_token = (*auth_token).clone();
|
|
||||||
let on_login = on_login.clone();
|
|
||||||
|
|
||||||
match route {
|
|
||||||
Route::Home => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Login => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Login {on_login} /> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Calendar => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <CalendarView /> }
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</BrowserRouter>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
fn CalendarView() -> Html {
|
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
|
|
||||||
let loading = use_state(|| true);
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
let refreshing_event = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
// Get current auth token
|
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
||||||
|
|
||||||
let today = Local::now().date_naive();
|
|
||||||
let current_year = today.year();
|
|
||||||
let current_month = today.month();
|
|
||||||
|
|
||||||
// Event refresh callback
|
|
||||||
let on_event_click = {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
Callback::from(move |event: CalendarEvent| {
|
|
||||||
if let Some(token) = auth_token.clone() {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let uid = event.uid.clone();
|
|
||||||
|
|
||||||
refreshing_event.set(Some(uid.clone()));
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
// Get password from stored credentials
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
|
||||||
Ok(Some(refreshed_event)) => {
|
|
||||||
// If this is a recurring event, we need to regenerate all occurrences
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
// First, remove all existing occurrences of this event
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then, if it's a recurring event, generate new occurrences
|
|
||||||
if refreshed_event.recurrence_rule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.clone()]);
|
|
||||||
|
|
||||||
// Add all new occurrences to the appropriate dates
|
|
||||||
for occurrence in new_occurrences {
|
|
||||||
let date = occurrence.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(occurrence);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-recurring event, just add it to the appropriate date
|
|
||||||
let date = refreshed_event.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_event);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// Event was deleted, remove it from the map
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
|
||||||
// Log error but don't show it to user - keep using cached event
|
|
||||||
// Silently fall back to cached event data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing_event.set(None);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch events when component mounts
|
|
||||||
{
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((), move |_| {
|
|
||||||
if let Some(token) = auth_token {
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
// Get password from stored credentials
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.fetch_events_for_month(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(calendar_events) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(calendar_events);
|
|
||||||
events.set(grouped_events);
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error.set(Some(format!("Failed to load events: {}", err)));
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
loading.set(false);
|
|
||||||
error.set(Some("No authentication token found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="calendar-view">
|
|
||||||
{
|
|
||||||
if *loading {
|
|
||||||
html! {
|
|
||||||
<div class="calendar-loading">
|
|
||||||
<p>{"Loading calendar events..."}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else if let Some(err) = (*error).clone() {
|
|
||||||
let dummy_callback = Callback::from(|_: CalendarEvent| {});
|
|
||||||
html! {
|
|
||||||
<div class="calendar-error">
|
|
||||||
<p>{format!("Error: {}", err)}</p>
|
|
||||||
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use crate::services::calendar_service::CalendarEvent;
|
|
||||||
use crate::components::EventModal;
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct CalendarProps {
|
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
|
||||||
pub on_event_click: Callback<CalendarEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
|
||||||
let today = Local::now().date_naive();
|
|
||||||
let current_month = use_state(|| today);
|
|
||||||
let selected_day = use_state(|| today);
|
|
||||||
let selected_event = use_state(|| None::<CalendarEvent>);
|
|
||||||
|
|
||||||
let first_day_of_month = current_month.with_day(1).unwrap();
|
|
||||||
let days_in_month = get_days_in_month(*current_month);
|
|
||||||
let first_weekday = first_day_of_month.weekday();
|
|
||||||
let days_from_prev_month = get_days_from_previous_month(*current_month, first_weekday);
|
|
||||||
|
|
||||||
let prev_month = {
|
|
||||||
let current_month = current_month.clone();
|
|
||||||
Callback::from(move |_| {
|
|
||||||
let prev = *current_month - Duration::days(1);
|
|
||||||
let first_of_prev = prev.with_day(1).unwrap();
|
|
||||||
current_month.set(first_of_prev);
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let next_month = {
|
|
||||||
let current_month = current_month.clone();
|
|
||||||
Callback::from(move |_| {
|
|
||||||
let next = if current_month.month() == 12 {
|
|
||||||
NaiveDate::from_ymd_opt(current_month.year() + 1, 1, 1).unwrap()
|
|
||||||
} else {
|
|
||||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() + 1, 1).unwrap()
|
|
||||||
};
|
|
||||||
current_month.set(next);
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="calendar">
|
|
||||||
<div class="calendar-header">
|
|
||||||
<button class="nav-button" onclick={prev_month}>{"‹"}</button>
|
|
||||||
<h2 class="month-year">{format!("{} {}", get_month_name(current_month.month()), current_month.year())}</h2>
|
|
||||||
<button class="nav-button" onclick={next_month}>{"›"}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="calendar-grid">
|
|
||||||
// Weekday headers
|
|
||||||
<div class="weekday-header">{"Sun"}</div>
|
|
||||||
<div class="weekday-header">{"Mon"}</div>
|
|
||||||
<div class="weekday-header">{"Tue"}</div>
|
|
||||||
<div class="weekday-header">{"Wed"}</div>
|
|
||||||
<div class="weekday-header">{"Thu"}</div>
|
|
||||||
<div class="weekday-header">{"Fri"}</div>
|
|
||||||
<div class="weekday-header">{"Sat"}</div>
|
|
||||||
|
|
||||||
// Days from previous month (grayed out)
|
|
||||||
{
|
|
||||||
days_from_prev_month.iter().map(|day| {
|
|
||||||
html! {
|
|
||||||
<div class="calendar-day prev-month">{*day}</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Days of current month
|
|
||||||
{
|
|
||||||
(1..=days_in_month).map(|day| {
|
|
||||||
let date = current_month.with_day(day).unwrap();
|
|
||||||
let is_today = date == today;
|
|
||||||
let is_selected = date == *selected_day;
|
|
||||||
let events = props.events.get(&date).cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
let mut classes = vec!["calendar-day", "current-month"];
|
|
||||||
if is_today {
|
|
||||||
classes.push("today");
|
|
||||||
}
|
|
||||||
if is_selected {
|
|
||||||
classes.push("selected");
|
|
||||||
}
|
|
||||||
if !events.is_empty() {
|
|
||||||
classes.push("has-events");
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected_day_clone = selected_day.clone();
|
|
||||||
let on_click = Callback::from(move |_| {
|
|
||||||
selected_day_clone.set(date);
|
|
||||||
});
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class={classes!(classes)} onclick={on_click}>
|
|
||||||
<div class="day-number">{day}</div>
|
|
||||||
{
|
|
||||||
if !events.is_empty() {
|
|
||||||
html! {
|
|
||||||
<div class="event-indicators">
|
|
||||||
{
|
|
||||||
events.iter().take(2).map(|event| {
|
|
||||||
let event_clone = event.clone();
|
|
||||||
let selected_event_clone = selected_event.clone();
|
|
||||||
let on_event_click = props.on_event_click.clone();
|
|
||||||
let event_click = Callback::from(move |e: MouseEvent| {
|
|
||||||
e.stop_propagation(); // Prevent day selection
|
|
||||||
on_event_click.emit(event_clone.clone());
|
|
||||||
selected_event_clone.set(Some(event_clone.clone()));
|
|
||||||
});
|
|
||||||
|
|
||||||
let title = event.get_title();
|
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
|
||||||
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
|
|
||||||
html! {
|
|
||||||
<div class={class_name}
|
|
||||||
title={title.clone()}
|
|
||||||
onclick={event_click}>
|
|
||||||
{
|
|
||||||
if is_refreshing {
|
|
||||||
"🔄 Refreshing...".to_string()
|
|
||||||
} else if title.len() > 15 {
|
|
||||||
format!("{}...", &title[..12])
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
{
|
|
||||||
if events.len() > 2 {
|
|
||||||
html! { <div class="more-events">{format!("+{} more", events.len() - 2)}</div> }
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
|
|
||||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Event details modal
|
|
||||||
<EventModal
|
|
||||||
event={(*selected_event).clone()}
|
|
||||||
on_close={{
|
|
||||||
let selected_event_clone = selected_event.clone();
|
|
||||||
Callback::from(move |_| {
|
|
||||||
selected_event_clone.set(None);
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
|
||||||
|
|
||||||
(1..=remaining_slots).map(|day| {
|
|
||||||
html! {
|
|
||||||
<div class="calendar-day next-month">{day}</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
|
||||||
NaiveDate::from_ymd_opt(
|
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
|
||||||
1
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.pred_opt()
|
|
||||||
.unwrap()
|
|
||||||
.day()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
|
|
||||||
let days_before = match first_weekday {
|
|
||||||
Weekday::Sun => 0,
|
|
||||||
Weekday::Mon => 1,
|
|
||||||
Weekday::Tue => 2,
|
|
||||||
Weekday::Wed => 3,
|
|
||||||
Weekday::Thu => 4,
|
|
||||||
Weekday::Fri => 5,
|
|
||||||
Weekday::Sat => 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
if days_before == 0 {
|
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
// Calculate the previous month
|
|
||||||
let prev_month = if current_month.month() == 1 {
|
|
||||||
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
|
|
||||||
} else {
|
|
||||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let prev_month_days = get_days_in_month(prev_month);
|
|
||||||
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_month_name(month: u32) -> &'static str {
|
|
||||||
match month {
|
|
||||||
1 => "January",
|
|
||||||
2 => "February",
|
|
||||||
3 => "March",
|
|
||||||
4 => "April",
|
|
||||||
5 => "May",
|
|
||||||
6 => "June",
|
|
||||||
7 => "July",
|
|
||||||
8 => "August",
|
|
||||||
9 => "September",
|
|
||||||
10 => "October",
|
|
||||||
11 => "November",
|
|
||||||
12 => "December",
|
|
||||||
_ => "Invalid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct LoginProps {
|
|
||||||
pub on_login: Callback<String>, // Callback with JWT token
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component]
|
|
||||||
pub fn Login(props: &LoginProps) -> Html {
|
|
||||||
let server_url = use_state(String::new);
|
|
||||||
let username = use_state(String::new);
|
|
||||||
let password = use_state(String::new);
|
|
||||||
let error_message = use_state(|| Option::<String>::None);
|
|
||||||
let is_loading = use_state(|| false);
|
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
|
||||||
let username_ref = use_node_ref();
|
|
||||||
let password_ref = use_node_ref();
|
|
||||||
|
|
||||||
let on_server_url_change = {
|
|
||||||
let server_url = server_url.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
|
||||||
server_url.set(target.value());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_username_change = {
|
|
||||||
let username = username.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
|
||||||
username.set(target.value());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_password_change = {
|
|
||||||
let password = password.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
|
||||||
password.set(target.value());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_submit = {
|
|
||||||
let server_url = server_url.clone();
|
|
||||||
let username = username.clone();
|
|
||||||
let password = password.clone();
|
|
||||||
let error_message = error_message.clone();
|
|
||||||
let is_loading = is_loading.clone();
|
|
||||||
let on_login = props.on_login.clone();
|
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
|
|
||||||
let server_url = (*server_url).clone();
|
|
||||||
let username = (*username).clone();
|
|
||||||
let password = (*password).clone();
|
|
||||||
let error_message = error_message.clone();
|
|
||||||
let is_loading = is_loading.clone();
|
|
||||||
let on_login = on_login.clone();
|
|
||||||
|
|
||||||
// Basic client-side validation
|
|
||||||
if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() {
|
|
||||||
error_message.set(Some("Please fill in all fields".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
is_loading.set(true);
|
|
||||||
error_message.set(None);
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
|
||||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
|
||||||
Ok((token, credentials)) => {
|
|
||||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
|
||||||
// Store token and credentials in local storage
|
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
|
||||||
is_loading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
|
|
||||||
error_message.set(Some("Failed to store credentials".to_string()));
|
|
||||||
is_loading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
is_loading.set(false);
|
|
||||||
on_login.emit(token);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
|
||||||
error_message.set(Some(err));
|
|
||||||
is_loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="login-container">
|
|
||||||
<div class="login-form">
|
|
||||||
<h2>{"Sign In to CalDAV"}</h2>
|
|
||||||
<form onsubmit={on_submit}>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
|
||||||
<input
|
|
||||||
ref={server_url_ref}
|
|
||||||
type="text"
|
|
||||||
id="server_url"
|
|
||||||
placeholder="https://your-caldav-server.com/dav/"
|
|
||||||
value={(*server_url).clone()}
|
|
||||||
onchange={on_server_url_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">{"Username"}</label>
|
|
||||||
<input
|
|
||||||
ref={username_ref}
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
value={(*username).clone()}
|
|
||||||
onchange={on_username_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">{"Password"}</label>
|
|
||||||
<input
|
|
||||||
ref={password_ref}
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={(*password).clone()}
|
|
||||||
onchange={on_password_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
if let Some(error) = (*error_message).clone() {
|
|
||||||
html! { <div class="error-message">{error}</div> }
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<button type="submit" disabled={*is_loading} class="login-button">
|
|
||||||
{
|
|
||||||
if *is_loading {
|
|
||||||
"Signing in..."
|
|
||||||
} else {
|
|
||||||
"Sign In"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="auth-links">
|
|
||||||
<p>{"Enter your CalDAV server credentials to connect to your calendar"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
|
||||||
use serde_json;
|
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
|
||||||
|
|
||||||
let auth_service = AuthService::new();
|
|
||||||
let request = CalDAVLoginRequest {
|
|
||||||
server_url: server_url.clone(),
|
|
||||||
username: username.clone(),
|
|
||||||
password: password.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
|
||||||
|
|
||||||
match auth_service.login(request).await {
|
|
||||||
Ok(response) => {
|
|
||||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
|
||||||
// Create credentials object to store
|
|
||||||
let credentials = serde_json::json!({
|
|
||||||
"server_url": server_url,
|
|
||||||
"username": username,
|
|
||||||
"password": password
|
|
||||||
});
|
|
||||||
Ok((response.token, credentials.to_string()))
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
|
||||||
Err(err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
|
||||||
pub mod event_modal;
|
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user