5 Commits

Author SHA1 Message Date
Connor Johnstone
51d5552156 Fix modal series editing to use correct backend endpoint
The modal update flow was calling the regular event update endpoint
instead of the series endpoint, preventing proper handling of the
three edit types (this event, this and future, all events).

## Changes:
- Add logic to detect when edit_scope is set for recurring events
- Route to update_series() when edit_scope is present and event has RRULE
- Map EditAction enum to backend update_scope strings:
  - EditThis → "this_only" (creates exception + EXDATE)
  - EditFuture → "this_and_future" (new series + UNTIL on original)
  - EditAll → "all_in_series" (update existing series)
- Pass occurrence date for single/future edits using original event date
- Fall back to regular update_event() for non-recurring events

Now the modal properly leverages the existing robust series endpoint
that handles RFC 5545 compliant recurring event modifications.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 19:06:11 -04:00
Connor Johnstone
5a12c0e0d0 Implement comprehensive event series editing via modal
## Frontend Changes:
- Add EditAction enum (EditThis, EditFuture, EditAll) to event context menu
- Update context menu to show 3 edit options for recurring events
- Enhance EventCreationData with edit_scope and changed_fields tracking
- Update app component to handle EditAction types and pass to modal
- Add field change tracking infrastructure to CreateEventModal

## Backend Changes:
- Add changed_fields parameter to UpdateEventSeriesRequest for optimization
- Existing series endpoint already supports the three update types:
  - "this_only" - creates exception with EXDATE
  - "this_and_future" - creates new series with UNTIL on original
  - "all_in_series" - updates existing series in-place

## Implementation Details:
- Event context menu shows single edit option for non-recurring events
- Recurring events get three options: "Edit This Event", "Edit This and Future Events", "Edit All Events in Series"
- Modal tracks which fields user actually changed for efficient updates
- Backend series endpoint already has the logic for all three update scenarios
- Full RFC 5545 compliance with proper EXDATE and UNTIL handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:49:56 -04:00
Connor Johnstone
ee181cf6cb Add Gitea Actions CI pipeline for Docker builds
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m3s
- Builds and pushes Docker image on main branch commits
- Tags with both latest and commit SHA
- Uses build cache for faster builds

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:03:50 -04:00
Connor Johnstone
74d636117d Added the volume mounts 2025-08-31 17:55:06 -04:00
Connor Johnstone
ed458e6c3a Implement complete Docker Compose CI/CD setup with optimized builds
- Add multi-stage Dockerfile with dependency caching for both frontend and backend
- Implement Docker Compose configuration with separate frontend/backend services
- Configure Caddy as reverse proxy with proper WASM and static file serving
- Add volume mounting for frontend assets shared between containers
- Optimize build process with staged compilation and workspace handling
- Add debug logging and WASM initialization tracking for production deployment
- Update README with project motivation and "vibe coded" disclaimer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 17:54:44 -04:00
13 changed files with 327 additions and 74 deletions

View File

@@ -1,6 +1,9 @@
# Build artifacts # Build artifacts
target/ target/
dist/ frontend/dist/
backend/target/
# Allow backend binary for multi-stage builds
!backend/target/release/backend
# Git # Git
.git/ .git/
@@ -21,8 +24,18 @@ Thumbs.db
# Documentation # Documentation
README.md README.md
*.md
# Docker # Development files
Dockerfile CLAUDE.md
.dockerignore *.txt
test_*.js
# Database files
*.db
calendar.db
# Test files
**/tests/
# Migrations (not needed for builds)
migrations/

View File

@@ -0,0 +1,34 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

4
.gitignore vendored
View File

@@ -19,4 +19,6 @@ dist/
.env.*.local .env.*.local
# Development notes (keep local) # Development notes (keep local)
CLAUDE.md CLAUDE.md
data/

10
Caddyfile Normal file
View File

@@ -0,0 +1,10 @@
{
default_sni rcjohnstone.com
key_type rsa4096
email c@rcjohnstone.com
}
:80, :443 {
root * /srv/www
file_server
}

View File

