Compare commits
16 Commits
bugfix/wee
...
a9521ad536
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9521ad536 | ||
|
|
5456d7140c | ||
|
|
62cc910e1a | ||
|
|
6ec7bb5422 | ||
|
|
ce74750d85 | ||
|
|
d089f1545b | ||
|
|
7b06fef6c3 | ||
|
|
7be9f5a869 | ||
|
|
a7ebbe0635 | ||
|
|
3662f117f5 | ||
|
|
0899a84b42 | ||
|
|
85d23b0347 | ||
|
|
13db4abc0f | ||
|
|
57e434e4ff | ||
|
|
7c2901f453 | ||
| 6c67444b19 |
@@ -18,17 +18,18 @@ jobs:
|
|||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
registry: ${{ vars.REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ vars.USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: ./backend/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
|
${{ vars.REGISTRY }}/connor/calendar:latest
|
||||||
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
|
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80, :443 {
|
:80, :443 {
|
||||||
|
try_files {path} /index.html
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
109
Dockerfile
109
Dockerfile
@@ -1,109 +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
|
|
||||||
|
|
||||||
# Install sqlx-cli for migrations
|
|
||||||
RUN cargo install sqlx-cli --no-default-features --features sqlite
|
|
||||||
|
|
||||||
# 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 sqlite
|
|
||||||
|
|
||||||
# Copy frontend files to temporary location
|
|
||||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
|
||||||
|
|
||||||
# Copy backend binary and sqlx-cli
|
|
||||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
|
||||||
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
|
||||||
|
|
||||||
# Copy migrations for database setup
|
|
||||||
COPY --from=backend-builder /app/backend/migrations /migrations
|
|
||||||
|
|
||||||
# Create startup script to copy frontend files, run migrations, and start backend
|
|
||||||
RUN mkdir -p /srv/www /db
|
|
||||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
|
||||||
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo '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 copies frontend files then starts backend
|
|
||||||
CMD ["/usr/local/bin/start.sh"]
|
|
||||||
21
README.md
21
README.md
@@ -1,13 +1,22 @@
|
|||||||
# Modern CalDAV Web Client
|
# Runway
|
||||||
|
## _Passive infrastructure for life's coordination_
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
>[!WARNING]
|
>[!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.
|
>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
|
## Features
|
||||||
|
|
||||||
@@ -63,7 +72,7 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
### Docker Deployment (Recommended)
|
### Docker Deployment (Recommended)
|
||||||
|
|
||||||
The easiest way to run the calendar is using Docker Compose:
|
The easiest way to run Runway is using Docker Compose:
|
||||||
|
|
||||||
1. **Clone the repository**:
|
1. **Clone the repository**:
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +171,7 @@ calendar/
|
|||||||
This client is designed to work with any RFC-compliant CalDAV server:
|
This client is designed to work with any RFC-compliant CalDAV server:
|
||||||
|
|
||||||
- **Baikal** - ✅ Fully tested with complete event and recurrence support
|
- **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
|
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
||||||
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
||||||
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
||||||
|
|||||||
64
backend/Dockerfile
Normal file
64
backend/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Build stage
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies for backend
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||||
|
|
||||||
|
# Install sqlx-cli for migrations
|
||||||
|
RUN cargo install sqlx-cli --no-default-features --features sqlite
|
||||||
|
|
||||||
|
# Copy workspace files to maintain workspace structure
|
||||||
|
COPY ./Cargo.toml ./
|
||||||
|
COPY ./calendar-models ./calendar-models
|
||||||
|
|
||||||
|
# Create empty frontend directory to satisfy workspace
|
||||||
|
RUN mkdir -p frontend/src && \
|
||||||
|
printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
|
||||||
|
echo 'fn main() {}' > frontend/src/main.rs
|
||||||
|
|
||||||
|
# Copy backend files
|
||||||
|
COPY backend/Cargo.toml ./backend/
|
||||||
|
|
||||||
|
# Create dummy backend source to build dependencies first
|
||||||
|
RUN mkdir -p backend/src && \
|
||||||
|
echo "fn main() {}" > backend/src/main.rs
|
||||||
|
|
||||||
|
# Build dependencies (this layer will be cached unless dependencies change)
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Copy actual backend source and build
|
||||||
|
COPY backend/src ./backend/src
|
||||||
|
COPY backend/migrations ./backend/migrations
|
||||||
|
RUN cargo build --release --bin backend
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||||
|
|
||||||
|
# Copy backend binary and sqlx-cli
|
||||||
|
COPY --from=builder /app/target/release/backend /usr/local/bin/backend
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||||
|
|
||||||
|
# Copy migrations for database setup
|
||||||
|
COPY backend/migrations /migrations
|
||||||
|
|
||||||
|
# Create startup script to run migrations and start backend
|
||||||
|
RUN mkdir -p /db
|
||||||
|
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'touch /db/calendar.db' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||||
|
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||||
|
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||||
|
chmod +x /usr/local/bin/start.sh
|
||||||
|
|
||||||
|
# Start with script that runs migrations then starts backend
|
||||||
|
CMD ["/usr/local/bin/start.sh"]
|
||||||
@@ -417,11 +417,19 @@ pub async fn create_event(
|
|||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
if end_datetime <= start_datetime {
|
if request.all_day {
|
||||||
return Err(ApiError::BadRequest(
|
if end_datetime < start_datetime {
|
||||||
"End date/time must be after start date/time".to_string(),
|
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
|
// Generate a unique UID for the event
|
||||||
@@ -707,11 +715,19 @@ pub async fn update_event(
|
|||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
if end_datetime <= start_datetime {
|
if request.all_day {
|
||||||
return Err(ApiError::BadRequest(
|
if end_datetime < start_datetime {
|
||||||
"End date/time must be after start date/time".to_string(),
|
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
|
// Update event properties
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
calendar-backend:
|
calendar-backend:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./backend/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/site_dist:/srv/www
|
|
||||||
- ./data/db:/db
|
- ./data/db:/db
|
||||||
|
|
||||||
calendar-frontend:
|
calendar-frontend:
|
||||||
@@ -15,7 +16,7 @@ services:
|
|||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/site_dist:/srv/www:ro
|
- ./frontend/dist:/srv/www:ro
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- ./data/caddy/data:/data
|
- ./data/caddy/data:/data
|
||||||
- ./data/caddy/config:/config
|
- ./data/caddy/config:/config
|
||||||
|
|||||||
4
deploy_frontend.sh
Executable file
4
deploy_frontend.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||||
|
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "calendar-app"
|
name = "runway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Calendar App</title>
|
<title>Runway</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base data-trunk-public-url />
|
<base data-trunk-public-url />
|
||||||
<link data-trunk rel="css" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
console.log("HTML loaded, waiting for WASM...");
|
console.log("HTML fully loaded, waiting for WASM...");
|
||||||
window.addEventListener('TrunkApplicationStarted', () => {
|
window.addEventListener('TrunkApplicationStarted', () => {
|
||||||
console.log("Trunk application started successfully!");
|
console.log("Trunk application started successfully!");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>{"Calendar App"}</h1>
|
<h1>{"Runway"}</h1>
|
||||||
{
|
{
|
||||||
if let Some(ref info) = props.user_info {
|
if let Some(ref info) = props.user_info {
|
||||||
html! {
|
html! {
|
||||||
@@ -219,7 +219,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="theme-selector">
|
<div class="theme-selector">
|
||||||
<label>{"Theme:"}</label>
|
|
||||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||||
@@ -233,7 +232,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="style-selector">
|
<div class="style-selector">
|
||||||
<label>{"Style:"}</label>
|
|
||||||
<select class="style-selector-dropdown" onchange={on_style_change}>
|
<select class="style-selector-dropdown" onchange={on_style_change}>
|
||||||
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
||||||
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
||||||
|
|||||||
@@ -319,11 +319,52 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
week_days.iter().map(|date| {
|
week_days.iter().map(|date| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
let weekday_name = get_weekday_name(date.weekday());
|
||||||
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
// Filter for all-day events only
|
||||||
|
let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||||
<div class="weekday-name">{weekday_name}</div>
|
<div class="day-header-content">
|
||||||
<div class="day-number">{date.day()}</div>
|
<div class="weekday-name">{weekday_name}</div>
|
||||||
|
<div class="day-number">{date.day()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// All-day events section
|
||||||
|
{if !all_day_events.is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="all-day-events">
|
||||||
|
{
|
||||||
|
all_day_events.iter().map(|event| {
|
||||||
|
let event_color = get_event_color(event);
|
||||||
|
let onclick = {
|
||||||
|
let on_event_click = props.on_event_click.clone();
|
||||||
|
let event = (*event).clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_event_click.emit(event.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="all-day-event"
|
||||||
|
style={format!("background-color: {}", event_color)}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
<span class="all-day-event-title">
|
||||||
|
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -353,6 +394,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
|
let event_layouts = calculate_event_layout(&day_events, *date);
|
||||||
|
|
||||||
// Drag event handlers
|
// Drag event handlers
|
||||||
let drag_state_clone = drag_state.clone();
|
let drag_state_clone = drag_state.clone();
|
||||||
@@ -398,6 +440,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only process mouse move if a button is still pressed
|
||||||
|
if e.buttons() == 0 {
|
||||||
|
// No mouse button pressed, clear drag state
|
||||||
|
drag_state.set(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
if let Some(mut current_drag) = (*drag_state).clone() {
|
||||||
if current_drag.is_dragging {
|
if current_drag.is_dragging {
|
||||||
// Use layer_y for consistent coordinate calculation
|
// Use layer_y for consistent coordinate calculation
|
||||||
@@ -567,9 +616,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if currently dragging to create an event
|
||||||
|
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
|
||||||
|
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
class={classes!(
|
||||||
|
"week-day-column",
|
||||||
|
if is_today { Some("today") } else { None },
|
||||||
|
if is_creating_event { Some("creating-event") } else { None }
|
||||||
|
)}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
{onmousemove}
|
{onmousemove}
|
||||||
{onmouseup}
|
{onmouseup}
|
||||||
@@ -589,11 +649,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Events positioned absolutely based on their actual times
|
// Events positioned absolutely based on their actual times
|
||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().filter_map(|event| {
|
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||||
|
|
||||||
|
// Skip all-day events (they're rendered in the header)
|
||||||
|
if is_all_day {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip events that don't belong on this date or have invalid positioning
|
// Skip events that don't belong on this date or have invalid positioning
|
||||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
if start_pixels == 0.0 && duration_pixels == 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,12 +847,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if is_refreshing { Some("refreshing") } else { None },
|
if is_refreshing { Some("refreshing") } else { None },
|
||||||
if is_all_day { Some("all-day") } else { None }
|
if is_all_day { Some("all-day") } else { None }
|
||||||
)}
|
)}
|
||||||
style={format!(
|
style={
|
||||||
"background-color: {}; top: {}px; height: {}px;",
|
let (column_idx, total_columns) = event_layouts[event_idx];
|
||||||
event_color,
|
let column_width = if total_columns > 1 {
|
||||||
start_pixels,
|
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
|
||||||
duration_pixels
|
} else {
|
||||||
)}
|
"calc(100% - 8px)".to_string()
|
||||||
|
};
|
||||||
|
let left_offset = if total_columns > 1 {
|
||||||
|
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
|
||||||
|
} else {
|
||||||
|
"4px".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
|
||||||
|
event_color,
|
||||||
|
start_pixels,
|
||||||
|
duration_pixels,
|
||||||
|
left_offset,
|
||||||
|
column_width
|
||||||
|
)
|
||||||
|
}
|
||||||
{onclick}
|
{onclick}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
onmousedown={onmousedown_event}
|
onmousedown={onmousedown_event}
|
||||||
@@ -835,7 +916,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Temporary event box during drag
|
// Temporary event box during drag
|
||||||
{
|
{
|
||||||
if let Some(drag) = (*drag_state).clone() {
|
if let Some(drag) = (*drag_state).clone() {
|
||||||
if drag.is_dragging && drag.start_date == *date {
|
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
|
||||||
match &drag.drag_type {
|
match &drag.drag_type {
|
||||||
DragType::CreateEvent => {
|
DragType::CreateEvent => {
|
||||||
let start_y = drag.start_y.min(drag.current_y);
|
let start_y = drag.start_y.min(drag.current_y);
|
||||||
@@ -1065,3 +1146,111 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if two events overlap in time
|
||||||
|
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||||
|
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
||||||
|
let end1 = if let Some(end) = event1.dtend {
|
||||||
|
end.with_timezone(&Local).naive_local()
|
||||||
|
} else {
|
||||||
|
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
|
};
|
||||||
|
|
||||||
|
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
||||||
|
let end2 = if let Some(end) = event2.dtend {
|
||||||
|
end.with_timezone(&Local).naive_local()
|
||||||
|
} else {
|
||||||
|
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Events overlap if one starts before the other ends
|
||||||
|
start1 < end2 && start2 < end1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate layout columns for overlapping events
|
||||||
|
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
|
||||||
|
|
||||||
|
// Filter and sort events that should appear on this date
|
||||||
|
let mut day_events: Vec<_> = events.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, event)| {
|
||||||
|
let (_, _, _) = calculate_event_position(event, date);
|
||||||
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
|
let event_date = local_start.date_naive();
|
||||||
|
if event_date == date ||
|
||||||
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||||
|
Some((idx, event))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by start time
|
||||||
|
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
||||||
|
|
||||||
|
// For each event, find all events it overlaps with
|
||||||
|
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
||||||
|
|
||||||
|
for i in 0..day_events.len() {
|
||||||
|
let (orig_idx_i, event_i) = day_events[i];
|
||||||
|
|
||||||
|
// Find all events that overlap with this event
|
||||||
|
let mut overlapping_events = vec![i];
|
||||||
|
for j in 0..day_events.len() {
|
||||||
|
if i != j {
|
||||||
|
let (_, event_j) = day_events[j];
|
||||||
|
if events_overlap(event_i, event_j) {
|
||||||
|
overlapping_events.push(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this event doesn't overlap with anything, it gets full width
|
||||||
|
if overlapping_events.len() == 1 {
|
||||||
|
event_columns[orig_idx_i] = (0, 1);
|
||||||
|
} else {
|
||||||
|
// This event overlaps - we need to calculate column layout
|
||||||
|
// Sort the overlapping group by start time
|
||||||
|
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
|
||||||
|
|
||||||
|
// Assign columns using a greedy algorithm
|
||||||
|
let mut columns: Vec<Vec<usize>> = Vec::new();
|
||||||
|
|
||||||
|
for &event_idx in &overlapping_events {
|
||||||
|
let (orig_idx, event) = day_events[event_idx];
|
||||||
|
|
||||||
|
// Find the first column where this event doesn't overlap with existing events
|
||||||
|
let mut placed = false;
|
||||||
|
for (col_idx, column) in columns.iter_mut().enumerate() {
|
||||||
|
let can_place = column.iter().all(|&existing_idx| {
|
||||||
|
let (_, existing_event) = day_events[existing_idx];
|
||||||
|
!events_overlap(event, existing_event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if can_place {
|
||||||
|
column.push(event_idx);
|
||||||
|
event_columns[orig_idx] = (col_idx, columns.len());
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !placed {
|
||||||
|
// Create new column
|
||||||
|
columns.push(vec![event_idx]);
|
||||||
|
event_columns[orig_idx] = (columns.len() - 1, columns.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update total_columns for all events in this overlapping group
|
||||||
|
let total_columns = columns.len();
|
||||||
|
for &event_idx in &overlapping_events {
|
||||||
|
let (orig_idx, _) = day_events[event_idx];
|
||||||
|
event_columns[orig_idx].1 = total_columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_columns
|
||||||
|
}
|
||||||
|
|||||||
@@ -650,11 +650,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.week-day-header {
|
.week-day-header {
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||||
background: var(--weekday-header-bg, #f8f9fa);
|
background: var(--weekday-header-bg, #f8f9fa);
|
||||||
color: var(--weekday-header-text, inherit);
|
color: var(--weekday-header-text, inherit);
|
||||||
|
min-height: 70px; /* Ensure space for all-day events */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-header.today {
|
.week-day-header.today {
|
||||||
@@ -680,6 +683,45 @@ body {
|
|||||||
color: var(--calendar-today-text, #1976d2);
|
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 */
|
||||||
.week-content {
|
.week-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -782,8 +824,7 @@ body {
|
|||||||
/* Week Events */
|
/* Week Events */
|
||||||
.week-event {
|
.week-event {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
left: 4px;
|
/* left and width are now set inline for overlap handling */
|
||||||
right: 4px;
|
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
background: #3B82F6;
|
background: #3B82F6;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -803,6 +844,20 @@ body {
|
|||||||
white-space: nowrap;
|
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 {
|
.week-event:hover {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
|
|||||||
BIN
sample.png
Normal file
BIN
sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Reference in New Issue
Block a user