38 Commits

Author SHA1 Message Date
Connor Johnstone
419cb3d790 Complete CreateEventModalV2 integration and fix styling
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s
- Replace CreateEventModal with new modular CreateEventModalV2 throughout app
- Fix compilation errors by aligning event_form types with create_event_modal types
- Add missing props (initial_start_time, initial_end_time) to modal interface
- Fix styling issues: use tab-navigation class and add modal-body wrapper
- Remove duplicate on_create prop causing compilation failure
- All recurrence options now properly positioned below repeat/reminder pickers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:11:18 -04:00
Connor Johnstone
53a62fb05e Refactor create_event_modal into modular components
- Split massive 27K line modal into focused components
- Created event_form module with 6 tab components:
  * BasicDetailsTab - main event info with recurrence options properly positioned
  * AdvancedTab - status, privacy, priority
  * PeopleTab - organizer and attendees
  * CategoriesTab - event categories
  * LocationTab - location information
  * RemindersTab - reminder settings
- Added shared types and data structures
- Created new CreateEventModalV2 using modular architecture
- Recurrence options now positioned directly after repeat/reminder pickers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:45:42 -04:00
Connor Johnstone
322c88612a Add API proxy configuration and large favicon
- Configure Caddy to proxy /api requests to backend service
- Add favicon_big.png for various icon size needs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:26:50 -04:00
Connor Johnstone
4aa53d79e7 Add favicon and remove calendar.db from tracking
- Add favicon.ico as site favicon using Trunk asset pipeline
- Remove calendar.db from git tracking (already in .gitignore)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:25:41 -04:00
Connor Johnstone
3464754489 Remove debug logging from all-day event detection
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:22:34 -04:00
Connor Johnstone
e56253b9c2 Fix all-day recurring events RFC-5545 compliance
- Set all_day flag properly when creating VEvent in series handler
- Improve all-day event detection using VALUE=DATE parameter
- Add RFC-5545 compliance for exclusive end dates (backend adds 1 day)
- Fix end date display in event modal (frontend subtracts 1 day for display)
- Fix recurring all-day event expansion to maintain proper end date pattern

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:21:46 -04:00
Connor Johnstone
cb8cc7258c Implement smart context menu positioning
Added intelligent viewport boundary detection that repositions context
menus when they would appear outside the screen:

- Detects right/bottom edge overflow and repositions menus accordingly
- Uses accurate size estimates based on actual menu content
- Event menus: 280×200px (recurring) / 180×100px (non-recurring)
- Calendar/generic menus: 180×60px for single items
- Maintains 5px minimum margins from screen edges
- Graceful fallback to original positioning if viewport detection fails

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:47:48 -04:00
Connor Johnstone
b576cd8c4a Add context menus to all-day event boxes
All-day events now have the same right-click context menu functionality
as regular timed events, allowing users to edit, delete, and perform
other actions on all-day events.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:37:36 -04:00
Connor Johnstone
a773159016 Fix recurring event filtering for month view
Previously filtered events by start date only, which excluded recurring
events that started in previous months/years but have instances in the
current month.

New logic:
- Non-recurring events: filter by exact month match (unchanged)
- Recurring events: include if they could have instances in requested month
- Check event start date is before/during month
- Parse RRULE UNTIL date to exclude expired recurring events
- Let frontend handle proper RRULE expansion

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:36:28 -04:00
Connor Johnstone
a9521ad536 Convenience and fixes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m11s
2025-09-02 16:07:40 -04:00
Connor Johnstone
5456d7140c Manual updates to fix some deployment steps
Some checks failed
Build and Push Docker Image / docker (push) Failing after 21s
2025-09-02 16:03:11 -04:00
Connor Johnstone
62cc910e1a Removing Cargo.lock
All checks were successful
Build and Push Docker Image / docker (push) Successful in 7m49s
2025-09-02 12:44:01 -04:00
Connor Johnstone
6ec7bb5422 variables > vars
Some checks failed
Build and Push Docker Image / docker (push) Failing after 24s
2025-09-02 12:39:51 -04:00
Connor Johnstone
ce74750d85 Trying another approach
Some checks failed
Build and Push Docker Image / docker (push) Failing after 20s
2025-09-02 12:38:08 -04:00
Connor Johnstone
d089f1545b Fixing the gitea error
Some checks failed
Build and Push Docker Image / docker (push) Failing after 22s
2025-09-02 12:34:47 -04:00
Connor Johnstone
7b06fef6c3 Revert "Fix Gitea action Docker build tag error"
This reverts commit 7be9f5a869.
2025-09-02 12:33:17 -04:00
Connor Johnstone
7be9f5a869 Fix Gitea action Docker build tag error
- Add fallback registry to prevent invalid tag format
- Make Docker login conditional on secrets being present
- Make push conditional on registry being configured
- Rename Docker image from calendar to runway

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 12:32:34 -04:00
Connor Johnstone
a7ebbe0635 Add application screenshot to README
Shows Runway's week view with events, all-day events, and dark theme

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:23:13 -04:00
Connor Johnstone
3662f117f5 Fix overlapping events to only split columns for overlapping event groups
Implemented clustering algorithm in calculate_event_layout that:
- Only creates column splits for events that actually overlap
- Non-overlapping events maintain full width display
- Uses greedy column assignment for overlapping groups
- Preserves proper column indices for each event

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:20:37 -04:00
Connor Johnstone
0899a84b42 Fix all-day events: validation and proper header positioning
Backend fixes:
- Fix all-day event creation validation error
- Allow same start/end date for all-day events (single-day events)
- Maintain strict validation for timed events (end must be after start)

Frontend improvements:
- Move all-day events from time grid to day headers
- Add dedicated all-day events container that stacks vertically
- Filter all-day events out of main time-based events area
- Add proper CSS styling for all-day event display and interaction
- Maintain event click handling and color themes

All-day events now appear in the correct location at the top of each
day column and properly stack when multiple events exist.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:13:54 -04:00
Connor Johnstone
85d23b0347 Rebrand application from 'Calendar App' to 'Runway'
Some checks failed
Build and Push Docker Image / docker (push) Failing after 26s
- Update project name in Cargo.toml from calendar-app to runway
- Change HTML title and sidebar header to 'Runway'
- Complete README rewrite with new branding and philosophy
- Add 'The Name' section explaining runway metaphor as passive infrastructure
- Update Dockerfile build references to use new binary name
- Maintain all technical documentation with new branding context