@@ -1,67 +1,96 @@
# Build stage # Build stage
# --------------------------------------- # -----------------------------------------------------------
FROM rust:alpine AS builder FROM rust:alpine AS builder
# Install build dependencies RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
RUN apk add --no-cache \
musl-dev \
pkgconfig \
openssl-dev \
nodejs \
npm
# Install trunk for building Yew apps # Install trunk ahead of the compilation. This may break and then you'll have to update the version.
RUN cargo install trunk wasm-pack RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100
# Add wasm32 target
RUN rustup target add wasm32-unknown-unknown RUN rustup target add wasm32-unknown-unknown
# Set working directory
WORKDIR /app WORKDIR /app
# Copy dependency files # Copy workspace files to maintain workspace structure
COPY Cargo.toml ./ COPY Cargo.toml Cargo.lock ./
COPY src ./src 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/
# Copy web assets # Create empty backend directory to satisfy workspace
COPY index.html ./ RUN mkdir -p backend/src && \
COPY Trunk.toml ./ 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
# Build the application
RUN trunk build --release
# Runtime stage # Runtime stage
# --------------------------------------- # -----------------------------------------------------------
FROM docker.io/nginx:alpine FROM alpine:latest
# Remove default nginx content # Install runtime dependencies
RUN rm -rf /usr/share/nginx/html/* RUN apk add --no-cache ca-certificates tzdata
# Copy built application from builder stage # Copy frontend files to temporary location
COPY --from=builder /app/dist/* /usr/share/nginx/html/ COPY --from=builder /app/frontend/dist /app/frontend-dist
# Add nginx configuration for SPA # Copy backend binary (built in workspace root)
RUN echo 'server { \ COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
listen 80; \
server_name localhost; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
# Enable gzip compression \
gzip on; \
gzip_types text/css application/javascript application/wasm; \
}' > /etc/nginx/conf.d/default.conf
# Expose port # Create startup script to copy frontend files to shared volume
EXPOSE 80 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
# Health check # Start with script that copies frontend files then starts backend
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD ["/usr/local/bin/start.sh"]
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,5 +1,8 @@
# Modern CalDAV Web Client # Modern CalDAV Web Client
>[!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 full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
## Motivation ## Motivation
@@ -122,4 +125,4 @@ This client is designed to work with any RFC-compliant CalDAV server:
- **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
*Note: While the client follows RFC standards and should work with any compliant CalDAV server, we have currently only tested extensively with Baikal. Testing with other servers is planned.* *Note: While the client follows RFC standards and should work with any compliant CalDAV server, we have currently only tested extensively with Baikal. Testing with other servers is planned.*

View File

@@ -201,6 +201,7 @@ pub struct UpdateEventSeriesRequest {
// Update scope control // Update scope control
pub update_scope: String, // "this_only", "this_and_future", "all_in_series" pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

22
compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
calendar-backend:
build: .
env_file:
- .env
ports:
- "3000:3000"
volumes:
- ./data/site_dist:/srv/www
calendar-frontend:
image: caddy
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- ./data/site_dist:/srv/www:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy/data:/data
- ./data/caddy/config:/config

View File

@@ -4,7 +4,15 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Calendar App</title> <title>Calendar App</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 />
<link data-trunk rel="css" href="styles.css"> <link data-trunk rel="css" href="styles.css">
</head> </head>
<body></body> <body>
<script>
console.log("HTML loaded, waiting for WASM...");
window.addEventListener('TrunkApplicationStarted', () => {
console.log("Trunk application started successfully!");
});
</script>
</body>
</html> </html>

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage}; use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent; use web_sys::MouseEvent;
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction}; use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction};
use crate::services::{CalendarService, calendar_service::UserInfo}; use crate::services::{CalendarService, calendar_service::UserInfo};
use crate::models::ical::VEvent; use crate::models::ical::VEvent;
use chrono::NaiveDate; use chrono::NaiveDate;
@@ -54,6 +54,7 @@ pub fn App() -> Html {
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None }); let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false); let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None }); let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_modal_open = use_state(|| false);
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
@@ -575,6 +576,9 @@ pub fn App() -> Html {
}) })
}; };
// Debug logging
web_sys::console::log_1(&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into());
html! { html! {
<BrowserRouter> <BrowserRouter>
<div class="app" onclick={on_outside_click}> <div class="app" onclick={on_outside_click}>
@@ -735,8 +739,10 @@ pub fn App() -> Html {
let _event_context_menu_event = event_context_menu_event.clone(); let _event_context_menu_event = event_context_menu_event.clone();
let event_context_menu_open = event_context_menu_open.clone(); let event_context_menu_open = event_context_menu_open.clone();
let create_event_modal_open = create_event_modal_open.clone(); let create_event_modal_open = create_event_modal_open.clone();
move |_| { let event_edit_scope = event_edit_scope.clone();
// Close the context menu and open the edit modal move |edit_action: EditAction| {
// Set the edit scope and close the context menu
event_edit_scope.set(Some(edit_action));
event_context_menu_open.set(false); event_context_menu_open.set(false);
create_event_modal_open.set(true); create_event_modal_open.set(true);
} }
@@ -837,13 +843,16 @@ pub fn App() -> Html {
is_open={*create_event_modal_open} is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()} selected_date={(*selected_date_for_event).clone()}
event_to_edit={(*event_context_menu_event).clone()} event_to_edit={(*event_context_menu_event).clone()}
edit_scope={(*event_edit_scope).clone()}
on_close={Callback::from({ on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone(); let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone(); let event_context_menu_event = event_context_menu_event.clone();
let event_edit_scope = event_edit_scope.clone();
move |_| { move |_| {
create_event_modal_open.set(false); create_event_modal_open.set(false);
// Clear the event being edited // Clear the event being edited and edit scope
event_context_menu_event.set(None); event_context_menu_event.set(None);
event_edit_scope.set(None);
} }
})} })}
on_create={on_event_create} on_create={on_event_create}
@@ -851,10 +860,12 @@ pub fn App() -> Html {
let auth_token = auth_token.clone(); let auth_token = auth_token.clone();
let create_event_modal_open = create_event_modal_open.clone(); let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone(); let event_context_menu_event = event_context_menu_event.clone();
let event_edit_scope = event_edit_scope.clone();
move |(original_event, updated_data): (VEvent, EventCreationData)| { move |(original_event, updated_data): (VEvent, EventCreationData)| {
web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into()); web_sys::console::log_1(&format!("Updating event: {:?}, edit_scope: {:?}", updated_data, updated_data.edit_scope).into());
create_event_modal_open.set(false); create_event_modal_open.set(false);
event_context_menu_event.set(None); event_context_menu_event.set(None);
event_edit_scope.set(None);
if let Some(token) = (*auth_token).clone() { if let Some(token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
@@ -985,8 +996,61 @@ pub fn App() -> Html {
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap(); web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap();
} }
} else { } else {
// Calendar hasn't changed - normal update // Calendar hasn't changed - check if we should use series endpoint
match calendar_service.update_event( let use_series_endpoint = updated_data.edit_scope.is_some() && original_event.rrule.is_some();
if use_series_endpoint {
// Use series endpoint for recurring event modal edits
let update_scope = match updated_data.edit_scope.as_ref().unwrap() {
EditAction::EditThis => "this_only",
EditAction::EditFuture => "this_and_future",
EditAction::EditAll => "all_in_series",
};
// For single occurrence edits, we need the occurrence date
let occurrence_date = if update_scope == "this_only" || update_scope == "this_and_future" {
// Use the original event's start date as the occurrence date
Some(original_event.dtstart.format("%Y-%m-%d").to_string())
} else {
None
};
match calendar_service.update_series(
&token,
&password,
original_event.uid,
updated_data.title,
updated_data.description,
start_date,
start_time,
end_date,
end_time,
updated_data.location,
updated_data.all_day,
status_str,
class_str,
updated_data.priority,
updated_data.organizer,
updated_data.attendees,
updated_data.categories,
reminder_str,
recurrence_str,
updated_data.selected_calendar,
update_scope.to_string(),
occurrence_date,
).await {
Ok(_) => {
web_sys::console::log_1(&"Series updated successfully".into());
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to update series: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to update series: {}", err)).unwrap();
}
}
} else {
// Use regular event endpoint for non-recurring events or legacy updates
match calendar_service.update_event(
&token, &token,
&password, &password,
original_event.uid, original_event.uid,
@@ -1022,6 +1086,7 @@ pub fn App() -> Html {
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap(); web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
} }
} }
}
} }
}); });
} }

View File

@@ -4,6 +4,7 @@ use wasm_bindgen::JsCast;
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike}; use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike};
use crate::services::calendar_service::CalendarInfo; use crate::services::calendar_service::CalendarInfo;
use crate::models::ical::VEvent; use crate::models::ical::VEvent;
use crate::components::EditAction;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct CreateEventModalProps { pub struct CreateEventModalProps {
@@ -18,6 +19,8 @@ pub struct CreateEventModalProps {
pub initial_start_time: Option<NaiveTime>, pub initial_start_time: Option<NaiveTime>,
#[prop_or_default] #[prop_or_default]
pub initial_end_time: Option<NaiveTime>, pub initial_end_time: Option<NaiveTime>,
#[prop_or_default]
pub edit_scope: Option<EditAction>,
} }
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
@@ -330,6 +333,10 @@ pub struct EventCreationData {
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc. pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31) pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec] pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
// Edit scope and tracking fields
pub edit_scope: Option<EditAction>,
pub changed_fields: Vec<String>, // List of field names that were changed
} }
impl Default for EventCreationData { impl Default for EventCreationData {
@@ -365,6 +372,10 @@ impl Default for EventCreationData {
monthly_by_day: None, monthly_by_day: None,
monthly_by_monthday: None, monthly_by_monthday: None,
yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default
// Edit scope and tracking defaults
edit_scope: None,
changed_fields: vec![],
} }
} }
} }
@@ -566,6 +577,10 @@ impl EventCreationData {
} else { } else {
vec![false; 12] vec![false; 12]
}, },
// Edit scope and tracking defaults (will be set later if needed)
edit_scope: None,
changed_fields: vec![],
} }
} }
@@ -593,9 +608,9 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let active_tab = use_state(|| ModalTab::default()); let active_tab = use_state(|| ModalTab::default());
// Initialize with selected date or event data if provided // Initialize with selected date or event data if provided
use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time), { use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time, props.edit_scope.clone()), {
let event_data = event_data.clone(); let event_data = event_data.clone();
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| { move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time, edit_scope)| {
if *is_open { if *is_open {
let mut data = if let Some(event) = event_to_edit { let mut data = if let Some(event) = event_to_edit {
// Pre-populate with event data for editing // Pre-populate with event data for editing
@@ -625,6 +640,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
data.selected_calendar = Some(available_calendars[0].path.clone()); 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); event_data.set(data);
} }
|| () || ()
@@ -644,12 +664,25 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
}) })
}; };
// Helper function to track field changes
let _track_field_change = |data: &mut EventCreationData, field_name: &str| {
if !data.changed_fields.contains(&field_name.to_string()) {
data.changed_fields.push(field_name.to_string());
}
};
let on_title_input = { let on_title_input = {
let event_data = event_data.clone(); let event_data = event_data.clone();
Callback::from(move |e: InputEvent| { Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone(); let mut data = (*event_data).clone();
data.title = input.value(); let new_value = input.value();
if data.title != new_value {
data.title = new_value;
if !data.changed_fields.contains(&"title".to_string()) {
data.changed_fields.push("title".to_string());
}
}
event_data.set(data); event_data.set(data);
} }
}) })
@@ -661,7 +694,13 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone(); let mut data = (*event_data).clone();
let value = select.value(); let value = select.value();
data.selected_calendar = if value.is_empty() { None } else { Some(value) }; let new_calendar = if value.is_empty() { None } else { Some(value) };
if data.selected_calendar != new_calendar {
data.selected_calendar = new_calendar;
if !data.changed_fields.contains(&"selected_calendar".to_string()) {
data.changed_fields.push("selected_calendar".to_string());
}
}
event_data.set(data); event_data.set(data);
} }
}) })

View File

@@ -9,13 +9,20 @@ pub enum DeleteAction {
DeleteSeries, DeleteSeries,
} }
#[derive(Clone, PartialEq, Debug)]
pub enum EditAction {
EditThis,
EditFuture,
EditAll,
}
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct EventContextMenuProps { pub struct EventContextMenuProps {
pub is_open: bool, pub is_open: bool,
pub x: i32, pub x: i32,
pub y: i32, pub y: i32,
pub event: Option<VEvent>, pub event: Option<VEvent>,
pub on_edit: Callback<()>, pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>, pub on_delete: Callback<DeleteAction>,
pub on_close: Callback<()>, pub on_close: Callback<()>,
} }
@@ -38,11 +45,11 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
.map(|event| event.rrule.is_some()) .map(|event| event.rrule.is_some())
.unwrap_or(false); .unwrap_or(false);
let on_edit_click = { let create_edit_callback = |action: EditAction| {
let on_edit = props.on_edit.clone(); let on_edit = props.on_edit.clone();
let on_close = props.on_close.clone(); let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| { Callback::from(move |_: MouseEvent| {
on_edit.emit(()); on_edit.emit(action.clone());
on_close.emit(()); on_close.emit(());
}) })
}; };
@@ -62,9 +69,29 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
class="context-menu" class="context-menu"
style={style} style={style}
> >
<div class="context-menu-item" onclick={on_edit_click}> {
{"Edit Event"} if is_recurring {
</div> html! {
<>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"Edit This Event"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}>
{"Edit This and Future Events"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}>
{"Edit All Events in Series"}
</div>
</>
}
} else {
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"Edit Event"}
</div>
}
}
}
{ {
if is_recurring { if is_recurring {
html! { html! {

View File

@@ -22,7 +22,7 @@ pub use week_view::WeekView;
pub use event_modal::EventModal; pub use event_modal::EventModal;
pub use create_calendar_modal::CreateCalendarModal; pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu; pub use context_menu::ContextMenu;
pub use event_context_menu::{EventContextMenu, DeleteAction}; pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
pub use calendar_context_menu::CalendarContextMenu; pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
pub use sidebar::{Sidebar, ViewMode, Theme}; pub use sidebar::{Sidebar, ViewMode, Theme};