The name 'Runway' embodies passive infrastructure that enables coordination
without getting in the way - like airport runways that provide essential
structure for planes but stay invisible during flight.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:05:21 -04:00
Connor Johnstone
13db4abc0f Remove labels from theme and style pickers in sidebar
- Remove "Theme:" label from theme selector dropdown
- Remove "Style:" label from style selector dropdown
- Create cleaner, more minimal sidebar UI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:57:26 -04:00
Connor Johnstone
57e434e4ff Fix drag interaction issues in week view
- Fix drag-to-create being blocked by existing events
- Add creating-event CSS class that disables pointer events on existing events
- Fix single clicks creating temporary event boxes
- Add mouse button state check to prevent post-mouseup movement being treated as drag
- Ensure temp event boxes only appear during actual drag operations (has_moved=true)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:55:39 -04:00
Connor Johnstone
7c2901f453 Implement side-by-side rendering for overlapping events in week view
- Add overlap detection algorithm to identify overlapping events
- Implement layout calculation to arrange events in columns
- Update event positioning to use dynamic left/width instead of fixed right
- Events now render side-by-side when they overlap in time
- Maintains proper spacing and margins for all event arrangements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:44:32 -04:00
6c67444b19 Merge pull request 'Fixes for the time grid for late night events' (#8) from bugfix/week-view-time-grid into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 13m50s
Reviewed-on: #8
2025-09-02 10:39:23 -04:00
Connor Johnstone
970b0a07da Fix compiler warnings
- Remove unused import in auth handler
- Remove unused PreferencesService export
- Fix unused mutable variable in preferences
- Remove unused display_name method from Style enum
- Add dead_code attributes for future preferences service

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:38:13 -04:00
Connor Johnstone
e2e5813b54 Fix week view time grid display and late night event rendering
- Remove unnecessary boundary slot from week view time grid
- Adjust container heights to display full 11 PM - midnight time slot
- Fix timezone issue preventing events at 8 PM or later from rendering
- Update date matching logic to handle UTC-4 timezone offset correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:36:26 -04:00
Connor Johnstone
73567c185c Implement comprehensive style system with Google Calendar theme
This commit adds a complete style system alongside the existing theme system, allowing users to switch between different UI styles while maintaining theme color variations.

**Core Features:**
- Style enum (Default, Google Calendar) separate from Theme enum
- Hot-swappable stylesheets with dynamic loading
- Style preference persistence (localStorage + database)
- Style picker UI in sidebar below theme picker

**Frontend Implementation:**
- Add Style enum to sidebar.rs with value/display methods
- Implement dynamic stylesheet loading in app.rs
- Add style picker dropdown with proper styling
- Handle style state management and persistence
- Add web-sys features for HtmlLinkElement support

**Backend Integration:**
- Add calendar_style column to user_preferences table
- Update all database operations (insert/update/select)
- Extend API models for style preference
- Add migration for existing users

**Google Calendar Style:**
- Clean Material Design-inspired interface
- White sidebar with proper contrast
- Enhanced calendar grid with subtle shadows
- Improved event styling with hover effects
- Google Sans typography throughout
- Professional color scheme and spacing

**Technical Details:**
- Trunk asset management for stylesheet copying
- High CSS specificity to override theme styles
- Modular CSS architecture for easy extensibility
- Comprehensive text contrast fixes
- Enhanced calendar cells and navigation

Users can now choose between the original gradient design (Default) and a clean Google Calendar-inspired interface (Google Calendar), with full preference persistence across sessions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 20:08:05 -04:00
0587762bbb Merge pull request 'Fixed some auth stuff and deployment stuff' (#7) from bugfix/sqlite-auth-docker-deployment into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #7
2025-09-01 19:29:17 -04:00
Connor Johnstone
cd6e9c3619 Update README with Docker deployment instructions and auth features
- Added comprehensive Docker Compose deployment section as recommended method
- Documented automatic database migrations and persistent storage
- Updated architecture section to mention SQLite auth system
- Added new User Experience section highlighting session management
- Reorganized development setup with local database migration instructions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:28:24 -04:00
Connor Johnstone
d8c3997f24 Fix Docker database directory and permissions
- Create /db directory in container
- Ensure database directory exists and has proper permissions in startup script
- Remove problematic conditional COPY command that was causing build failures

This fixes the 'unable to open database file' error by ensuring the
database directory exists before migrations run.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:21:38 -04:00
Connor Johnstone
e44d49e190 Add database migrations to Docker entrypoint
- Install sqlx-cli in backend builder stage
- Copy migrations and sqlx binary to runtime image
- Run database migrations automatically on container startup
- Add error handling to prevent startup failure if migrations already applied

This ensures the database schema is always up to date when deploying
with Docker, eliminating manual migration steps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:13:04 -04:00
4d2aad404b Merge pull request 'Improved auth system' (#6) from feature/sqlite-auth-system into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #6
2025-09-01 19:03:30 -04:00
Connor Johnstone
0453763c98 Make remember checkboxes more subtle and checked by default
- Remember checkboxes now default to checked for better user experience
- Reduced visual prominence with smaller size, lighter colors, and lower opacity
- Users get convenience by default while still being able to opt out

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:02:29 -04:00
Connor Johnstone
03c0011445 Implement lightweight auth system with SQLite
Added SQLite database for session management and user preferences storage,
allowing users to have consistent settings across different sessions and devices.

Backend changes:
- Added SQLite database with users, sessions, and preferences tables
- Implemented session-based authentication alongside JWT tokens
- Created preference storage/retrieval API endpoints
- Database migrations for schema setup
- Session validation and cleanup functionality

Frontend changes:
- Added "Remember server" and "Remember username" checkboxes to login
- Created preferences service for syncing settings with backend
- Updated auth flow to handle session tokens and preferences
- Store remembered values in LocalStorage (not database) for convenience

Key features:
- User preferences persist across sessions and devices
- CalDAV passwords never stored, only passed through
- Sessions expire after 24 hours
- Remember checkboxes only affect local browser storage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:55:09 -04:00
Connor Johnstone
79f287ed61 Fix calendar event fetching to use visible date range
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly
use the visible date range instead of hardcoded current month. The Calendar
component already tracks the current visible date through navigation, so events
now load correctly for August and other months when navigating.

Changes:
- Calendar component now manages its own events state and fetching
- Event fetching responds to current_date changes from navigation
- CalendarView simplified to just render Calendar component
- Fixed cargo fmt/clippy formatting across codebase

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:31:51 -04:00
Connor Johnstone
e55e6bf4dd Clean up obsolete CalDAV environment variables
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
## Removed Obsolete Environment Variables:
- `CALDAV_SERVER_URL` - provided by user login
- `CALDAV_USERNAME` - provided by user login
- `CALDAV_PASSWORD` - provided by user login
- `CALDAV_TASKS_PATH` - not used in any features

## Kept with Intelligent Discovery:
- `CALDAV_CALENDAR_PATH` - optional override, defaults to smart discovery

## Changes:
### Backend
- Remove `CalDAVConfig::from_env()` method (not used in main app)
- Add `CalDAVConfig::new()` constructor with credentials
- Remove `tasks_path` field from CalDAVConfig
- Update auth service to use new constructor
- Update tests to use hardcoded test values instead of env vars
- Update debug tools to use test credentials

### Frontend
- Remove unused `config.rs` file entirely (frontend uses backend API)

## Current Authentication Flow:
1. User provides CalDAV credentials via login API
2. Backend creates CalDAVConfig dynamically from login request
3. Backend tests authentication via calendar discovery
4. Optional `CALDAV_CALENDAR_PATH` env var can override discovery
5. No environment variables required for normal operation

This simplifies deployment - users only need to provide CalDAV
credentials through the web interface, no server-side configuration required.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 19:48:13 -04:00
1fa3bf44b6 Merge pull request 'Editing Series Events via the Modal' (#5) from feature/modal-series-editing into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 3s
Reviewed-on: #5
2025-08-31 19:10:26 -04:00
77 changed files with 15158 additions and 3376 deletions

View File

@@ -38,4 +38,4 @@ calendar.db
**/tests/
# Migrations (not needed for builds)
migrations/
migrations/

View File

@@ -18,17 +18,18 @@ jobs:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
${{ vars.REGISTRY }}/connor/calendar:latest
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max

6
.gitignore vendored
View File

@@ -22,3 +22,9 @@ dist/
CLAUDE.md
data/
# SQLite database
*.db
*.db-shm
*.db-wal
calendar.db

View File

@@ -5,6 +5,11 @@
}
:80, :443 {
@backend {
path /api /api/*
}
reverse_proxy @backend calendar-backend:3000
try_files {path} /index.html
root * /srv/www
file_server
}

View File

@@ -1,96 +0,0 @@
# Build stage
# -----------------------------------------------------------
FROM rust:alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
# Install trunk ahead of the compilation. This may break and then you'll have to update the version.
RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
# Copy workspace files to maintain workspace structure
COPY Cargo.toml Cargo.lock ./
COPY calendar-models ./calendar-models
COPY frontend/Cargo.toml ./frontend/
COPY frontend/Trunk.toml ./frontend/
COPY frontend/index.html ./frontend/
COPY frontend/styles.css ./frontend/
# Create empty backend directory to satisfy workspace
RUN mkdir -p backend/src && \
printf '[package]\nname = "calendar-backend"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > backend/Cargo.toml && \
echo 'fn main() {}' > backend/src/main.rs
# Create dummy source files to build dependencies first
RUN mkdir -p frontend/src && \
echo "use web_sys::*; fn main() {}" > frontend/src/main.rs && \
echo "pub fn add(a: usize, b: usize) -> usize { a + b }" > calendar-models/src/lib.rs
# Build dependencies (this layer will be cached unless dependencies change)
RUN cargo build --release --target wasm32-unknown-unknown --bin calendar-app
# Copy actual source code and build the frontend application
RUN rm -rf frontend
COPY frontend ./frontend
RUN trunk build --release --config ./frontend/Trunk.toml
# Backend build stage
# -----------------------------------------------------------
FROM rust:alpine AS backend-builder
# Install build dependencies for backend
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
# Copy shared models
COPY calendar-models ./calendar-models
# Create empty frontend directory to satisfy workspace
RUN mkdir -p frontend/src && \
printf '[package]\nname = "calendar-app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
echo 'fn main() {}' > frontend/src/main.rs
# Create dummy backend source to build dependencies first
RUN mkdir -p backend/src && \
echo "fn main() {}" > backend/src/main.rs
# Build dependencies (this layer will be cached unless dependencies change)
COPY Cargo.toml Cargo.lock ./
COPY backend/Cargo.toml ./backend/
RUN cargo build --release
# Build the backend
COPY backend ./backend
RUN cargo build --release --bin backend
# Runtime stage
# -----------------------------------------------------------
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata
# Copy frontend files to temporary location
COPY --from=builder /app/frontend/dist /app/frontend-dist
# Copy backend binary (built in workspace root)
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
# Create startup script to copy frontend files to shared volume
RUN mkdir -p /srv/www
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
chmod +x /usr/local/bin/start.sh
# Start with script that copies frontend files then starts backend
CMD ["/usr/local/bin/start.sh"]

View File

@@ -1,13 +1,22 @@
# Modern CalDAV Web Client
# Runway
## _Passive infrastructure for life's coordination_
![Runway Screenshot](sample.png)
>[!WARNING]
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
A full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
A modern CalDAV web client built with Rust WebAssembly.
## Motivation
## The Name
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. This project aims to provide a modern, fast, and reliable web interface for CalDAV servers.
Runway embodies the concept of **passive infrastructure** — unobtrusive systems that enable better coordination without getting in the way. Planes can fly and do lots of cool things, but without runways, they can't take off or land. Similarly, calendars and scheduling tools are essential for organizing our lives, but they should not dominate our attention.
The best infrastructure is invisible when working, essential when needed, and enables rather than constrains.
## Why Runway?
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. Runway provides a modern, fast, and reliable web interface for CalDAV servers — infrastructure that just works.
## Features
@@ -29,6 +38,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
- **Real-time Updates**: Seamless synchronization with CalDAV servers
- **Timezone Aware**: Proper local time display with UTC storage
### User Experience
- **Persistent Preferences**: Settings sync across devices and sessions
- **Remember Me**: Optional server/username remembering for convenience
- **Session Management**: Secure session tokens with automatic expiry
- **Cross-Device Sync**: User preferences stored in database, not just browser
## Architecture
### Frontend (Yew WebAssembly)
@@ -40,7 +55,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
### Backend (Axum)
- **Framework**: Axum async web framework with CORS support
- **Authentication**: JWT token management and validation
- **Authentication**: SQLite-backed session management with JWT tokens
- **Database**: SQLite for user preferences and session storage
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
- **API Design**: RESTful endpoints following calendar operation patterns
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
@@ -54,12 +70,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
## Getting Started
### Prerequisites
### Docker Deployment (Recommended)
The easiest way to run Runway is using Docker Compose:
1. **Clone the repository**:
```bash
git clone <repository-url>
cd calendar
```
2. **Start the application**:
```bash
docker compose up
```
3. **Access the application** at `http://localhost`
The Docker setup includes:
- **Automatic database migrations** on startup
- **Persistent data storage** in `./data/db/` volume
- **Frontend served via Caddy** on port 80
- **Backend API** accessible on port 3000
### Development Setup
#### Prerequisites
- Rust (latest stable version)
- Trunk (`cargo install trunk`)
### Development Setup
#### Local Development
1. **Start the backend server** (serves API at http://localhost:3000):
```bash
@@ -73,6 +113,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
3. **Access the application** at `http://localhost:8080`
#### Database Setup
For local development, run the database migrations:
```bash
# Install sqlx-cli if not already installed
cargo install sqlx-cli --features sqlite
# Run migrations
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
```
### Building for Production
```bash
@@ -120,7 +171,7 @@ calendar/
This client is designed to work with any RFC-compliant CalDAV server:
- **Baikal** - ✅ Fully tested with complete event and recurrence support
- **Nextcloud** - 🚧 Planned compatibility with calendar app
- **Nextcloud** - 🚧 Planned compatibility with Nextcloud calendar
- **Radicale** - 🚧 Planned lightweight CalDAV server support
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
- **Google Calendar** - 🚧 Planned CalDAV API compatibility

View File

@@ -34,6 +34,10 @@ base64 = "0.21"
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]
tokio = { version = "1.0", features = ["macros", "rt"] }
reqwest = { version = "0.11", features = ["json"] }

64
backend/Dockerfile Normal file
View 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"]

View 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)
);

View 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);

View 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
);

View File

@@ -0,0 +1,2 @@
-- Add calendar style preference to user preferences
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';

View File

@@ -1,27 +1,30 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
use crate::config::CalDAVConfig;
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)]
pub struct Claims {
pub username: String,
pub server_url: String,
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
}
#[derive(Clone)]
pub struct AuthService {
jwt_secret: String,
db: Database,
}
impl AuthService {
pub fn new(jwt_secret: String) -> Self {
Self { jwt_secret }
pub fn new(jwt_secret: String, db: Database) -> Self {
Self { jwt_secret, db }
}
/// Authenticate user directly against CalDAV server
@@ -31,36 +34,74 @@ impl AuthService {
println!("✅ Input validation passed");
// Create CalDAV config with provided credentials
let caldav_config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
let caldav_config = CalDAVConfig::new(
request.server_url.clone(),
request.username.clone(),
request.password.clone(),
);
println!("📝 Created CalDAV config");
// Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
// Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await {
Ok(calendars) => {
println!("✅ Authentication successful! Found {} calendars", calendars.len());
// Authentication successful, generate JWT token
let token = self.generate_token(&request.username, &request.server_url)?;
println!(
" Authentication successful! Found {} calendars",
calendars.len()
);
// Find or create user in database
let user_repo = UserRepository::new(&self.db);
let user = user_repo
.find_or_create(&request.username, &request.server_url)
.await
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
// Generate JWT token
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
// Generate session token
let session_token = format!("sess_{}", Uuid::new_v4());
// Create session in database
let session = Session::new(user.id.clone(), session_token.clone(), 24);
let session_repo = SessionRepository::new(&self.db);
session_repo
.create(&session)
.await
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
// Get or create user preferences
let prefs_repo = PreferencesRepository::new(&self.db);
let preferences = prefs_repo
.get_or_create(&user.id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
Ok(AuthResponse {
token,
token: jwt_token,
session_token,
username: request.username,
server_url: request.server_url,
preferences: UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
},
})
}
Err(err) => {
println!("❌ Authentication failed: {:?}", err);
// Authentication failed
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
Err(ApiError::Unauthorized(
"Invalid CalDAV credentials or server unavailable".to_string(),
))
}
}
}
@@ -71,16 +112,18 @@ impl AuthService {
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
pub fn caldav_config_from_token(
&self,
token: &str,
password: &str,
) -> Result<CalDAVConfig, ApiError> {
let claims = self.verify_token(token)?;
Ok(CalDAVConfig {
server_url: claims.server_url,
username: claims.username,
password: password.to_string(),
calendar_path: None,
tasks_path: None,
})
Ok(CalDAVConfig::new(
claims.server_url,
claims.username,
password.to_string(),
))
}
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
@@ -97,8 +140,11 @@ impl AuthService {
}
// Basic URL validation
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()));
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(),
));
}
Ok(())
@@ -135,4 +181,33 @@ impl AuthService {
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

View File

@@ -1,132 +1,97 @@
use base64::prelude::*;
use serde::{Deserialize, Serialize};
use std::env;
use base64::prelude::*;
/// Configuration for CalDAV server connection and authentication.
///
///
/// This struct holds all the necessary information to connect to a CalDAV server,
/// including server URL, credentials, and optional collection paths.
///
///
/// # Security Note
///
///
/// The password field contains sensitive information and should be handled carefully.
/// This struct implements `Debug` but in production, consider implementing a custom
/// `Debug` that masks the password field.
///
///
/// # Example
///
///
/// ```rust
/// # use calendar_backend::config::CalDAVConfig;
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// // Load configuration from environment variables
/// let config = CalDAVConfig::from_env()?;
///
/// let config = CalDAVConfig {
/// server_url: "https://caldav.example.com".to_string(),
/// username: "user@example.com".to_string(),
/// password: "password".to_string(),
/// calendar_path: None,
/// tasks_path: None,
/// };
///
/// // Use the configuration for HTTP requests
/// let auth_header = format!("Basic {}", config.get_basic_auth());
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalDAVConfig {
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
pub server_url: String,
/// Username for authentication with the CalDAV server
pub username: String,
/// Password for authentication with the CalDAV server
///
///
/// **Security Note**: This contains sensitive information
pub password: String,
/// Optional path to the calendar collection on the server
///
/// If not provided, the client will need to discover available calendars
///
/// If not provided, the client will discover available calendars
/// through CalDAV PROPFIND requests
pub calendar_path: Option<String>,
/// Optional path to the tasks/todo collection on the server
///
/// Some CalDAV servers store tasks separately from calendar events
pub tasks_path: Option<String>,
}
impl CalDAVConfig {
/// Creates a new CalDAVConfig by loading values from environment variables.
///
/// This method will attempt to load a `.env` file from the current directory
/// and then read the following required environment variables:
///
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
/// - `CALDAV_USERNAME`: Username for authentication
/// - `CALDAV_PASSWORD`: Password for authentication
///
/// Optional environment variables:
///
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
///
/// # Errors
///
/// Returns `ConfigError::MissingVar` if any required environment variable
/// is not set or cannot be read.
///
/// Creates a new CalDAVConfig with the given credentials.
///
/// # Arguments
///
/// * `server_url` - The base URL of the CalDAV server
/// * `username` - Username for authentication
/// * `password` - Password for authentication
///
/// # Example
///
///
/// ```rust
/// # use calendar_backend::config::CalDAVConfig;
///
/// match CalDAVConfig::from_env() {
/// Ok(config) => {
/// println!("Loaded config for server: {}", config.server_url);
/// }
/// Err(e) => {
/// eprintln!("Failed to load config: {}", e);
/// }
/// }
/// let config = CalDAVConfig::new(
/// "https://caldav.example.com".to_string(),
/// "user@example.com".to_string(),
/// "password".to_string()
/// );
/// ```
pub fn from_env() -> Result<Self, ConfigError> {
// Attempt to load .env file, but don't fail if it doesn't exist
dotenvy::dotenv().ok();
let server_url = env::var("CALDAV_SERVER_URL")
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
let username = env::var("CALDAV_USERNAME")
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
let password = env::var("CALDAV_PASSWORD")
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
// Optional paths - it's fine if these are not set
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
Ok(CalDAVConfig {
pub fn new(server_url: String, username: String, password: String) -> Self {
Self {
server_url,
username,
password,
calendar_path,
tasks_path,
})
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
}
}
/// Generates a Base64-encoded string for HTTP Basic Authentication.
///
///
/// This method combines the username and password in the format
/// `username:password` and encodes it using Base64, which is the
/// standard format for the `Authorization: Basic` HTTP header.
///
///
/// # Returns
///
///
/// A Base64-encoded string that can be used directly in the
/// `Authorization` header: `Authorization: Basic <returned_value>`
///
///
/// # Example
///
///
/// ```rust
/// # use calendar_backend::config::CalDAVConfig;
///
///
/// let config = CalDAVConfig {
/// server_url: "https://example.com".to_string(),
/// username: "user".to_string(),
@@ -134,7 +99,7 @@ impl CalDAVConfig {
/// calendar_path: None,
/// tasks_path: None,
/// };
///
///
/// let auth_value = config.get_basic_auth();
/// let auth_header = format!("Basic {}", auth_value);
/// ```
@@ -148,15 +113,15 @@ impl CalDAVConfig {
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// A required environment variable is missing or cannot be read.
///
///
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
/// or `CALDAV_PASSWORD`) is not set.
#[error("Missing environment variable: {0}")]
MissingVar(String),
/// The configuration contains invalid or malformed values.
///
///
/// This could include malformed URLs, invalid authentication credentials,
/// or other configuration issues that prevent proper CalDAV operation.
#[error("Invalid configuration: {0}")]
@@ -174,7 +139,6 @@ mod tests {
username: "testuser".to_string(),
password: "testpass".to_string(),
calendar_path: None,
tasks_path: None,
};
let auth = config.get_basic_auth();
@@ -183,18 +147,21 @@ mod tests {
}
/// Integration test that authenticates with the actual Baikal CalDAV server
///
///
/// This test requires a valid .env file with:
/// - CALDAV_SERVER_URL
/// - CALDAV_USERNAME
/// - CALDAV_PASSWORD
///
///
/// Run with: `cargo test test_baikal_auth`
#[tokio::test]
async fn test_baikal_auth() {
// Load config from .env
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
// Use test config - update these values to test with real server
let config = CalDAVConfig::new(
"https://example.com".to_string(),
"test_user".to_string(),
"test_password".to_string(),
);
println!("Testing authentication to: {}", config.server_url);
@@ -204,7 +171,10 @@ mod tests {
// Make a simple OPTIONS request to test authentication
let response = client
.request(reqwest::Method::OPTIONS, &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header(
"Authorization",
format!("Basic {}", config.get_basic_auth()),
)
.header("User-Agent", "calendar-app/0.1.0")
.send()
.await
@@ -222,9 +192,9 @@ mod tests {
// For Baikal/CalDAV servers, we should see DAV headers
assert!(
response.headers().contains_key("dav") ||
response.headers().contains_key("DAV") ||
response.status().is_success(),
response.headers().contains_key("dav")
|| response.headers().contains_key("DAV")
|| response.status().is_success(),
"Server doesn't appear to be a CalDAV server - missing DAV headers"
);
@@ -232,14 +202,18 @@ mod tests {
}
/// Test making a PROPFIND request to discover calendars
///
///
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
///
///
/// Run with: `cargo test test_propfind_calendars`
#[tokio::test]
async fn test_propfind_calendars() {
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
// Use test config - update these values to test with real server
let config = CalDAVConfig::new(
"https://example.com".to_string(),
"test_user".to_string(),
"test_password".to_string(),
);
let client = reqwest::Client::new();
@@ -255,8 +229,14 @@ mod tests {
</d:propfind>"#;
let response = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.request(
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
&config.server_url,
)
.header(
"Authorization",
format!("Basic {}", config.get_basic_auth()),
)
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
@@ -267,7 +247,7 @@ mod tests {
let status = response.status();
println!("PROPFIND Response status: {}", status);
let body = response.text().await.expect("Failed to read response body");
println!("PROPFIND Response body: {}", body);
@@ -279,8 +259,11 @@ mod tests {
);
// 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!");
}
}
}

309
backend/src/db.rs Normal file
View File

@@ -0,0 +1,309 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{FromRow, Result};
use std::sync::Arc;
use uuid::Uuid;
/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
pool: Arc<SqlitePool>,
}
impl Database {
/// Create a new database connection pool
pub async fn new(database_url: &str) -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self {
pool: Arc::new(pool),
})
}
/// Get a reference to the connection pool
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
/// User model representing a CalDAV user
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String, // UUID as string for SQLite
pub username: String,
pub server_url: String,
pub created_at: DateTime<Utc>,
}
impl User {
/// Create a new user with generated UUID
pub fn new(username: String, server_url: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
username,
server_url,
created_at: Utc::now(),
}
}
}
/// Session model for user sessions
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub id: String, // UUID as string
pub user_id: String, // Foreign key to User
pub token: String, // Session token
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_accessed: DateTime<Utc>,
}
impl Session {
/// Create a new session for a user
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id,
token,
created_at: now,
expires_at: now + chrono::Duration::hours(expires_in_hours),
last_accessed: now,
}
}
/// Check if the session has expired
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
/// User preferences model
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UserPreferences {
pub user_id: String,
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>, // JSON string
pub updated_at: DateTime<Utc>,
}
impl UserPreferences {
/// Create default preferences for a new user
pub fn default_for_user(user_id: String) -> Self {
Self {
user_id,
calendar_selected_date: None,
calendar_time_increment: Some(15),
calendar_view_mode: Some("month".to_string()),
calendar_theme: Some("light".to_string()),
calendar_style: Some("default".to_string()),
calendar_colors: None,
updated_at: Utc::now(),
}
}
}
/// Repository for User operations
pub struct UserRepository<'a> {
db: &'a Database,
}
impl<'a> UserRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Find or create a user by username and server URL
pub async fn find_or_create(
&self,
username: &str,
server_url: &str,
) -> Result<User> {
// Try to find existing user
let existing = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE username = ? AND server_url = ?",
)
.bind(username)
.bind(server_url)
.fetch_optional(self.db.pool())
.await?;
if let Some(user) = existing {
Ok(user)
} else {
// Create new user
let user = User::new(username.to_string(), server_url.to_string());
sqlx::query(
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
)
.bind(&user.id)
.bind(&user.username)
.bind(&user.server_url)
.bind(&user.created_at)
.execute(self.db.pool())
.await?;
Ok(user)
}
}
/// Find a user by ID
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(self.db.pool())
.await
}
}
/// Repository for Session operations
pub struct SessionRepository<'a> {
db: &'a Database,
}
impl<'a> SessionRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Create a new session
pub async fn create(&self, session: &Session) -> Result<()> {
sqlx::query(
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&session.id)
.bind(&session.user_id)
.bind(&session.token)
.bind(&session.created_at)
.bind(&session.expires_at)
.bind(&session.last_accessed)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Find a session by token and update last_accessed
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
.bind(token)
.fetch_optional(self.db.pool())
.await?;
if let Some(ref s) = session {
if !s.is_expired() {
// Update last_accessed time
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
.bind(Utc::now())
.bind(&s.id)
.execute(self.db.pool())
.await?;
}
}
Ok(session)
}
/// Delete a session (logout)
pub async fn delete(&self, token: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Clean up expired sessions
pub async fn cleanup_expired(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
.bind(Utc::now())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected())
}
}
/// Repository for UserPreferences operations
pub struct PreferencesRepository<'a> {
db: &'a Database,
}
impl<'a> PreferencesRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Get user preferences, creating defaults if not exist
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
let existing = sqlx::query_as::<_, UserPreferences>(
"SELECT * FROM user_preferences WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(self.db.pool())
.await?;
if let Some(prefs) = existing {
Ok(prefs)
} else {
// Create default preferences
let prefs = UserPreferences::default_for_user(user_id.to_string());
sqlx::query(
"INSERT INTO user_preferences
(user_id, calendar_selected_date, calendar_time_increment,
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&prefs.user_id)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(&prefs.updated_at)
.execute(self.db.pool())
.await?;
Ok(prefs)
}
}
/// Update user preferences
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
sqlx::query(
"UPDATE user_preferences
SET calendar_selected_date = ?, calendar_time_increment = ?,
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
calendar_colors = ?, updated_at = ?
WHERE user_id = ?",
)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(Utc::now())
.bind(&prefs.user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
}

View File

@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
use crate::config::CalDAVConfig;
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
let config = CalDAVConfig::from_env()?;
// 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);
println!("=== DEBUG: CalDAV Fetch ===");

View File

@@ -2,9 +2,11 @@
mod auth;
mod calendar;
mod events;
mod preferences;
mod series;
pub use auth::{login, verify_token, get_user_info};
pub use auth::{get_user_info, login, verify_token};
pub use calendar::{create_calendar, delete_calendar};
pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
pub use series::{create_event_series, update_event_series, delete_event_series};
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
pub use preferences::{get_preferences, logout, update_preferences};
pub use series::{create_event_series, delete_event_series, update_event_series};

View File

@@ -1,33 +1,37 @@
use axum::{
extract::State,
http::HeaderMap,
response::Json,
};
use axum::{extract::State, http::HeaderMap, response::Json};
use std::sync::Arc;
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
use crate::calendar::CalDAVClient;
use crate::config::CalDAVConfig;
use crate::{
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
AppState,
};
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
let auth_header = headers.get("authorization")
let auth_header = headers
.get("authorization")
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
let auth_str = auth_header.to_str()
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
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()))
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")
let password_header = headers
.get("x-caldav-password")
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
password_header.to_str()
password_header
.to_str()
.map(|s| s.to_string())
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
}
@@ -40,39 +44,13 @@ pub async fn login(
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
// Basic validation
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string()));
}
println!("✅ Input validation passed");
// Create a token using the auth service
println!("📝 Created CalDAV config");
// First verify the credentials are valid by attempting to discover calendars
let config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
let client = CalDAVClient::new(config);
client.discover_calendars()
.await
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
println!("🔗 Created CalDAV client, attempting to discover calendars...");
Ok(Json(AuthResponse {
token,
username: request.username,
server_url: request.server_url,
}))
// 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(
@@ -81,7 +59,7 @@ pub async fn verify_token(
) -> 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 })))
}
@@ -91,26 +69,33 @@ pub async fn get_user_info(
) -> 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 config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config.clone());
// Discover calendars
let calendar_paths = client.discover_calendars()
let calendar_paths = client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
CalendarInfo {
println!(
"✅ Authentication successful! Found {} calendars",
calendar_paths.len()
);
let calendars: Vec<CalendarInfo> = calendar_paths
.iter()
.map(|path| CalendarInfo {
path: path.clone(),
display_name: extract_calendar_name(path),
color: generate_calendar_color(path),
}
}).collect();
})
.collect();
Ok(Json(UserInfo {
username: config.username,
server_url: config.server_url,
@@ -125,15 +110,14 @@ fn generate_calendar_color(path: &str) -> String {
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"
"#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()
}
@@ -156,4 +140,4 @@ fn extract_calendar_name(path: &str) -> String {
})
.collect::<Vec<String>>()
.join(" ")
}
}

View File

@@ -1,12 +1,14 @@
use axum::{
extract::State,
http::HeaderMap,
response::Json,
};
use axum::{extract::State, http::HeaderMap, response::Json};
use std::sync::Arc;
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
use crate::calendar::CalDAVClient;
use crate::{
models::{
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
DeleteCalendarResponse,
},
AppState,
};
use super::auth::{extract_bearer_token, extract_password_header};
@@ -20,22 +22,36 @@ pub async fn create_calendar(
// Validate request
if request.name.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
return Err(ApiError::BadRequest(
"Calendar name is required".to_string(),
));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Create calendar on CalDAV server
match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await {
match client
.create_calendar(
&request.name,
request.description.as_deref(),
request.color.as_deref(),
)
.await
{
Ok(_) => Ok(Json(CreateCalendarResponse {
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)))
Err(ApiError::Internal(format!(
"Failed to create calendar: {}",
e
)))
}
}
}
@@ -50,11 +66,15 @@ pub async fn delete_calendar(
// Validate request
if request.path.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
return Err(ApiError::BadRequest(
"Calendar path is required".to_string(),
));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Delete calendar on CalDAV server
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
})),
Err(e) => {
eprintln!("Failed to delete calendar: {}", e);
Err(ApiError::Internal(format!("Failed to delete calendar: {}", e)))
Err(ApiError::Internal(format!(
"Failed to delete calendar: {}",
e
)))
}
}
}
}

View File

@@ -1,15 +1,23 @@
use axum::{
extract::{State, Query, Path},
extract::{Path, Query, State},
http::HeaderMap,
response::Json,
};
use chrono::Datelike;
use serde::Deserialize;
use std::sync::Arc;
use chrono::Datelike;
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
use crate::calendar::{CalDAVClient, CalendarEvent};
use crate::{
models::{
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
UpdateEventRequest, UpdateEventResponse,
},
AppState,
};
use calendar_models::{
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
};
use super::auth::{extract_bearer_token, extract_password_header};
@@ -28,20 +36,23 @@ pub async fn get_calendar_events(
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 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()
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 {
@@ -54,21 +65,68 @@ pub async fn get_calendar_events(
all_events.extend(events);
}
Err(e) => {
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
eprintln!(
"Failed to fetch events from calendar {}: {}",
calendar_path, e
);
// Continue with other calendars instead of failing completely
}
}
}
// If year and month are specified, filter events
if let (Some(year), Some(month)) = (params.year, params.month) {
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let month_start = target_date;
let month_end = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
} - chrono::Duration::days(1);
all_events.retain(|event| {
let event_year = event.dtstart.year();
let event_month = event.dtstart.month();
event_year == year && event_month == month
let event_date = event.dtstart.date_naive();
// For non-recurring events, check if the event date is within the month
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
let event_year = event.dtstart.year();
let event_month = event.dtstart.month();
return event_year == year && event_month == month;
}
// For recurring events, check if they could have instances in this month
// Include if:
// 1. The event starts before or during the requested month
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
if event_date > month_end {
// Event starts after the requested month
return false;
}
// Check UNTIL date in RRULE if present
if let Some(ref rrule) = event.rrule {
if let Some(until_pos) = rrule.find("UNTIL=") {
let until_part = &rrule[until_pos + 6..];
let until_end = until_part.find(';').unwrap_or(until_part.len());
let until_str = &until_part[..until_end];
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
if until_str.len() >= 8 {
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
if until_date < month_start {
// Recurring event ended before the requested month
return false;
}
}
}
}
}
// Include the recurring event - the frontend will do proper expansion
true
});
}
println!("📅 Returning {} events", all_events.len());
Ok(Json(all_events))
}
@@ -80,16 +138,19 @@ pub async fn refresh_event(
) -> 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 config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars
let calendar_paths = client.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 {
@@ -97,18 +158,25 @@ pub async fn refresh_event(
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> {
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<_>>());
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 {
@@ -118,22 +186,25 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
}
}
}
// 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);
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)
}
@@ -146,41 +217,63 @@ pub async fn delete_event(
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 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)))? {
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_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
let exception_utc = if let Ok(date) =
chrono::DateTime::parse_from_rfc3339(occurrence_date)
{
// RFC3339 format (with time and timezone)
date.with_timezone(&chrono::Utc)
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
} else if let Ok(naive_date) =
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
{
// Simple date format (YYYY-MM-DD)
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)));
};
let mut updated_event = event;
updated_event.exdate.push(exception_utc);
println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid);
println!(
"🔄 Adding EXDATE {} to recurring event {}",
exception_utc.format("%Y%m%dT%H%M%SZ"),
updated_event.uid
);
// Update the event with the new EXDATE
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
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)))?;
.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(),
@@ -191,13 +284,16 @@ pub async fn delete_event(
} else {
// Non-recurring event - delete the entire event
println!("🗑️ Deleting non-recurring event: {}", event.uid);
client.delete_event(&request.calendar_path, &request.event_href)
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
.map_err(|e| {
ApiError::Internal(format!("Failed to delete event: {}", e))
})?;
println!("✅ Successfully deleted non-recurring event");
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
@@ -206,70 +302,99 @@ pub async fn delete_event(
} 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(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) {
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") {
} 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)));
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"));
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)
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)))?;
.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(),
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)
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
.map_err(|e| {
ApiError::Internal(format!("Failed to delete event: {}", e))
})?;
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
}))
}
} else {
Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string()))
Err(ApiError::BadRequest(
"Occurrence date is required for following deletion".to_string(),
))
}
} else {
Err(ApiError::NotFound("Event not found".to_string()))
}
},
}
"delete_series" | _ => {
// Delete the entire event/series
client.delete_event(&request.calendar_path, &request.event_href)
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
@@ -283,9 +408,11 @@ pub async fn create_event(
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);
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)?;
@@ -294,13 +421,17 @@ pub async fn create_event(
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()));
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 config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Determine which calendar to use
@@ -308,31 +439,55 @@ pub async fn create_event(
path
} else {
// Use the first available calendar
let calendar_paths = client.discover_calendars()
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()));
return Err(ApiError::BadRequest(
"No calendars available for event creation".to_string(),
));
}
calendar_paths[0].clone()
};
// Parse dates and times
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Generate a unique UID for the event
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
let uid = format!(
"{}-{}",
uuid::Uuid::new_v4(),
chrono::Utc::now().timestamp()
);
// Parse status
let status = match request.status.to_lowercase().as_str() {
@@ -352,7 +507,8 @@ pub async fn create_event(
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request.attendees
request
.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
@@ -363,7 +519,8 @@ pub async fn create_event(
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request.categories
request
.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
@@ -399,10 +556,11 @@ pub async fn create_event(
"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
let selected_days: Vec<&str> = request
.recurrence_days
.iter()
.enumerate()
.filter_map(|(i, &selected)| {
@@ -416,20 +574,20 @@ pub async fn create_event(
5 => "FR", // Friday
6 => "SA", // Saturday
_ => return None,
})
} else {
None
}
})
.collect();
})
} else {
None
}
})
.collect();
if !selected_days.is_empty() {
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
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,
@@ -439,15 +597,27 @@ pub async fn create_event(
// Create the VEvent struct (RFC 5545 compliant)
let mut event = VEvent::new(uid, start_datetime);
event.dtend = Some(end_datetime);
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.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 {
event.organizer = if request.organizer.trim().is_empty() {
None
} else {
Some(CalendarUser {
cal_address: request.organizer,
common_name: None,
@@ -456,41 +626,53 @@ pub async fn create_event(
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.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.into_iter().map(|reminder| VAlarm {
action: AlarmAction::Display,
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
duration: None,
repeat: None,
description: reminder.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
}).collect();
event.alarms = alarms
.into_iter()
.map(|reminder| VAlarm {
action: AlarmAction::Display,
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
-reminder.minutes_before as i64,
)),
duration: None,
repeat: None,
description: reminder.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
})
.collect();
event.calendar_path = Some(calendar_path.clone());
// Create the event on the CalDAV server
let event_href = client.create_event(&calendar_path, &event)
let event_href = client
.create_event(&calendar_path, &event)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
println!(
"✅ Event created successfully with UID: {} at href: {}",
event.uid, event_href
);
Ok(Json(CreateEventResponse {
success: true,
@@ -505,7 +687,7 @@ pub async fn update_event(
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)?;
@@ -514,37 +696,45 @@ pub async fn update_event(
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()));
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 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()
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));
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;
@@ -553,9 +743,12 @@ pub async fn update_event(
if found_event.is_some() {
break;
}
},
}
Err(e) => {
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
eprintln!(
"Failed to fetch events from calendar {}: {}",
calendar_path, e
);
continue;
}
}
@@ -565,23 +758,52 @@ pub async fn update_event(
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
// Parse dates and times
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Update event properties
event.dtstart = start_datetime;
event.dtend = Some(end_datetime);
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.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
@@ -601,11 +823,15 @@ pub async fn update_event(
event.priority = request.priority;
// 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)
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 {
@@ -614,27 +840,32 @@ pub async fn update_event(
}))
}
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
fn parse_event_datetime(
date_str: &str,
time_str: &str,
all_day: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
// Parse the date
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
if all_day {
// For all-day events, use midnight UTC
let datetime = date.and_hms_opt(0, 0, 0)
let datetime = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
Ok(Utc.from_utc_datetime(&datetime))
} else {
// Parse the time
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
// Combine date and time
let datetime = NaiveDateTime::new(date, time);
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
Ok(Utc.from_utc_datetime(&datetime))
}
}
}

View File

@@ -0,0 +1,128 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use std::sync::Arc;
use crate::{
db::PreferencesRepository,
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
AppState,
};
/// Get user preferences
pub async fn get_preferences(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Validate session and get user ID
let user_id = state.auth_service.validate_session(session_token).await?;
// Get preferences from database
let prefs_repo = PreferencesRepository::new(&state.db);
let preferences = prefs_repo
.get_or_create(&user_id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
Ok(Json(UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
}))
}
/// Update user preferences
pub async fn update_preferences(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<UpdatePreferencesRequest>,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Validate session and get user ID
let user_id = state.auth_service.validate_session(session_token).await?;
// Update preferences in database
let prefs_repo = PreferencesRepository::new(&state.db);
let mut preferences = prefs_repo
.get_or_create(&user_id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
// Update only provided fields
if request.calendar_selected_date.is_some() {
preferences.calendar_selected_date = request.calendar_selected_date;
}
if request.calendar_time_increment.is_some() {
preferences.calendar_time_increment = request.calendar_time_increment;
}
if request.calendar_view_mode.is_some() {
preferences.calendar_view_mode = request.calendar_view_mode;
}
if request.calendar_theme.is_some() {
preferences.calendar_theme = request.calendar_theme;
}
if request.calendar_style.is_some() {
preferences.calendar_style = request.calendar_style;
}
if request.calendar_colors.is_some() {
preferences.calendar_colors = request.calendar_colors;
}
prefs_repo
.update(&preferences)
.await
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
Ok((
StatusCode::OK,
Json(UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
}),
))
}
/// Logout user
pub async fn logout(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Delete session
state.auth_service.logout(session_token).await?;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"message": "Logged out successfully"
})),
))
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,33 +3,43 @@ use axum::{
routing::{get, post},
Router,
};
use tower_http::cors::{CorsLayer, Any};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
pub mod auth;
pub mod models;
pub mod handlers;
pub mod calendar;
pub mod config;
pub mod db;
pub mod handlers;
pub mod models;
use auth::AuthService;
use db::Database;
#[derive(Clone)]
pub struct AppState {
pub auth_service: AuthService,
pub db: Database,
}
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
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
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
let auth_service = AuthService::new(jwt_secret);
let app_state = AppState { auth_service };
let auth_service = AuthService::new(jwt_secret, db.clone());
let app_state = AppState { auth_service, db };
// Build our application with routes
let app = Router::new()
@@ -46,9 +56,22 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/calendar/events/delete", post(handlers::delete_event))
.route("/api/calendar/events/: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))
.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))
.layer(
CorsLayer::new()
.allow_origin(Any)
@@ -60,7 +83,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Start server
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("📡 Server listening on http://0.0.0.0:3000");
axum::serve(listener, app).await?;
Ok(())
@@ -76,4 +99,4 @@ async fn health_check() -> Json<serde_json::Value> {
"service": "calendar-backend",
"version": "0.1.0"
}))
}
}

View File

@@ -4,4 +4,4 @@ use calendar_backend::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_server().await
}
}

View File

@@ -16,8 +16,30 @@ pub struct CalDAVLoginRequest {
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub session_token: String,
pub username: String,
pub server_url: String,
pub preferences: UserPreferencesResponse,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPreferencesResponse {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePreferencesRequest {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -76,21 +98,21 @@ pub struct DeleteEventResponse {
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 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 reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
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 reminder: String, // reminder type
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
}
@@ -103,24 +125,24 @@ pub struct CreateEventResponse {
#[derive(Debug, Deserialize)]
pub struct UpdateEventRequest {
pub uid: String, // Event UID to identify which event to update
pub uid: String, // Event UID to identify which event to update
pub title: String,
pub 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 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 reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
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 reminder: String, // reminder type
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 - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events
#[serde(skip_serializing_if = "Option::is_none")]
@@ -139,22 +161,22 @@ pub struct UpdateEventResponse {
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 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 reminder: String, // reminder type
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 reminder: String, // reminder type
// Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
@@ -173,33 +195,33 @@ pub struct CreateEventSeriesResponse {
#[derive(Debug, Deserialize)]
pub struct UpdateEventSeriesRequest {
pub series_uid: String, // Series UID to identify which series to update
pub series_uid: String, // Series UID to identify which series to update
pub title: String,
pub 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 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 reminder: String, // reminder type
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 reminder: String, // reminder type
// Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_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 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)
}
@@ -214,12 +236,12 @@ pub struct UpdateEventSeriesResponse {
#[derive(Debug, Deserialize)]
pub struct DeleteEventSeriesRequest {
pub series_uid: String, // Series UID to identify which series to delete
pub series_uid: String, // Series UID to identify which series to delete
pub calendar_path: String,
pub event_href: String,
// Delete scope control
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
}
@@ -274,4 +296,4 @@ impl std::fmt::Display for ApiError {
}
}
impl std::error::Error for ApiError {}
impl std::error::Error for ApiError {}

View File

@@ -1,26 +1,26 @@
use calendar_backend::AppState;
use calendar_backend::auth::AuthService;
use reqwest::Client;
use serde_json::json;
use std::time::Duration;
use tokio::time::sleep;
use axum::{
response::Json,
routing::{get, post},
Router,
};
use tower_http::cors::{CorsLayer, Any};
use calendar_backend::auth::AuthService;
use calendar_backend::AppState;
use reqwest::Client;
use serde_json::json;
use std::sync::Arc;
use std::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
@@ -33,19 +33,55 @@ mod test_utils {
.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))
.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))
.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)
@@ -58,39 +94,47 @@ mod test_utils {
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": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()),
"password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()),
"server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string())
"username": "test".to_string(),
"password": "test".to_string(),
"server_url": "https://example.com".to_string()
});
let response = self.client
let response = self
.client
.post(&format!("{}/api/auth/login", self.base_url))
.json(&login_payload)
.send()
.await
.expect("Failed to send login request");
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
assert!(
response.status().is_success(),
"Login failed with status: {}",
response.status()
);
let login_response: serde_json::Value = response.json().await.unwrap();
login_response["token"].as_str().expect("Login response should contain token").to_string()
login_response["token"]
.as_str()
.expect("Login response should contain token")
.to_string()
}
}
async fn root() -> &'static str {
"Calendar Backend API v0.1.0"
}
@@ -106,26 +150,27 @@ mod test_utils {
#[cfg(test)]
mod tests {
use super::*;
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
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");
}
@@ -133,33 +178,42 @@ mod tests {
#[tokio::test]
async fn test_auth_login() {
let server = TestServer::start().await;
// Load credentials from .env
dotenvy::dotenv().ok();
let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string());
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string());
// 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
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());
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");
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");
}
@@ -167,52 +221,57 @@ mod tests {
#[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
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]
#[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 = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
let response = server.client
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());
println!(
"⚠ User info test skipped (CalDAV server issues): {}",
response.status()
);
}
}
@@ -220,48 +279,59 @@ mod tests {
#[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
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
let response = server.client
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
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());
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());
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 = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
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_date": "2024-12-25",
"end_time": "11:00",
"location": "Test Location",
"all_day": false,
@@ -275,8 +345,9 @@ mod tests {
"recurrence": "none",
"recurrence_days": [false, false, false, false, false, false, false]
});
let response = server.client
let response = server
.client
.post(&format!("{}/api/calendar/events/create", server.base_url))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
@@ -284,10 +355,10 @@ mod tests {
.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();
@@ -302,47 +373,58 @@ mod tests {
#[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 = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
let password = "test".to_string();
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
let test_uid = "test-event-uid";
let response = server.client
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_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());
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
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());
assert!(
response.status() == 401 || response.status() == 400,
"Expected 401 or 400 for invalid token, got {}",
response.status()
);
println!("✓ Invalid authentication test passed");
}
@@ -350,13 +432,14 @@ mod tests {
#[tokio::test]
async fn test_missing_auth() {
let server = TestServer::start().await;
let response = server.client
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");
}
@@ -367,20 +450,20 @@ mod tests {
#[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 = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
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_date": "2024-12-25",
"end_time": "11:00",
"location": "Test Series Location",
"all_day": false,
@@ -397,19 +480,23 @@ mod tests {
"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))
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();
@@ -422,24 +509,24 @@ mod tests {
}
/// Test event series update endpoint
#[tokio::test]
#[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 = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
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_date": "2024-12-26",
"end_time": "15:00",
"location": "Updated Location",
"all_day": false,
@@ -457,27 +544,36 @@ mod tests {
"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))
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");
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)");
println!(
"⚠ Update event series test skipped (event not found - expected for test data)"
);
} else {
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
}
@@ -487,40 +583,46 @@ mod tests {
#[tokio::test]
async fn test_delete_event_series() {
let server = TestServer::start().await;
// First login to get a token
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
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))
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)");
println!(
"⚠ Delete event series test skipped (event not found - expected for test data)"
);
} else {
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
}
@@ -530,17 +632,17 @@ mod tests {
#[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_date": "2024-12-25",
"end_time": "11:00",
"location": "Test",
"all_day": false,
@@ -554,16 +656,24 @@ mod tests {
"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))
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");
assert_eq!(
response.status(),
400,
"Expected 400 for invalid update scope"
);
println!("✓ Invalid update scope test passed");
}
@@ -571,16 +681,16 @@ mod tests {
#[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_date": "2024-12-25",
"end_time": "11:00",
"location": "Test",
"all_day": false,
@@ -593,16 +703,24 @@ mod tests {
"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))
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");
assert_eq!(
response.status(),
400,
"Expected 400 for non-recurring event in series endpoint"
);
println!("✓ Non-recurring series rejection test passed");
}
}
}

View File

@@ -1,6 +1,6 @@
//! Common types and enums used across calendar components
use chrono::{DateTime, Utc, Duration};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
// ==================== ENUMS AND COMMON TYPES ====================
@@ -22,7 +22,7 @@ pub enum EventClass {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TimeTransparency {
Opaque, // OPAQUE - time is not available
Transparent, // TRANSPARENT - time is available
Transparent, // TRANSPARENT - time is available
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -64,11 +64,11 @@ pub enum AlarmAction {
#[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
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)]
@@ -78,130 +78,130 @@ pub struct Attendee {
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
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
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
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)
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
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 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
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
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)
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)
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)
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
// Categorization
pub categories: Vec<String>, // Categories (CATEGORIES)
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)
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)
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)
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
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)
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)
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)
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
pub fb_type: FreeBusyType, // Free/busy type
pub periods: Vec<Period>, // Time periods
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
#[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)
}
pub start: DateTime<Utc>, // Period start
pub end: Option<DateTime<Utc>>, // Period end
pub duration: Option<Duration>, // Period duration (alternative to end)
}

View File

@@ -1,10 +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 vevent;
pub mod common;
pub mod vevent;
pub use common::*;
pub use vevent::*;
pub use common::*;

View File

@@ -1,66 +1,66 @@
//! VEvent - RFC 5545 compliant calendar event structure
use chrono::{DateTime, Utc, Duration};
use serde::{Deserialize, Serialize};
use crate::common::*;
use chrono::{DateTime, Duration, 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
pub uid: String, // Unique identifier (UID) - REQUIRED
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
pub uid: String, // Unique identifier (UID) - REQUIRED
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
// Optional properties (commonly used)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
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)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
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)
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)
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)
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)
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
// 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)
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)
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)
// Alarms and attachments
pub alarms: Vec<VAlarm>, // VALARM components
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
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
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 {
@@ -129,7 +129,9 @@ impl VEvent {
/// 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())
self.summary
.clone()
.unwrap_or_else(|| "Untitled Event".to_string())
}
/// Helper method to get start date for UI compatibility
@@ -151,7 +153,7 @@ impl VEvent {
pub fn get_status_display(&self) -> &'static str {
match &self.status {
Some(EventStatus::Tentative) => "Tentative",
Some(EventStatus::Confirmed) => "Confirmed",
Some(EventStatus::Confirmed) => "Confirmed",
Some(EventStatus::Cancelled) => "Cancelled",
None => "Confirmed", // Default
}
@@ -180,4 +182,4 @@ impl VEvent {
Some(p) => format!("Priority {}", p),
}
}
}
}

Binary file not shown.

View File

@@ -1,22 +1,22 @@
services:
calendar-backend:
build: .
env_file:
- .env
build:
context: .
dockerfile: ./backend/Dockerfile
ports:
- "3000:3000"
volumes:
- ./data/site_dist:/srv/www
- ./data/db:/db
calendar-frontend:
image: caddy
env_file:
- .env
environment:
- BACKEND_API_URL=http://localhost:3000/api
ports:
- "80:80"
- "443:443"
volumes:
- ./data/site_dist:/srv/www:ro
- ./frontend/dist:/srv/www:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy/data:/data
- ./data/caddy/config:/config

4
deploy_frontend.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/

BIN
favicon_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

View File

@@ -1,5 +1,5 @@
[package]
name = "calendar-app"
name = "runway"
version = "0.1.0"
edition = "2021"
@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
"HtmlSelectElement",
"HtmlInputElement",
"HtmlTextAreaElement",
"HtmlLinkElement",
"HtmlHeadElement",
"Event",
"MouseEvent",
"InputEvent",

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -2,17 +2,19 @@
<html>
<head>
<meta charset="utf-8" />
<title>Calendar App</title>
<title>Runway</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base data-trunk-public-url />
<link data-trunk rel="css" href="styles.css">
<link data-trunk rel="copy-file" href="styles/google.css">
<link data-trunk rel="icon" href="favicon.ico">
</head>
<body>
<script>
console.log("HTML loaded, waiting for WASM...");
console.log("HTML fully loaded, waiting for WASM...");
window.addEventListener('TrunkApplicationStarted', () => {
console.log("Trunk application started successfully!");
});
</script>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
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)]
pub struct AuthResponse {
pub token: String,
pub session_token: String,
pub username: String,
pub server_url: String,
pub preferences: UserPreferencesResponse,
}
#[derive(Debug, Deserialize)]
@@ -34,14 +45,14 @@ impl AuthService {
let base_url = option_env!("BACKEND_API_URL")
.unwrap_or("http://localhost:3000/api")
.to_string();
Self { base_url }
}
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
self.post_json("/auth/login", &request).await
}
// Helper method for POST requests with JSON body
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
@@ -49,9 +60,9 @@ impl AuthService {
body: &T,
) -> Result<R, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let json_body = serde_json::to_string(body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let json_body =
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
let opts = RequestInit::new();
opts.set_method("POST");
@@ -62,23 +73,27 @@ impl AuthService {
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")
request
.headers()
.set("Content-Type", "application/json")
.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()
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
let text = JsFuture::from(resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text = 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")?;
let text_string = text.as_string().ok_or("Response text is not a string")?;
if resp.ok() {
serde_json::from_str::<R>(&text_string)
@@ -92,4 +107,4 @@ impl AuthService {
}
}
}
}
}

View File

@@ -1,19 +1,16 @@
use yew::prelude::*;
use chrono::{Datelike, Local, NaiveDate, Duration};
use crate::components::{
CalendarHeader, CreateEventModalV2, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::UserInfo, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
use web_sys::MouseEvent;
use crate::services::calendar_service::UserInfo;
use crate::models::ical::VEvent;
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
use gloo_storage::{LocalStorage, Storage};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarProps {
#[prop_or_default]
pub events: HashMap<NaiveDate, Vec<VEvent>>,
pub on_event_click: Callback<VEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
@@ -25,7 +22,17 @@ pub struct CalendarProps {
#[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::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
@@ -33,6 +40,12 @@ pub struct CalendarProps {
#[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
@@ -55,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
}
}
});
// 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 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)>);
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
@@ -82,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html {
15
}
});
// Fetch events when current_date changes
{
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
let current_date = current_date.clone();
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let date = *date; // Clone the date to avoid lifetime issues
if let Some(token) = auth_token {
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) =
LocalStorage::get::<String>("caldav_credentials")
{
if let Ok(credentials) =
serde_json::from_str::<serde_json::Value>(&credentials_str)
{
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
let current_year = date.year();
let current_month = date.month();
match calendar_service
.fetch_events_for_month_vevent(
&token,
&password,
current_year,
current_month,
)
.await
{
Ok(vevents) => {
let grouped_events = CalendarService::group_events_by_date(vevents);
events.set(grouped_events);
loading.set(false);
}
Err(err) => {
error.set(Some(format!("Failed to load events: {}", err)));
loading.set(false);
}
}
});
} else {
loading.set(false);
error.set(Some("No authentication token found".to_string()));
}
|| ()
});
}
// Handle event click to refresh individual events
let on_event_click = {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
Callback::from(move |event: VEvent| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
if let Some(token) = auth_token {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
let uid = event.uid.clone();
refreshing_event_uid.set(Some(uid.clone()));
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) =
LocalStorage::get::<String>("caldav_credentials")
{
if let Ok(credentials) =
serde_json::from_str::<serde_json::Value>(&credentials_str)
{
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service
.refresh_event(&token, &password, &uid)
.await
{
Ok(Some(refreshed_event)) => {
let refreshed_vevent = refreshed_event;
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
if refreshed_vevent.rrule.is_some() {
let new_occurrences =
CalendarService::expand_recurring_events(vec![
refreshed_vevent.clone(),
]);
for occurrence in new_occurrences {
let date = occurrence.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(occurrence);
}
} else {
let date = refreshed_vevent.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(refreshed_vevent);
}
events.set(updated_events);
}
Ok(None) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {}
}
refreshing_event_uid.set(None);
});
}
})
};
// Handle view mode changes - adjust current_date format when switching between month/week
{
let current_date = current_date.clone();
@@ -98,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|| {}
});
}
let on_prev = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
@@ -110,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let prev_month = *current_date - Duration::days(1);
let 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 _ = 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();
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
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()
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 _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
@@ -160,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
ViewMode::Month => {
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());
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();
@@ -179,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let _ = LocalStorage::set("calendar_time_increment", next);
})
};
// 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);
})
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::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
if let Some(callback) = &on_event_update_request {
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
}
})
Callback::from(
move |(
event,
new_start,
new_end,
preserve_rrule,
until_date,
update_scope,
occurrence_date,
): (
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)| {
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
<CalendarHeader
current_date={*current_date}
view_mode={props.view.clone()}
on_prev={on_prev}
@@ -213,9 +419,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
/>
{
match props.view {
if *loading {
html! {
<div class="calendar-loading">
<p>{"Loading calendar events..."}</p>
</div>
}
} else if let Some(err) = (*error).clone() {
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
</div>
}
} else {
match props.view {
ViewMode::Month => {
let on_day_select = {
let selected_date = selected_date.clone();
@@ -224,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
})
};
html! {
<MonthView
current_month={*current_date}
today={today}
events={props.events.clone()}
on_event_click={props.on_event_click.clone()}
refreshing_event_uid={props.refreshing_event_uid.clone()}
events={(*events).clone()}
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
<WeekView
current_date={*current_date}
today={today}
events={props.events.clone()}
on_event_click={props.on_event_click.clone()}
refreshing_event_uid={props.refreshing_event_uid.clone()}
events={(*events).clone()}
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
@@ -257,11 +476,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
time_increment={*time_increment}
/>
},
}
}
}
// Event details modal
<EventModal
<EventModal
event={(*selected_event).clone()}
on_close={{
let selected_event_clone = selected_event.clone();
@@ -270,9 +490,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
}}
/>
// Create event modal
<CreateEventModal
<CreateEventModalV2
is_open={*show_create_modal}
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
event_to_edit={None}
@@ -294,23 +514,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
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);
}
})
}}
on_update={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
show_create_modal.set(false);
create_event_data.set(None);
// TODO: Handle actual event update
})
}}
/>
</div>
}
}
}

View File

@@ -1,5 +1,5 @@
use yew::prelude::*;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarContextMenuProps {
@@ -13,14 +13,50 @@ pub struct CalendarContextMenuProps {
#[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;",
props.x, props.y
x, y
);
let on_create_event_click = {
@@ -33,9 +69,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
};
html! {
<div
<div
ref={menu_ref}
class="context-menu"
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
@@ -44,4 +80,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
</div>
</div>
}
}
}

View File

@@ -1,7 +1,7 @@
use yew::prelude::*;
use chrono::{NaiveDate, Datelike};
use crate::components::ViewMode;
use chrono::{Datelike, NaiveDate};
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarHeaderProps {
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
#[function_component(CalendarHeader)]
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
let title = format!(
"{} {}",
get_month_name(props.current_date.month()),
props.current_date.year()
);
html! {
<div class="calendar-header">
@@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
fn get_month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
10 => "October",
11 => "November",
12 => "December",
_ => "Invalid"
_ => "Invalid",
}
}
}

View File

@@ -1,13 +1,13 @@
use yew::prelude::*;
use web_sys::MouseEvent;
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 on_color_picker_toggle: Callback<String>, // calendar_path
pub available_colors: Vec<String>,
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
}
@@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
html! {
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
<span class="calendar-color"
<span class="calendar-color"
style={format!("background-color: {}", props.calendar.color)}
onclick={on_color_click}>
{
@@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
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 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)}
@@ -72,4 +72,4 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
<span class="calendar-name">{&props.calendar.display_name}</span>
</li>
}
}
}

View File

@@ -1,5 +1,5 @@
use yew::prelude::*;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct ContextMenuProps {
@@ -13,16 +13,52 @@ pub struct ContextMenuProps {
#[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;",
props.x, props.y
x, y
);
let on_delete_click = {
@@ -35,9 +71,9 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
};
html! {
<div
<div
ref={menu_ref}
class="context-menu"
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
@@ -45,4 +81,4 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
</div>
</div>
}
}
}

View File

@@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
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()));
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()));
})
};
@@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
{"×"}
</button>
</div>
<form class="modal-body" onsubmit={on_submit}>
{
if let Some(ref error) = *error_message {
@@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
html! {}
}
}
<div class="form-group">
<label for="calendar-name">{"Calendar Name *"}</label>
<input
<input
id="calendar-name"
type="text"
value={(*calendar_name).clone()}
@@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label for="calendar-description">{"Description"}</label>
<textarea
@@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-grid">
@@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
selected_color.set(Some(color.clone()));
})
};
let class_name = if is_selected {
"color-option selected"
} else {
"color-option"
let class_name = if is_selected {
"color-option selected"
} else {
"color-option"
};
html! {
<button
key={index}
@@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
</div>
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
</div>
<div class="modal-actions">
<button
type="button"
<button
type="button"
class="cancel-button"
onclick={props.on_close.reform(|_| ())}
disabled={*is_creating}
>
{"Cancel"}
</button>
<button
type="submit"
<button
type="submit"
class="create-button"
disabled={*is_creating}
>
@@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
</div>
</div>
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
use crate::components::event_form::*;
use crate::components::create_event_modal::{EventCreationData}; // Use the existing types
use crate::components::{EditAction};
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
pub selected_date: Option<chrono::NaiveDate>,
pub initial_start_time: Option<chrono::NaiveTime>,
pub initial_end_time: Option<chrono::NaiveTime>,
#[prop_or_default]
pub event_to_edit: Option<VEvent>,
#[prop_or_default]
pub edit_scope: Option<EditAction>,
}
#[function_component(CreateEventModalV2)]
pub fn create_event_modal_v2(props: &CreateEventModalProps) -> Html {
let active_tab = use_state(|| ModalTab::default());
let event_data = use_state(|| EventCreationData::default());
// Initialize data when modal opens
{
let event_data = event_data.clone();
let is_open = props.is_open;
let event_to_edit = props.event_to_edit.clone();
let selected_date = props.selected_date;
let initial_start_time = props.initial_start_time;
let initial_end_time = props.initial_end_time;
let edit_scope = props.edit_scope.clone();
let available_calendars = props.available_calendars.clone();
use_effect_with(is_open, move |&is_open| {
if is_open {
let mut data = if let Some(_event) = &event_to_edit {
// TODO: Convert VEvent to EventCreationData
EventCreationData::default()
} else if let Some(date) = selected_date {
let mut data = EventCreationData::default();
data.start_date = date;
data.end_date = date;
if let Some(start_time) = initial_start_time {
data.start_time = start_time;
}
if let Some(end_time) = initial_end_time {
data.end_time = end_time;
}
data
} else {
EventCreationData::default()
};
// Set default calendar
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
data.selected_calendar = Some(available_calendars[0].path.clone());
}
// Set edit scope if provided
if let Some(scope) = &edit_scope {
data.edit_scope = Some(scope.clone());
}
event_data.set(data);
}
|| ()
});
}
if !props.is_open {
return html! {};
}
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if e.target() == e.current_target() {
on_close.emit(());
}
})
};
let switch_to_tab = {
let active_tab = active_tab.clone();
Callback::from(move |tab: ModalTab| {
active_tab.set(tab);
})
};
let on_save = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
Callback::from(move |_: MouseEvent| {
on_create.emit((*event_data).clone());
})
};
let on_close = props.on_close.clone();
let on_close_header = on_close.clone();
let tab_props = TabProps {
data: event_data.clone(),
available_calendars: props.available_calendars.clone(),
};
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content create-event-modal">
<div class="modal-header">
<h3>
{if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }}
</h3>
<button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}>
{"×"}
</button>
</div>
<div class="modal-tabs">
<div class="tab-navigation">
<button
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
}}
>
{"Basic"}
</button>
<button
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
}}
>
{"Advanced"}
</button>
<button
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
}}
>
{"People"}
</button>
<button
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
}}
>
{"Categories"}
</button>
<button
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
}}
>
{"Location"}
</button>
<button
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
}}
>
{"Reminders"}
</button>
</div>
<div class="modal-body">
<div class="tab-content">
{
match *active_tab {
ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> },
ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> },
ModalTab::People => html! { <PeopleTab ..tab_props /> },
ModalTab::Categories => html! { <CategoriesTab ..tab_props /> },
ModalTab::Location => html! { <LocationTab ..tab_props /> },
ModalTab::Reminders => html! { <RemindersTab ..tab_props /> },
}
}
</div>
</div>
</div>
<div class="modal-footer">
<div class="modal-actions">
<button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}>
{"Cancel"}
</button>
<button class="btn btn-primary" onclick={on_save}>
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
</button>
</div>
</div>
</div>
</div>
}
}

View File

@@ -1,6 +1,6 @@
use yew::prelude::*;
use web_sys::MouseEvent;
use crate::models::ical::VEvent;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Clone, PartialEq, Debug)]
pub enum DeleteAction {
@@ -30,18 +30,64 @@ pub struct EventContextMenuProps {
#[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;",
props.x, props.y
x, y
);
// Check if the event is recurring
let is_recurring = props.event.as_ref()
let is_recurring = props
.event
.as_ref()
.map(|event| event.rrule.is_some())
.unwrap_or(false);
@@ -64,9 +110,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
};
html! {
<div
<div
ref={menu_ref}
class="context-menu"
class="context-menu"
style={style}
>
{
@@ -117,4 +163,4 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
}
</div>
}
}
}

View File

@@ -0,0 +1,109 @@
use super::types::*;
use crate::components::create_event_modal::{EventStatus, EventClass};
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlSelectElement};
use yew::prelude::*;
#[function_component(AdvancedTab)]
pub fn advanced_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_status_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.status = match select.value().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
data.set(event_data);
}
}
})
};
let on_class_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.class = match select.value().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
data.set(event_data);
}
}
})
};
let on_priority_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
let value = select.value();
event_data.priority = if value.is_empty() {
None
} else {
value.parse::<u8>().ok().filter(|&p| p <= 9)
};
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-row">
<div class="form-group">
<label for="event-status">{"Status"}</label>
<select
id="event-status"
class="form-input"
onchange={on_status_change}
>
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
</select>
</div>
<div class="form-group">
<label for="event-class">{"Privacy"}</label>
<select
id="event-class"
class="form-input"
onchange={on_class_change}
>
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="event-priority">{"Priority"}</label>
<select
id="event-priority"
class="form-input"
onchange={on_priority_change}
>
<option value="" selected={data.priority.is_none()}>{"Not set"}</option>
<option value="1" selected={data.priority == Some(1)}>{"High"}</option>
<option value="5" selected={data.priority == Some(5)}>{"Medium"}</option>
<option value="9" selected={data.priority == Some(9)}>{"Low"}</option>
</select>
<p class="form-help-text">{"Set the importance level for this event."}</p>
</div>
</div>
}
}

View File

@@ -0,0 +1,730 @@
use super::types::*;
use crate::components::create_event_modal::{EventStatus, EventClass, RecurrenceType, ReminderType};
use chrono::{Datelike, NaiveDate};
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
use yew::prelude::*;
#[function_component(BasicDetailsTab)]
pub fn basic_details_tab(props: &TabProps) -> Html {
let data = &props.data;
// Event handlers
let on_title_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.title = input.value();
if !event_data.changed_fields.contains(&"title".to_string()) {
event_data.changed_fields.push("title".to_string());
}
data.set(event_data);
}
}
})
};
let on_description_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
let mut event_data = (*data).clone();
event_data.description = textarea.value();
data.set(event_data);
}
}
})
};
let on_calendar_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
let value = select.value();
let new_calendar = if value.is_empty() { None } else { Some(value) };
if event_data.selected_calendar != new_calendar {
event_data.selected_calendar = new_calendar;
if !event_data.changed_fields.contains(&"selected_calendar".to_string()) {
event_data.changed_fields.push("selected_calendar".to_string());
}
}
data.set(event_data);
}
}
})
};
let on_all_day_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.all_day = input.checked();
data.set(event_data);
}
}
})
};
let on_recurrence_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.recurrence = match select.value().as_str() {
"daily" => RecurrenceType::Daily,
"weekly" => RecurrenceType::Weekly,
"monthly" => RecurrenceType::Monthly,
"yearly" => RecurrenceType::Yearly,
_ => RecurrenceType::None,
};
// Reset recurrence-related fields when changing type
event_data.recurrence_days = vec![false; 7];
event_data.recurrence_interval = 1;
event_data.recurrence_until = None;
event_data.recurrence_count = None;
event_data.monthly_by_day = None;
event_data.monthly_by_monthday = None;
event_data.yearly_by_month = vec![false; 12];
data.set(event_data);
}
}
})
};
let on_reminder_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
}
})
};
let on_recurrence_interval_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(interval) = input.value().parse::<u32>() {
let mut event_data = (*data).clone();
event_data.recurrence_interval = interval.max(1);
data.set(event_data);
}
}
})
};
let on_recurrence_until_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.recurrence_until = None;
} else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
event_data.recurrence_until = Some(date);
}
data.set(event_data);
}
})
};
let on_recurrence_count_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.recurrence_count = None;
} else if let Ok(count) = input.value().parse::<u32>() {
event_data.recurrence_count = Some(count.max(1));
}
data.set(event_data);
}
})
};
let on_monthly_by_monthday_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.monthly_by_monthday = None;
} else if let Ok(day) = input.value().parse::<u8>() {
if day >= 1 && day <= 31 {
event_data.monthly_by_monthday = Some(day);
event_data.monthly_by_day = None; // Clear the other option
}
}
data.set(event_data);
}
})
};
let on_monthly_by_day_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
if select.value().is_empty() || select.value() == "none" {
event_data.monthly_by_day = None;
} else {
event_data.monthly_by_day = Some(select.value());
event_data.monthly_by_monthday = None; // Clear the other option
}
data.set(event_data);
}
})
};
let on_weekday_change = {
let data = data.clone();
move |day_index: usize| {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if day_index < event_data.recurrence_days.len() {
event_data.recurrence_days[day_index] = input.checked();
data.set(event_data);
}
}
})
}
};
let on_yearly_month_change = {
let data = data.clone();
move |month_index: usize| {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if month_index < event_data.yearly_by_month.len() {
event_data.yearly_by_month[month_index] = input.checked();
data.set(event_data);
}
}
})
}
};
let on_start_date_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
let mut event_data = (*data).clone();
event_data.start_date = date;
data.set(event_data);
}
}
})
};
let on_start_time_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut event_data = (*data).clone();
event_data.start_time = time;
data.set(event_data);
}
}
})
};
let on_end_date_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
let mut event_data = (*data).clone();
event_data.end_date = date;
data.set(event_data);
}
}
})
};
let on_end_time_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut event_data = (*data).clone();
event_data.end_time = time;
data.set(event_data);
}
}
})
};
let on_location_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.location = input.value();
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-title">{"Event Title *"}</label>
<input
type="text"
id="event-title"
class="form-input"
value={data.title.clone()}
oninput={on_title_input}
placeholder="Add a title"
required=true
/>
</div>
<div class="form-group">
<label for="event-description">{"Description"}</label>
<textarea
id="event-description"
class="form-input"
value={data.description.clone()}
oninput={on_description_input}
placeholder="Add a description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="event-calendar">{"Calendar"}</label>
<select
id="event-calendar"
class="form-input"
onchange={on_calendar_change}
>
<option value="">{"Select Calendar"}</option>
{
props.available_calendars.iter().map(|calendar| {
html! {
<option
key={calendar.path.clone()}
value={calendar.path.clone()}
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
>
{&calendar.display_name}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
checked={data.all_day}
onchange={on_all_day_change}
/>
{" All Day"}
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-recurrence-basic">{"Repeat"}</label>
<select
id="event-recurrence-basic"
class="form-input"
onchange={on_recurrence_change}
>
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
</select>
</div>
<div class="form-group">
<label for="event-reminder-basic">{"Reminder"}</label>
<select
id="event-reminder-basic"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
</select>
</div>
</div>
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
if matches!(data.recurrence, RecurrenceType::Weekly) {
<div class="form-group">
<label>{"Repeat on"}</label>
<div class="weekday-selection">
{
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
.iter()
.enumerate()
.map(|(i, day)| {
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
let on_change = on_weekday_change(i);
html! {
<label key={i} class="weekday-checkbox">
<input
type="checkbox"
checked={day_checked}
onchange={on_change}
/>
<span class="weekday-label">{day}</span>
</label>
}
})
.collect::<Html>()
}
</div>
</div>
}
if !matches!(data.recurrence, RecurrenceType::None) {
<div class="recurrence-options">
<div class="form-row">
<div class="form-group">
<label for="recurrence-interval">{"Every"}</label>
<div class="interval-input">
<input
id="recurrence-interval"
type="number"
class="form-input"
value={data.recurrence_interval.to_string()}
min="1"
max="999"
onchange={on_recurrence_interval_change}
/>
<span class="interval-unit">
{match data.recurrence {
RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" },
RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" },
RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" },
RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" },
RecurrenceType::None => "",
}}
</span>
</div>
</div>
<div class="form-group">
<label>{"Ends"}</label>
<div class="end-options">
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="never"
checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_until = None;
new_data.recurrence_count = None;
data.set(new_data);
})
}}
/>
{"Never"}
</label>
</div>
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="until"
checked={data.recurrence_until.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_count = None;
new_data.recurrence_until = Some(new_data.start_date);
data.set(new_data);
})
}}
/>
{"Until"}
</label>
<input
type="date"
class="form-input"
value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()}
onchange={on_recurrence_until_change}
/>
</div>
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="count"
checked={data.recurrence_count.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_until = None;
new_data.recurrence_count = Some(10); // Default count
data.set(new_data);
})
}}
/>
{"After"}
</label>
<input
type="number"
class="form-input count-input"
value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()}
min="1"
max="999"
placeholder="1"
onchange={on_recurrence_count_change}
/>
<span class="count-unit">{"occurrences"}</span>
</div>
</div>
</div>
</div>
// Monthly specific options
if matches!(data.recurrence, RecurrenceType::Monthly) {
<div class="form-group">
<label>{"Repeat by"}</label>
<div class="monthly-options">
<div class="monthly-option">
<label class="radio-label">
<input
type="radio"
name="monthly-type"
checked={data.monthly_by_monthday.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.monthly_by_day = None;
new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8);
data.set(new_data);
})
}}
/>
{"Day of month:"}
</label>
<input
type="number"
class="form-input day-input"
value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())}
min="1"
max="31"
onchange={on_monthly_by_monthday_change}
/>
</div>
<div class="monthly-option">
<label class="radio-label">
<input
type="radio"
name="monthly-type"
checked={data.monthly_by_day.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.monthly_by_monthday = None;
new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday
data.set(new_data);
})
}}
/>
{"Day of week:"}
</label>
<select
class="form-input"
value={data.monthly_by_day.clone().unwrap_or_default()}
onchange={on_monthly_by_day_change}
>
<option value="none">{"Select..."}</option>
<option value="1MO">{"First Monday"}</option>
<option value="1TU">{"First Tuesday"}</option>
<option value="1WE">{"First Wednesday"}</option>
<option value="1TH">{"First Thursday"}</option>
<option value="1FR">{"First Friday"}</option>
<option value="1SA">{"First Saturday"}</option>
<option value="1SU">{"First Sunday"}</option>
<option value="2MO">{"Second Monday"}</option>
<option value="2TU">{"Second Tuesday"}</option>
<option value="2WE">{"Second Wednesday"}</option>
<option value="2TH">{"Second Thursday"}</option>
<option value="2FR">{"Second Friday"}</option>
<option value="2SA">{"Second Saturday"}</option>
<option value="2SU">{"Second Sunday"}</option>
<option value="3MO">{"Third Monday"}</option>
<option value="3TU">{"Third Tuesday"}</option>
<option value="3WE">{"Third Wednesday"}</option>
<option value="3TH">{"Third Thursday"}</option>
<option value="3FR">{"Third Friday"}</option>
<option value="3SA">{"Third Saturday"}</option>
<option value="3SU">{"Third Sunday"}</option>
<option value="4MO">{"Fourth Monday"}</option>
<option value="4TU">{"Fourth Tuesday"}</option>
<option value="4WE">{"Fourth Wednesday"}</option>
<option value="4TH">{"Fourth Thursday"}</option>
<option value="4FR">{"Fourth Friday"}</option>
<option value="4SA">{"Fourth Saturday"}</option>
<option value="4SU">{"Fourth Sunday"}</option>
<option value="-1MO">{"Last Monday"}</option>
<option value="-1TU">{"Last Tuesday"}</option>
<option value="-1WE">{"Last Wednesday"}</option>
<option value="-1TH">{"Last Thursday"}</option>
<option value="-1FR">{"Last Friday"}</option>
<option value="-1SA">{"Last Saturday"}</option>
<option value="-1SU">{"Last Sunday"}</option>
</select>
</div>
</div>
</div>
}
// Yearly specific options
if matches!(data.recurrence, RecurrenceType::Yearly) {
<div class="form-group">
<label>{"Repeat in months"}</label>
<div class="yearly-months">
{
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
.iter()
.enumerate()
.map(|(i, month)| {
let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false);
let on_change = on_yearly_month_change(i);
html! {
<label key={i} class="month-checkbox">
<input
type="checkbox"
checked={month_checked}
onchange={on_change}
/>
<span class="month-label">{month}</span>
</label>
}
})
.collect::<Html>()
}
</div>
</div>
}
</div>
}
// Date and time fields go here AFTER recurrence options
<div class="form-row">
<div class="form-group">
<label for="start-date">{"Start Date *"}</label>
<input
type="date"
id="start-date"
class="form-input"
value={data.start_date.format("%Y-%m-%d").to_string()}
onchange={on_start_date_change}
required=true
/>
</div>
if !data.all_day {
<div class="form-group">
<label for="start-time">{"Start Time"}</label>
<input
type="time"
id="start-time"
class="form-input"
value={data.start_time.format("%H:%M").to_string()}
onchange={on_start_time_change}
/>
</div>
}
</div>
<div class="form-row">
<div class="form-group">
<label for="end-date">{"End Date *"}</label>
<input
type="date"
id="end-date"
class="form-input"
value={data.end_date.format("%Y-%m-%d").to_string()}
onchange={on_end_date_change}
required=true
/>
</div>
if !data.all_day {
<div class="form-group">
<label for="end-time">{"End Time"}</label>
<input
type="time"
id="end-time"
class="form-input"
value={data.end_time.format("%H:%M").to_string()}
onchange={on_end_time_change}
/>
</div>
}
</div>
<div class="form-group">
<label for="event-location">{"Location"}</label>
<input
type="text"
id="event-location"
class="form-input"
value={data.location.clone()}
oninput={on_location_input}
placeholder="Enter event location"
/>
</div>
</div>
}
}

View 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>
}
}

View 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>
}
}

View File

@@ -0,0 +1,16 @@
// Event form components module
pub mod types;
pub mod basic_details;
pub mod advanced;
pub mod people;
pub mod categories;
pub mod location;
pub mod reminders;
pub use types::*;
pub use basic_details::BasicDetailsTab;
pub use advanced::AdvancedTab;
pub use people::PeopleTab;
pub use categories::CategoriesTab;
pub use location::LocationTab;
pub use reminders::RemindersTab;

View 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>
}
}

View File

@@ -0,0 +1,100 @@
use super::types::*;
use crate::components::create_event_modal::ReminderType;
use wasm_bindgen::JsCast;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
#[function_component(RemindersTab)]
pub fn reminders_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_reminder_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-reminder-main">{"Primary Reminder"}</label>
<select
id="event-reminder-main"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
</select>
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
</div>
<div class="reminder-types">
<h5>{"Reminder & Alarm Types"}</h5>
<div class="alarm-examples">
<div class="alarm-type">
<strong>{"Display Alarm"}</strong>
<p>{"Pop-up notification on your device"}</p>
</div>
<div class="alarm-type">
<strong>{"Email Reminder"}</strong>
<p>{"Email notification sent to your address"}</p>
</div>
<div class="alarm-type">
<strong>{"Audio Alert"}</strong>
<p>{"Sound notification with custom audio"}</p>
</div>
<div class="alarm-type">
<strong>{"SMS/Text"}</strong>
<p>{"Text message reminder (enterprise feature)"}</p>
</div>
</div>
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
</div>
<div class="reminder-info">
<h5>{"Advanced Reminder Features"}</h5>
<ul>
<li>{"Multiple reminders per event with different timing"}</li>
<li>{"Custom reminder messages and descriptions"}</li>
<li>{"Recurring reminders for recurring events"}</li>
<li>{"Snooze and dismiss functionality"}</li>
<li>{"Integration with system notifications"}</li>
</ul>
<div class="attachments-section">
<h6>{"File Attachments & Documents"}</h6>
<p>{"Future attachment features will include:"}</p>
<ul>
<li>{"Drag-and-drop file uploads"}</li>
<li>{"Document preview and thumbnails"}</li>
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li>
<li>{"Version control for updated documents"}</li>
<li>{"Shared access permissions for attendees"}</li>
</ul>
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,180 @@
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use yew::prelude::*;
#[derive(Clone, PartialEq, Debug)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum ReminderType {
None,
Minutes15,
Minutes30,
Hour1,
Day1,
Days2,
Week1,
}
impl Default for ReminderType {
fn default() -> Self {
ReminderType::None
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum RecurrenceType {
None,
Daily,
Weekly,
Monthly,
Yearly,
}
impl Default for RecurrenceType {
fn default() -> Self {
RecurrenceType::None
}
}
#[derive(Clone, PartialEq)]
pub enum ModalTab {
BasicDetails,
Advanced,
People,
Categories,
Location,
Reminders,
}
impl Default for ModalTab {
fn default() -> Self {
ModalTab::BasicDetails
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EditAction {
ThisOnly,
ThisAndFuture,
AllInSeries,
}
#[derive(Clone, PartialEq, Debug)]
pub struct EventCreationData {
// Basic event info
pub title: String,
pub description: String,
pub location: String,
pub all_day: bool,
// Timing
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
// Classification
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
// People
pub organizer: String,
pub attendees: String,
// Categorization
pub categories: String,
// Reminders
pub reminder: ReminderType,
// Recurrence
pub recurrence: RecurrenceType,
pub recurrence_interval: u32,
pub recurrence_until: Option<NaiveDate>,
pub recurrence_count: Option<u32>,
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
// Advanced recurrence
pub monthly_by_day: Option<String>, // e.g., "1MO" for first Monday
pub monthly_by_monthday: Option<u8>, // e.g., 15 for 15th day of month
pub yearly_by_month: Vec<bool>, // [Jan, Feb, Mar, ...]
// Calendar selection
pub selected_calendar: Option<String>,
// Edit tracking (for recurring events)
pub edit_scope: Option<EditAction>,
pub changed_fields: Vec<String>,
}
impl Default for EventCreationData {
fn default() -> Self {
let now_local = Local::now();
let start_date = now_local.date_naive();
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
Self {
title: String::new(),
description: String::new(),
location: String::new(),
all_day: false,
start_date,
end_date: start_date,
start_time,
end_time,
status: EventStatus::default(),
class: EventClass::default(),
priority: None,
organizer: String::new(),
attendees: String::new(),
categories: String::new(),
reminder: ReminderType::default(),
recurrence: RecurrenceType::default(),
recurrence_interval: 1,
recurrence_until: None,
recurrence_count: None,
recurrence_days: vec![false; 7],
monthly_by_day: None,
monthly_by_monthday: None,
yearly_by_month: vec![false; 12],
selected_calendar: None,
edit_scope: None,
changed_fields: vec![],
}
}
}
// Common props for all tab components
#[derive(Properties, PartialEq)]
pub struct TabProps {
pub data: UseStateHandle<crate::components::create_event_modal::EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
}

View File

@@ -1,6 +1,6 @@
use yew::prelude::*;
use chrono::{DateTime, Utc};
use crate::models::ical::VEvent;
use chrono::{DateTime, Utc};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct EventModalProps {
@@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
on_close.emit(());
})
};
let backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
@@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
<strong>{"Title:"}</strong>
<span>{event.get_title()}</span>
</div>
{
if let Some(ref description) = event.description {
html! {
@@ -52,30 +52,30 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
<div class="event-detail">
<strong>{"Start:"}</strong>
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
</div>
{
if let Some(ref end) = event.dtend {
html! {
<div class="event-detail">
<strong>{"End:"}</strong>
<span>{format_datetime(end, event.all_day)}</span>
<span>{format_datetime_end(end, event.all_day)}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"All Day:"}</strong>
<span>{if event.all_day { "Yes" } else { "No" }}</span>
</div>
{
if let Some(ref location) = event.location {
html! {
@@ -88,22 +88,22 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
<div class="event-detail">
<strong>{"Status:"}</strong>
<span>{event.get_status_display()}</span>
</div>
<div class="event-detail">
<strong>{"Privacy:"}</strong>
<span>{event.get_class_display()}</span>
</div>
<div class="event-detail">
<strong>{"Priority:"}</strong>
<span>{event.get_priority_display()}</span>
</div>
{
if let Some(ref organizer) = event.organizer {
html! {
@@ -116,7 +116,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if !event.attendees.is_empty() {
html! {
@@ -129,7 +129,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if !event.categories.is_empty() {
html! {
@@ -142,7 +142,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if let Some(ref recurrence) = event.rrule {
html! {
@@ -160,7 +160,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
}
}
}
{
if !event.alarms.is_empty() {
html! {
@@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
}
}
}
{
if let Some(ref created) = event.created {
html! {
@@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if let Some(ref modified) = event.last_modified {
html! {
@@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
}
}
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String {
if all_day {
// For all-day events, subtract one day from end date for display
// RFC-5545 uses exclusive end dates, but users expect inclusive display
let display_date = *dt - chrono::Duration::days(1);
display_date.format("%B %d, %Y").to_string()
} else {
dt.format("%B %d, %Y at %I:%M %p").to_string()
}
}
fn format_recurrence_rule(rrule: &str) -> String {
// Basic parsing of RRULE to display user-friendly text
if rrule.contains("FREQ=DAILY") {
@@ -236,4 +247,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
format!("Custom ({})", rrule)
}
}

View File

@@ -1,6 +1,6 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use gloo_storage::{LocalStorage, Storage};
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct LoginProps {
@@ -9,11 +9,20 @@ pub struct LoginProps {
#[function_component]
pub fn Login(props: &LoginProps) -> Html {
let server_url = use_state(String::new);
let username = use_state(String::new);
// 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);
let server_url_ref = use_node_ref();
let username_ref = use_node_ref();
@@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html {
password.set(target.value());
})
};
let on_remember_server_change = {
let remember_server = remember_server.clone();
let server_url = server_url.clone();
Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>();
let checked = target.checked();
remember_server.set(checked);
if checked {
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
} else {
let _ = LocalStorage::delete("remembered_server_url");
}
})
};
let on_remember_username_change = {
let remember_username = remember_username.clone();
let username = username.clone();
Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>();
let checked = target.checked();
remember_username.set(checked);
if checked {
let _ = LocalStorage::set("remembered_username", (*username).clone());
} else {
let _ = LocalStorage::delete("remembered_username");
}
})
};
let on_submit = {
let server_url = server_url.clone();
@@ -53,7 +94,7 @@ pub fn Login(props: &LoginProps) -> Html {
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let server_url = (*server_url).clone();
let username = (*username).clone();
let password = (*password).clone();
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
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)) => {
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()));
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;
}
@@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html {
return;
}
// Store preferences from database
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
let _ = LocalStorage::set("user_preferences", &prefs_json);
}
is_loading.set(false);
on_login.emit(token);
}
@@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
onchange={on_server_url_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input
type="checkbox"
id="remember_server"
checked={*remember_server}
onchange={on_remember_server_change}
/>
<label for="remember_server">{"Remember server"}</label>
</div>
</div>
<div class="form-group">
@@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
onchange={on_username_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input
type="checkbox"
id="remember_username"
checked={*remember_username}
onchange={on_remember_username_change}
/>
<label for="remember_username">{"Remember username"}</label>
</div>
</div>
<div class="form-group">
@@ -172,21 +243,25 @@ pub fn Login(props: &LoginProps) -> Html {
}
/// Perform login using the CalDAV auth service
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
async fn perform_login(
server_url: String,
username: String,
password: String,
) -> Result<(String, String, String, serde_json::Value), String> {
use crate::auth::{AuthService, CalDAVLoginRequest};
use 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()
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());
@@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
"username": username,
"password": password
});
Ok((response.token, credentials.to_string()))
},
// Extract preferences as JSON
let preferences = serde_json::json!({
"calendar_selected_date": response.preferences.calendar_selected_date,
"calendar_time_increment": response.preferences.calendar_time_increment,
"calendar_view_mode": response.preferences.calendar_view_mode,
"calendar_theme": response.preferences.calendar_theme,
"calendar_colors": response.preferences.calendar_colors,
});
Ok((response.token, response.session_token, credentials.to_string(), preferences))
}
Err(err) => {
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
Err(err)
},
}
}
}
}

View File

@@ -1,31 +1,40 @@
pub mod login;
pub mod calendar;
pub mod calendar_header;
pub mod month_view;
pub mod week_view;
pub mod event_modal;
pub mod create_calendar_modal;
pub mod context_menu;
pub mod event_context_menu;
pub mod calendar_context_menu;
pub mod create_event_modal;
pub mod sidebar;
pub mod calendar_header;
pub mod calendar_list_item;
pub mod route_handler;
pub mod context_menu;
pub mod create_calendar_modal;
pub mod create_event_modal;
pub mod create_event_modal_v2;
pub mod event_context_menu;
pub mod event_form;
pub mod event_modal;
pub mod login;
pub mod month_view;
pub mod recurring_edit_modal;
pub mod route_handler;
pub mod sidebar;
pub mod week_view;
pub use login::Login;
pub use calendar::Calendar;
pub use calendar_header::CalendarHeader;
pub use month_view::MonthView;
pub use week_view::WeekView;
pub use event_modal::EventModal;
pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu;
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
pub use sidebar::{Sidebar, ViewMode, Theme};
pub use calendar_header::CalendarHeader;
pub use calendar_list_item::CalendarListItem;
pub use context_menu::ContextMenu;
pub use create_calendar_modal::CreateCalendarModal;
pub use create_event_modal::{
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
};
pub use create_event_modal_v2::CreateEventModalV2;
pub use event_form::{
EventClass as EventFormClass, EventCreationData as EventFormData, EventStatus as EventFormStatus,
RecurrenceType as EventFormRecurrenceType, ReminderType as EventFormReminderType,
};
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
pub use event_modal::EventModal;
pub use login::Login;
pub use month_view::MonthView;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
pub use route_handler::RouteHandler;
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
pub use sidebar::{Sidebar, Theme, ViewMode};
pub use week_view::WeekView;

View File

@@ -1,10 +1,10 @@
use yew::prelude::*;
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use chrono::{Datelike, NaiveDate, Weekday};
use std::collections::HashMap;
use web_sys::window;
use wasm_bindgen::{prelude::*, JsCast};
use crate::services::calendar_service::UserInfo;
use crate::models::ical::VEvent;
use web_sys::window;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct MonthViewProps {
@@ -52,30 +52,33 @@ pub fn month_view(props: &MonthViewProps) -> Html {
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());
let _ = window.add_event_listener_with_callback(
"resize",
resize_closure.as_ref().unchecked_ref(),
);
resize_closure.forget(); // Keep the closure alive
}
|| {}
});
}
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
let get_event_color = |event: &VEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
if let Some(calendar) = user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path) {
if let Some(calendar) = user_info
.calendars
.iter()
.find(|cal| &cal.path == calendar_path)
{
return calendar.color.clone();
}
}
@@ -103,7 +109,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
<div class="weekday-header">{"Thu"}</div>
<div class="weekday-header">{"Fri"}</div>
<div class="weekday-header">{"Sat"}</div>
// Days from previous month (grayed out)
{
days_from_prev_month.iter().map(|day| {
@@ -112,7 +118,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
}
}).collect::<Html>()
}
// Days of the current month
{
(1..=days_in_month).map(|day| {
@@ -120,16 +126,16 @@ pub fn month_view(props: &MonthViewProps) -> Html {
let is_today = date == props.today;
let is_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
<div
class={classes!(
"calendar-day",
"calendar-day",
if is_today { Some("today") } else { None },
if is_selected { Some("selected") } else { None }
)}
@@ -162,7 +168,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
visible_events.iter().map(|event| {
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();
@@ -170,7 +176,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
on_event_click.emit(event.clone());
})
};
let oncontextmenu = {
if let Some(callback) = &props.on_event_context_menu {
let callback = callback.clone();
@@ -183,9 +189,9 @@ pub fn month_view(props: &MonthViewProps) -> Html {
None
}
};
html! {
<div
<div
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
style={format!("background-color: {}", event_color)}
{onclick}
@@ -212,7 +218,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
}
}).collect::<Html>()
}
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
</div>
}
@@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
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>()
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
if date.month() == 12 {
date.year() + 1
} else {
date.year()
},
if date.month() == 12 {
1
} else {
date.month() + 1
},
1,
)
.unwrap()
.pred_opt()
@@ -252,7 +272,7 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
Weekday::Fri => 5,
Weekday::Sat => 6,
};
if days_before == 0 {
vec![]
} else {
@@ -261,8 +281,8 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
} 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()
}
}
}

View File

@@ -1,6 +1,6 @@
use yew::prelude::*;
use chrono::NaiveDateTime;
use crate::models::ical::VEvent;
use chrono::NaiveDateTime;
use yew::prelude::*;
#[derive(Clone, PartialEq)]
pub enum RecurringEditAction {
@@ -25,29 +25,34 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
return html! {};
}
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
let event_title = props
.event
.summary
.as_ref()
.map(|s| s.as_str())
.unwrap_or("Untitled Event");
let on_this_event = {
let on_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 |_| {
@@ -64,18 +69,18 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
<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>
@@ -90,4 +95,4 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
</div>
</div>
}
}
}

View File

@@ -1,8 +1,8 @@
use crate::components::{Login, ViewMode};
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use yew::prelude::*;
use yew_router::prelude::*;
use crate::components::{Login, ViewMode};
use crate::services::calendar_service::UserInfo;
use crate::models::ical::VEvent;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
#[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::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
@@ -44,7 +54,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
let on_create_event_request = props.on_create_event_request.clone();
let on_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();
@@ -56,7 +66,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
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() {
@@ -74,16 +84,16 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
}
Route::Calendar => {
if auth_token.is_some() {
html! {
<CalendarView
user_info={user_info}
html! {
<CalendarView
user_info={user_info}
on_event_context_menu={on_event_context_menu}
on_calendar_context_menu={on_calendar_context_menu}
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}/> }
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
#[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::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
use gloo_storage::{LocalStorage, Storage};
use crate::services::CalendarService;
use crate::components::Calendar;
use std::collections::HashMap;
use chrono::{Local, NaiveDate, Datelike};
#[function_component(CalendarView)]
pub fn calendar_view(props: &CalendarViewProps) -> Html {
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let refreshing_event = use_state(|| None::<String>);
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let today = Local::now().date_naive();
let current_year = today.year();
let current_month = today.month();
let on_event_click = {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let auth_token = auth_token.clone();
Callback::from(move |event: VEvent| {
if let Some(token) = auth_token.clone() {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let uid = event.uid.clone();
refreshing_event.set(Some(uid.clone()));
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.refresh_event(&token, &password, &uid).await {
Ok(Some(refreshed_event)) => {
let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
if refreshed_vevent.rrule.is_some() {
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
for occurrence in new_occurrences {
let date = occurrence.get_date();
updated_events.entry(date)
.or_insert_with(Vec::new)
.push(occurrence);
}
} else {
let date = refreshed_vevent.get_date();
updated_events.entry(date)
.or_insert_with(Vec::new)
.push(refreshed_vevent);
}
events.set(updated_events);
}
Ok(None) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {
}
}
refreshing_event.set(None);
});
}
})
};
{
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
let auth_token = auth_token.clone();
use_effect_with((), move |_| {
if let Some(token) = auth_token {
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
Ok(vevents) => {
let grouped_events = CalendarService::group_events_by_date(vevents);
events.set(grouped_events);
loading.set(false);
}
Err(err) => {
error.set(Some(format!("Failed to load events: {}", err)));
loading.set(false);
}
}
});
} else {
loading.set(false);
error.set(Some("No authentication token found".to_string()));
}
|| ()
});
}
html! {
<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(|_: VEvent| {});
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
<Calendar
events={HashMap::new()}
on_event_click={dummy_callback}
refreshing_event_uid={(*refreshing_event).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
view={props.view.clone()}
on_create_event_request={props.on_create_event_request.clone()}
on_event_update_request={props.on_event_update_request.clone()}
context_menus_open={props.context_menus_open}
/>
</div>
}
} else {
html! {
<Calendar
events={(*events).clone()}
on_event_click={on_event_click}
refreshing_event_uid={(*refreshing_event).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
view={props.view.clone()}
on_create_event_request={props.on_create_event_request.clone()}
on_event_update_request={props.on_event_update_request.clone()}
context_menus_open={props.context_menus_open}
/>
}
}
}
<Calendar
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
view={props.view.clone()}
on_create_event_request={props.on_create_event_request.clone()}
on_event_update_request={props.on_event_update_request.clone()}
context_menus_open={props.context_menus_open}
/>
</div>
}
}
}

View File

@@ -1,8 +1,8 @@
use crate::components::CalendarListItem;
use crate::services::calendar_service::UserInfo;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
use web_sys::HtmlSelectElement;
use crate::services::calendar_service::UserInfo;
use crate::components::CalendarListItem;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
@@ -32,13 +32,18 @@ pub enum Theme {
Mint,
}
#[derive(Clone, PartialEq)]
pub enum Style {
Default,
Google,
}
impl Theme {
pub fn value(&self) -> &'static str {
match self {
Theme::Default => "default",
Theme::Ocean => "ocean",
Theme::Forest => "forest",
Theme::Forest => "forest",
Theme::Sunset => "sunset",
Theme::Purple => "purple",
Theme::Dark => "dark",
@@ -46,7 +51,7 @@ impl Theme {
Theme::Mint => "mint",
}
}
pub fn from_value(value: &str) -> Self {
match value {
"ocean" => Theme::Ocean,
@@ -61,6 +66,30 @@ impl Theme {
}
}
impl Style {
pub fn value(&self) -> &'static str {
match self {
Style::Default => "default",
Style::Google => "google",
}
}
pub fn from_value(value: &str) -> Self {
match value {
"google" => Style::Google,
_ => Style::Default,
}
}
pub fn stylesheet_path(&self) -> Option<&'static str> {
match self {
Style::Default => None, // No additional stylesheet needed - uses base styles.css
Style::Google => Some("google.css"), // Trunk copies to root level
}
}
}
impl Default for ViewMode {
fn default() -> Self {
ViewMode::Month
@@ -81,6 +110,8 @@ pub struct SidebarProps {
pub on_view_change: Callback<ViewMode>,
pub current_theme: Theme,
pub on_theme_change: Callback<Theme>,
pub current_style: Style,
pub on_style_change: Callback<Style>,
}
#[function_component(Sidebar)]
@@ -112,10 +143,22 @@ pub fn sidebar(props: &SidebarProps) -> Html {
})
};
let on_style_change = {
let on_style_change = props.on_style_change.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<HtmlSelectElement>();
if let Some(select) = target {
let value = select.value();
let new_style = Style::from_value(&value);
on_style_change.emit(new_style);
}
})
};
html! {
<aside class="app-sidebar">
<div class="sidebar-header">
<h1>{"Calendar App"}</h1>
<h1>{"Runway"}</h1>
{
if let Some(ref info) = props.user_info {
html! {
@@ -167,14 +210,14 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
{"+ Create 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>
@@ -187,9 +230,16 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</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>
</select>
</div>
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
</div>
</aside>
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +0,0 @@
use serde::{Deserialize, Serialize};
use std::env;
use base64::prelude::*;
/// Configuration for CalDAV server connection and authentication.
///
/// This struct holds all the necessary information to connect to a CalDAV server,
/// including server URL, credentials, and optional collection paths.
///
/// # Security Note
///
/// The password field contains sensitive information and should be handled carefully.
/// This struct implements `Debug` but in production, consider implementing a custom
/// `Debug` that masks the password field.
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// // Load configuration from environment variables
/// let config = CalDAVConfig::from_env()?;
///
/// // Use the configuration for HTTP requests
/// let auth_header = format!("Basic {}", config.get_basic_auth());
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalDAVConfig {
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
pub server_url: String,
/// Username for authentication with the CalDAV server
pub username: String,
/// Password for authentication with the CalDAV server
///
/// **Security Note**: This contains sensitive information
pub password: String,
/// Optional path to the calendar collection on the server
///
/// If not provided, the client will need to discover available calendars
/// through CalDAV PROPFIND requests
pub calendar_path: Option<String>,
/// Optional path to the tasks/todo collection on the server
///
/// Some CalDAV servers store tasks separately from calendar events
pub tasks_path: Option<String>,
}
impl CalDAVConfig {
/// Creates a new CalDAVConfig by loading values from environment variables.
///
/// This method will attempt to load a `.env` file from the current directory
/// and then read the following required environment variables:
///
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
/// - `CALDAV_USERNAME`: Username for authentication
/// - `CALDAV_PASSWORD`: Password for authentication
///
/// Optional environment variables:
///
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
///
/// # Errors
///
/// Returns `ConfigError::MissingVar` if any required environment variable
/// is not set or cannot be read.
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// match CalDAVConfig::from_env() {
/// Ok(config) => {
/// println!("Loaded config for server: {}", config.server_url);
/// }
/// Err(e) => {
/// eprintln!("Failed to load config: {}", e);
/// }
/// }
/// ```
pub fn from_env() -> Result<Self, ConfigError> {
// Attempt to load .env file, but don't fail if it doesn't exist
dotenvy::dotenv().ok();
let server_url = env::var("CALDAV_SERVER_URL")
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
let username = env::var("CALDAV_USERNAME")
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
let password = env::var("CALDAV_PASSWORD")
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
// Optional paths - it's fine if these are not set
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
Ok(CalDAVConfig {
server_url,
username,
password,
calendar_path,
tasks_path,
})
}
/// Generates a Base64-encoded string for HTTP Basic Authentication.
///
/// This method combines the username and password in the format
/// `username:password` and encodes it using Base64, which is the
/// standard format for the `Authorization: Basic` HTTP header.
///
/// # Returns
///
/// A Base64-encoded string that can be used directly in the
/// `Authorization` header: `Authorization: Basic <returned_value>`
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// let config = CalDAVConfig {
/// server_url: "https://example.com".to_string(),
/// username: "user".to_string(),
/// password: "pass".to_string(),
/// calendar_path: None,
/// tasks_path: None,
/// };
///
/// let auth_value = config.get_basic_auth();
/// let auth_header = format!("Basic {}", auth_value);
/// ```
pub fn get_basic_auth(&self) -> String {
let credentials = format!("{}:{}", self.username, self.password);
BASE64_STANDARD.encode(&credentials)
}
}
/// Errors that can occur when loading or using CalDAV configuration.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// A required environment variable is missing or cannot be read.
///
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
/// or `CALDAV_PASSWORD`) is not set.
#[error("Missing environment variable: {0}")]
MissingVar(String),
/// The configuration contains invalid or malformed values.
///
/// This could include malformed URLs, invalid authentication credentials,
/// or other configuration issues that prevent proper CalDAV operation.
#[error("Invalid configuration: {0}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_auth_encoding() {
let config = CalDAVConfig {
server_url: "https://example.com".to_string(),
username: "testuser".to_string(),
password: "testpass".to_string(),
calendar_path: None,
tasks_path: None,
};
let auth = config.get_basic_auth();
let expected = BASE64_STANDARD.encode("testuser:testpass");
assert_eq!(auth, expected);
}
/// Integration test that authenticates with the actual Baikal CalDAV server
///
/// This test requires a valid .env file with:
/// - CALDAV_SERVER_URL
/// - CALDAV_USERNAME
/// - CALDAV_PASSWORD
///
/// Run with: `cargo test test_baikal_auth`
#[tokio::test]
async fn test_baikal_auth() {
// Load config from .env
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
println!("Testing authentication to: {}", config.server_url);
// Create HTTP client
let client = reqwest::Client::new();
// Make a simple OPTIONS request to test authentication
let response = client
.request(reqwest::Method::OPTIONS, &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header("User-Agent", "calendar-app/0.1.0")
.send()
.await
.expect("Failed to send request to CalDAV server");
println!("Response status: {}", response.status());
println!("Response headers: {:#?}", response.headers());
// Check if we got a successful response or at least not a 401 Unauthorized
assert!(
response.status().is_success() || response.status() != 401,
"Authentication failed with status: {}. Check your credentials in .env",
response.status()
);
// For Baikal/CalDAV servers, we should see DAV headers
assert!(
response.headers().contains_key("dav") ||
response.headers().contains_key("DAV") ||
response.status().is_success(),
"Server doesn't appear to be a CalDAV server - missing DAV headers"
);
println!("✓ Authentication test passed!");
}
/// Test making a PROPFIND request to discover calendars
///
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
///
/// Run with: `cargo test test_propfind_calendars`
#[tokio::test]
async fn test_propfind_calendars() {
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
let client = reqwest::Client::new();
// CalDAV PROPFIND request to discover calendars
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype />
<d:displayname />
<c:calendar-description />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>"#;
let response = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
.body(propfind_body)
.send()
.await
.expect("Failed to send PROPFIND request");
let status = response.status();
println!("PROPFIND Response status: {}", status);
let body = response.text().await.expect("Failed to read response body");
println!("PROPFIND Response body: {}", body);
// We should get a 207 Multi-Status for PROPFIND
assert_eq!(
status,
reqwest::StatusCode::from_u16(207).unwrap(),
"PROPFIND should return 207 Multi-Status"
);
// The response should contain XML with calendar information
assert!(body.contains("calendar"), "Response should contain calendar information");
println!("✓ PROPFIND calendars test passed!");
}
}

View File

@@ -1,4 +1,3 @@
mod app;
mod auth;
mod components;
@@ -9,4 +8,4 @@ use app::App;
fn main() {
yew::Renderer::<App>::new().render();
}
}

View File

@@ -1,2 +1,2 @@
// Re-export from shared calendar-models library for backward compatibility
pub use calendar_models::*;
pub use calendar_models::*;

View File

@@ -1,5 +1,5 @@
// RFC 5545 Compliant iCalendar Models
pub mod ical;
// Re-export commonly used types
// pub use ical::VEvent;
// Re-export commonly used types
// pub use ical::VEvent;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
pub mod calendar_service;
pub mod preferences;
pub use calendar_service::CalendarService;
pub use calendar_service::CalendarService;

View File

@@ -0,0 +1,180 @@
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use serde_json;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPreferences {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_colors: Option<String>,
}
#[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct UpdatePreferencesRequest {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_colors: Option<String>,
}
#[allow(dead_code)]
pub struct PreferencesService {
base_url: String,
}
#[allow(dead_code)]
impl PreferencesService {
pub fn new() -> Self {
let base_url = option_env!("BACKEND_API_URL")
.unwrap_or("http://localhost:3000/api")
.to_string();
Self { base_url }
}
/// Load preferences from LocalStorage (cached from login)
pub fn load_cached() -> Option<UserPreferences> {
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
serde_json::from_str(&prefs_json).ok()
} else {
None
}
}
/// Update a single preference field and sync with backend
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
// Get session token
let session_token = LocalStorage::get::<String>("session_token")
.map_err(|_| "No session token found".to_string())?;
// Load current preferences
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
calendar_selected_date: None,
calendar_time_increment: None,
calendar_view_mode: None,
calendar_theme: None,
calendar_colors: None,
});
// Update the specific field
match field {
"calendar_selected_date" => {
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
}
"calendar_time_increment" => {
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
}
"calendar_view_mode" => {
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
}
"calendar_theme" => {
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
}
"calendar_colors" => {
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
}
_ => return Err(format!("Unknown preference field: {}", field)),
}
// Save to LocalStorage cache
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
let _ = LocalStorage::set("user_preferences", &prefs_json);
}
// Sync with backend
let request = UpdatePreferencesRequest {
calendar_selected_date: preferences.calendar_selected_date.clone(),
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode.clone(),
calendar_theme: preferences.calendar_theme.clone(),
calendar_colors: preferences.calendar_colors.clone(),
};
self.sync_preferences(&session_token, &request).await
}
/// Sync all preferences with backend
async fn sync_preferences(
&self,
session_token: &str,
request: &UpdatePreferencesRequest,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
let json_body = serde_json::to_string(request)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
let url = format!("{}/preferences", self.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Header setting failed: {:?}", e))?;
request
.headers()
.set("X-Session-Token", session_token)
.map_err(|e| format!("Header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Network request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
if resp.ok() {
Ok(())
} else {
Err(format!("Failed to update preferences: {}", resp.status()))
}
}
/// Migrate preferences from LocalStorage to backend (on first login after update)
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
let session_token = LocalStorage::get::<String>("session_token")
.map_err(|_| "No session token found".to_string())?;
let request = UpdatePreferencesRequest {
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
};
// Only migrate if we have some preferences to migrate
if request.calendar_selected_date.is_some()
|| request.calendar_time_increment.is_some()
|| request.calendar_view_mode.is_some()
|| request.calendar_theme.is_some()
|| request.calendar_colors.is_some()
{
self.sync_preferences(&session_token, &request).await?;
// Clear old LocalStorage entries after successful migration
let _ = LocalStorage::delete("calendar_selected_date");
let _ = LocalStorage::delete("calendar_time_increment");
let _ = LocalStorage::delete("calendar_view_mode");
let _ = LocalStorage::delete("calendar_theme");
let _ = LocalStorage::delete("calendar_colors");
}
Ok(())
}
}

View File

@@ -1,9 +1,120 @@
/* Base Styles - Always Loaded */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* CSS Variables for Style System */
--border-radius-small: 4px;
--border-radius-medium: 8px;
--border-radius-large: 12px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 25px rgba(0,0,0,0.15);
--border-light: 1px solid #e9ecef;
--border-medium: 1px solid #dee2e6;
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
--transition-slow: 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: row;
}
.login-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
/* Base Layout */
.main-content {
flex: 1;
margin-left: 280px;
overflow-x: hidden;
}
/* Basic Form Elements */
input, select, textarea, button {
font-family: inherit;
}
/* Utility Classes */
.loading {
opacity: 0.7;
}
.error {
color: #dc3545;
}
.success {
color: #28a745;
}
/* Theme Data Attributes for Color Schemes */
[data-theme="default"] {
--primary-color: #667eea;
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
[data-theme="ocean"] {
--primary-color: #006994;
--primary-gradient: linear-gradient(135deg, #006994 0%, #0891b2 100%);
}
[data-theme="forest"] {
--primary-color: #065f46;
--primary-gradient: linear-gradient(135deg, #065f46 0%, #047857 100%);
}
[data-theme="sunset"] {
--primary-color: #ea580c;
--primary-gradient: linear-gradient(135deg, #ea580c 0%, #dc2626 100%);
}
[data-theme="purple"] {
--primary-color: #7c3aed;
--primary-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
}
[data-theme="dark"] {
--primary-color: #374151;
--primary-gradient: linear-gradient(135deg, #374151 0%, #1f2937 100%);
}
[data-theme="rose"] {
--primary-color: #e11d48;
--primary-gradient: linear-gradient(135deg, #e11d48 0%, #f43f5e 100%);
}
[data-theme="mint"] {
--primary-color: #10b981;
--primary-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
}* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
@@ -289,6 +400,30 @@ body {
cursor: not-allowed;
}
.remember-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.375rem;
opacity: 0.7;
}
.remember-checkbox input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
transform: scale(0.85);
}
.remember-checkbox label {
margin: 0;
font-size: 0.75rem;
color: #888;
cursor: pointer;
user-select: none;
font-weight: 400;
}
.login-button, .register-button {
width: 100%;
padding: 0.75rem;
@@ -515,11 +650,14 @@ body {
}
.week-day-header {
padding: 1rem;
padding: 0.5rem;
text-align: center;
border-right: 1px solid var(--time-label-border, #e9ecef);
background: var(--weekday-header-bg, #f8f9fa);
color: var(--weekday-header-text, inherit);
min-height: 70px; /* Ensure space for all-day events */
display: flex;
flex-direction: column;
}
.week-day-header.today {
@@ -545,17 +683,57 @@ body {
color: var(--calendar-today-text, #1976d2);
}
/* All-day events in header */
.day-header-content {
flex-shrink: 0;
}
.all-day-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 0.5rem;
min-height: 0;
}
.all-day-event {
background: #3B82F6;
color: white;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.75rem;
text-align: left;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.2);
min-height: 18px;
display: flex;
align-items: center;
}
.all-day-event:hover {
filter: brightness(1.1);
}
.all-day-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* Week Content */
.week-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Allow flex item to shrink below content size */
}
.time-grid {
display: grid;
grid-template-columns: 80px 1fr;
min-height: 100%;
min-height: 1530px;
}
/* Time Labels */
@@ -565,6 +743,7 @@ body {
position: sticky;
left: 0;
z-index: 5;
min-height: 1440px; /* Match the time slots height */
}
.time-label {
@@ -590,12 +769,13 @@ body {
.week-days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */
}
.week-day-column {
position: relative;
border-right: 1px solid var(--time-label-border, #e9ecef);
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
min-height: 1440px; /* 24 time slots × 60px = 1440px total */
}
.week-day-column:last-child {
@@ -644,8 +824,7 @@ body {
/* Week Events */
.week-event {
position: absolute !important;
left: 4px;
right: 4px;
/* left and width are now set inline for overlap handling */
min-height: 20px;
background: #3B82F6;
color: white;
@@ -665,6 +844,20 @@ body {
white-space: nowrap;
}
/* Disable pointer events on existing events when creating a new event */
.week-day-column.creating-event .week-event {
pointer-events: none;
opacity: 0.6; /* Visual feedback that events are not interactive */
}
.week-day-column.creating-event .week-event .event-content {
pointer-events: none;
}
.week-day-column.creating-event .week-event .resize-handle {
pointer-events: none;
}
.week-event:hover {
filter: brightness(1.1);
z-index: 4;
@@ -2990,6 +3183,50 @@ body {
padding: 0.5rem;
}
/* Style Selector Styles */
.style-selector {
margin-bottom: 1rem;
}
.style-selector label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.8);
}
.style-selector-dropdown {
width: 100%;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.style-selector-dropdown:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.style-selector-dropdown:focus {
outline: none;
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
}
.style-selector-dropdown option {
background: #333;
color: white;
padding: 0.5rem;
}
/* Theme Definitions */
:root {
/* Default Theme */

3501
frontend/styles.css.backup Normal file

File diff suppressed because it is too large Load Diff

51
frontend/styles/base.css Normal file
View File

@@ -0,0 +1,51 @@
/* Base Styles - Always Loaded */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: row;
}
.login-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
/* Base Layout */
.main-content {
flex: 1;
margin-left: 280px;
overflow-x: hidden;
}
/* Basic Form Elements */
input, select, textarea, button {
font-family: inherit;
}
/* Utility Classes */
.loading {
opacity: 0.7;
}
.error {
color: #dc3545;
}
.success {
color: #28a745;
}

3501
frontend/styles/default.css Normal file

File diff suppressed because it is too large Load Diff

645
frontend/styles/google.css Normal file
View File

@@ -0,0 +1,645 @@
/* Google Calendar-inspired styles */
/* Override CSS Variables for Google Calendar Style */
:root {
/* Google-style spacing */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
/* Google-style borders and radius */
--border-radius-small: 2px;
--border-radius-medium: 4px;
--border-radius-large: 8px;
--border-light: 1px solid #dadce0;
--border-medium: 1px solid #dadce0;
/* Google-style shadows */
--shadow-sm: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
--shadow-md: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15);
--shadow-lg: 0 4px 6px 0 rgba(60,64,67,.3), 0 8px 25px 5px rgba(60,64,67,.15);
}
/* Google-style sidebar - override all theme variants */
body .app-sidebar,
[data-theme] .app-sidebar,
[data-theme="default"] .app-sidebar,
[data-theme="ocean"] .app-sidebar,
[data-theme="forest"] .app-sidebar,
[data-theme="sunset"] .app-sidebar,
[data-theme="purple"] .app-sidebar,
[data-theme="dark"] .app-sidebar,
[data-theme="rose"] .app-sidebar,
[data-theme="mint"] .app-sidebar {
background: #ffffff !important;
border-right: 1px solid #dadce0 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
box-shadow: 2px 0 8px rgba(60,64,67,.1) !important;
}
body .sidebar-header,
[data-theme] .sidebar-header {
background: transparent !important;
border-bottom: 1px solid #dadce0 !important;
}
body .sidebar-header h1,
[data-theme] .sidebar-header h1 {
font-size: 20px !important;
font-weight: 500 !important;
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
body .user-info,
[data-theme] .user-info {
color: #3c4043 !important;
}
body .user-info .username,
[data-theme] .user-info .username {
font-weight: 500 !important;
color: #3c4043 !important;
}
body .user-info .server-url,
[data-theme] .user-info .server-url {
color: #5f6368 !important;
}
/* Google-style buttons */
.create-calendar-button {
background: #1a73e8 !important;
color: white !important;
border: none !important;
border-radius: 4px !important;
padding: 8px 16px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
transition: box-shadow 0.2s ease !important;
}
.create-calendar-button:hover {
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
}
.logout-button {
background: transparent !important;
color: #1a73e8 !important;
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
padding: 8px 16px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
transition: background-color 0.2s ease !important;
}
.logout-button:hover {
background: #f8f9fa !important;
}
/* Google-style navigation and sidebar text */
body .sidebar-nav .nav-link,
[data-theme] .sidebar-nav .nav-link {
color: #3c4043 !important;
text-decoration: none !important;
}
body .sidebar-nav .nav-link:hover,
[data-theme] .sidebar-nav .nav-link:hover {
color: #1a73e8 !important;
background: #f1f3f4 !important;
}
/* Calendar list styling */
body .calendar-list h3,
[data-theme] .calendar-list h3 {
color: #3c4043 !important;
}
body .calendar-list .calendar-name,
[data-theme] .calendar-list .calendar-name {
color: #3c4043 !important;
}
body .no-calendars,
[data-theme] .no-calendars {
color: #5f6368 !important;
}
/* Form labels and text */
body .sidebar-footer label,
[data-theme] .sidebar-footer label,
body .view-selector label,
[data-theme] .view-selector label,
body .theme-selector label,
[data-theme] .theme-selector label,
body .style-selector label,
[data-theme] .style-selector label {
color: #3c4043 !important;
}
/* Google-style selectors */
body .view-selector-dropdown,
body .theme-selector-dropdown,
body .style-selector-dropdown,
[data-theme] .view-selector-dropdown,
[data-theme] .theme-selector-dropdown,
[data-theme] .style-selector-dropdown {
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 14px !important;
color: #3c4043 !important;
background: white !important;
font-family: inherit !important;
}
.view-selector-dropdown:focus,
.theme-selector-dropdown:focus,
.style-selector-dropdown:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
}
/* Google-style calendar list items */
.calendar-list h3 {
font-size: 14px;
font-weight: 500;
color: #3c4043;
margin-bottom: 8px;
}
.calendar-list ul {
list-style: none;
}
.calendar-list .calendar-item {
padding: 4px 0;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.calendar-list .calendar-item:hover {
background-color: #f1f3f4;
}
.calendar-list .calendar-name {
color: #3c4043;
font-size: 14px;
font-weight: 400;
}
/* Google-style main content area */
body .app-main,
[data-theme] .app-main {
background: #ffffff !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
color: #3c4043 !important;
}
/* Calendar header elements */
body .calendar-header,
[data-theme] .calendar-header {
background: #f8f9fa !important;
color: #3c4043 !important;
}
body .calendar-header h2,
body .calendar-header h3,
body .month-header,
body .week-header,
[data-theme] .calendar-header h2,
[data-theme] .calendar-header h3,
[data-theme] .month-header,
[data-theme] .week-header {
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Month name and title - aggressive override */
body h1,
body h2,
body h3,
body .month-title,
body .calendar-title,
body .current-month,
body .month-year,
body .header-title,
[data-theme] h1,
[data-theme] h2,
[data-theme] h3,
[data-theme] .month-title,
[data-theme] .calendar-title,
[data-theme] .current-month,
[data-theme] .month-year,
[data-theme] .header-title {
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Navigation arrows and buttons - aggressive override */
body button,
body .nav-button,
body .calendar-nav-button,
body .prev-button,
body .next-button,
body .arrow-button,
body .navigation-arrow,
body [class*="arrow"],
body [class*="nav"],
body [class*="button"],
[data-theme] button,
[data-theme] .nav-button,
[data-theme] .calendar-nav-button,
[data-theme] .prev-button,
[data-theme] .next-button,
[data-theme] .arrow-button,
[data-theme] .navigation-arrow,
[data-theme] [class*="arrow"],
[data-theme] [class*="nav"],
[data-theme] [class*="button"] {
color: #3c4043 !important;
background: #f8f9fa !important;
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
}
body button:hover,
body .nav-button:hover,
body .calendar-nav-button:hover,
body .prev-button:hover,
body .next-button:hover,
body .arrow-button:hover,
[data-theme] button:hover,
[data-theme] .nav-button:hover,
[data-theme] .calendar-nav-button:hover,
[data-theme] .prev-button:hover,
[data-theme] .next-button:hover,
[data-theme] .arrow-button:hover {
background: #e8f0fe !important;
color: #1a73e8 !important;
border-color: #1a73e8 !important;
}
/* Calendar controls and date display */
body .calendar-controls,
body .current-date,
body .date-display,
[data-theme] .calendar-controls,
[data-theme] .current-date,
[data-theme] .date-display {
color: #3c4043 !important;
}
/* Ultimate nuclear approach - override EVERYTHING */
html body .app-main,
html body .app-main *,
html body .main-content,
html body .main-content *,
html body .calendar-container,
html body .calendar-container *,
html [data-theme] .app-main,
html [data-theme] .app-main *,
html [data-theme] .main-content,
html [data-theme] .main-content *,
html [data-theme] .calendar-container,
html [data-theme] .calendar-container *,
html [data-theme="default"] .app-main *,
html [data-theme="ocean"] .app-main *,
html [data-theme="forest"] .app-main *,
html [data-theme="sunset"] .app-main *,
html [data-theme="purple"] .app-main *,
html [data-theme="dark"] .app-main *,
html [data-theme="rose"] .app-main *,
html [data-theme="mint"] .app-main * {
color: #3c4043 !important;
text-shadow: none !important;
}
/* Force all text elements */
html body .app-main h1,
html body .app-main h2,
html body .app-main h3,
html body .app-main h4,
html body .app-main h5,
html body .app-main h6,
html body .app-main p,
html body .app-main span,
html body .app-main div,
html body .app-main button,
html [data-theme] .app-main h1,
html [data-theme] .app-main h2,
html [data-theme] .app-main h3,
html [data-theme] .app-main h4,
html [data-theme] .app-main h5,
html [data-theme] .app-main h6,
html [data-theme] .app-main p,
html [data-theme] .app-main span,
html [data-theme] .app-main div,
html [data-theme] .app-main button {
color: #3c4043 !important;
}
/* Exception for buttons - make them stand out */
body .app-main button,
body .main-content button,
[data-theme] .app-main button,
[data-theme] .main-content button {
color: #3c4043 !important;
background: #f8f9fa !important;
border: 1px solid #dadce0 !important;
}
/* Google-style calendar grid - more aggressive styling */
html body .calendar-grid,
html [data-theme] .calendar-grid,
body .calendar-container,
[data-theme] .calendar-container {
border: 1px solid #dadce0 !important;
border-radius: 8px !important;
overflow: hidden !important;
background: white !important;
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
html body .calendar-header,
html [data-theme] .calendar-header {
background: #f8f9fa !important;
border-bottom: 1px solid #dadce0 !important;
padding: 16px !important;
}
html body .month-header,
html body .week-header,
html [data-theme] .month-header,
html [data-theme] .week-header {
font-size: 22px !important;
font-weight: 400 !important;
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Google-style calendar cells - complete overhaul */
html body .calendar-day,
html [data-theme] .calendar-day,
body .day-cell,
[data-theme] .day-cell {
border: 1px solid #e8eaed !important;
background: white !important;
transition: background-color 0.15s ease !important;
padding: 8px !important;
min-height: 120px !important;
position: relative !important;
}
html body .calendar-day:hover,
html [data-theme] .calendar-day:hover,
body .day-cell:hover,
[data-theme] .day-cell:hover {
background: #f8f9fa !important;
box-shadow: inset 0 0 0 1px #dadce0 !important;
}
html body .calendar-day.today,
html [data-theme] .calendar-day.today,
body .day-cell.today,
[data-theme] .day-cell.today {
background: #e8f0fe !important;
border-color: #1a73e8 !important;
}
html body .calendar-day.other-month,
html [data-theme] .calendar-day.other-month,
body .day-cell.other-month,
[data-theme] .day-cell.other-month {
background: #fafafa !important;
color: #9aa0a6 !important;
}
html body .day-number,
html [data-theme] .day-number,
body .date-number,
[data-theme] .date-number {
font-size: 14px !important;
font-weight: 500 !important;
color: #3c4043 !important;
margin-bottom: 4px !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Day headers (Mon, Tue, Wed, etc.) */
html body .day-header,
html [data-theme] .day-header,
body .weekday-header,
[data-theme] .weekday-header {
background: #f8f9fa !important;
color: #5f6368 !important;
font-size: 12px !important;
font-weight: 500 !important;
text-transform: uppercase !important;
letter-spacing: 0.8px !important;
padding: 8px !important;
border-bottom: 1px solid #dadce0 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Google Calendar-style events - complete redesign */
html body .app-main .event,
html [data-theme] .app-main .event,
html body .calendar-container .event,
html [data-theme] .calendar-container .event,
body .event,
[data-theme] .event {
border-radius: 4px !important;
padding: 2px 8px !important;
font-size: 11px !important;
font-weight: 400 !important;
margin: 1px 0 2px 0 !important;
cursor: pointer !important;
border: none !important;
color: white !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
transition: transform 0.1s ease, box-shadow 0.1s ease !important;
display: block !important;
text-overflow: ellipsis !important;
overflow: hidden !important;
white-space: nowrap !important;
line-height: 1.3 !important;
}
html body .app-main .event *,
html [data-theme] .app-main .event *,
html body .calendar-container .event *,
html [data-theme] .calendar-container .event *,
body .event *,
[data-theme] .event * {
color: white !important;
font-family: inherit !important;
}
html body .app-main .event:hover,
html [data-theme] .app-main .event:hover,
body .event:hover,
[data-theme] .event:hover {
transform: translateY(-1px) !important;
box-shadow: 0 2px 8px rgba(60,64,67,.4) !important;
}
/* All-day events styling */
html body .event.all-day,
html [data-theme] .event.all-day {
border-radius: 12px !important;
padding: 4px 12px !important;
font-weight: 500 !important;
margin: 2px 0 !important;
}
/* Event time display */
html body .event-time,
html [data-theme] .event-time {
opacity: 0.9 !important;
font-size: 10px !important;
margin-right: 4px !important;
}
/* Week view events */
html body .week-view .event,
html [data-theme] .week-view .event {
border-left: 3px solid rgba(255,255,255,0.8) !important;
border-radius: 0 4px 4px 0 !important;
padding-left: 6px !important;
}
/* Calendar table structure */
html body .calendar-table,
html [data-theme] .calendar-table,
body table,
[data-theme] table {
border-collapse: separate !important;
border-spacing: 0 !important;
width: 100% !important;
background: white !important;
}
html body .calendar-table td,
html [data-theme] .calendar-table td,
body table td,
[data-theme] table td {
vertical-align: top !important;
border: 1px solid #e8eaed !important;
background: white !important;
}
/* Month/Week view toggle */
html body .view-toggle,
html [data-theme] .view-toggle {
display: flex !important;
gap: 4px !important;
background: #f1f3f4 !important;
border-radius: 6px !important;
padding: 2px !important;
}
html body .view-toggle button,
html [data-theme] .view-toggle button {
padding: 6px 12px !important;
border: none !important;
background: transparent !important;
color: #5f6368 !important;
border-radius: 4px !important;
font-size: 13px !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
}
html body .view-toggle button.active,
html [data-theme] .view-toggle button.active {
background: white !important;
color: #1a73e8 !important;
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
}
/* Today button */
html body .today-button,
html [data-theme] .today-button {
background: white !important;
border: 1px solid #dadce0 !important;
color: #1a73e8 !important;
padding: 8px 16px !important;
border-radius: 4px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
}
html body .today-button:hover,
html [data-theme] .today-button:hover {
background: #f8f9fa !important;
border-color: #1a73e8 !important;
}
/* Google-style modals */
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: var(--shadow-lg);
border: none;
}
.modal h2 {
font-size: 20px;
font-weight: 500;
color: #3c4043;
font-family: 'Google Sans', sans-serif;
}
/* Google-style form inputs */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="url"],
input[type="date"],
input[type="time"],
textarea,
select {
border: 1px solid #dadce0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: #3c4043;
background: white;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
}
/* Google-style labels */
label {
font-size: 14px;
font-weight: 500;
color: #3c4043;
margin-bottom: 4px;
display: block;
}

BIN
sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB