Compare commits
4 Commits
6c67444b19
...
85d23b0347
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d23b0347 | ||
|
|
13db4abc0f | ||
|
|
57e434e4ff | ||
|
|
7c2901f453 |
@@ -29,7 +29,7 @@ RUN mkdir -p frontend/src && \
|
||||
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
|
||||
RUN cargo build --release --target wasm32-unknown-unknown --bin runway
|
||||
|
||||
# Copy actual source code and build the frontend application
|
||||
RUN rm -rf frontend
|
||||
@@ -55,7 +55,7 @@ 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 && \
|
||||
printf '[package]\nname = "runway"\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
|
||||
|
||||
19
README.md
19
README.md
@@ -1,13 +1,20 @@
|
||||
# Modern CalDAV Web Client
|
||||
# Runway
|
||||
## _Passive infrastructure for life's coordination_
|
||||
|
||||
>[!WARNING]
|
||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
||||
|
||||
A full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
|
||||
A modern CalDAV web client built with Rust WebAssembly.
|
||||
|
||||
## Motivation
|
||||
## The Name
|
||||
|
||||
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. This project aims to provide a modern, fast, and reliable web interface for CalDAV servers.
|
||||
Runway embodies the concept of **passive infrastructure** — unobtrusive systems that enable better coordination without getting in the way. Planes can fly and do lots of cool things, but without runways, they can't take off or land. Similarly, calendars and scheduling tools are essential for organizing our lives, but they should not dominate our attention.
|
||||
|
||||
The best infrastructure is invisible when working, essential when needed, and enables rather than constrains.
|
||||
|
||||
## Why Runway?
|
||||
|
||||
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. Runway provides a modern, fast, and reliable web interface for CalDAV servers — infrastructure that just works.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -63,7 +70,7 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
### 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**:
|
||||
```bash
|
||||
@@ -162,7 +169,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "calendar-app"
|
||||
name = "runway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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">
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
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! {
|
||||
@@ -219,7 +219,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</div>
|
||||
|
||||
<div class="theme-selector">
|
||||
<label>{"Theme:"}</label>
|
||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||
@@ -233,7 +232,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</div>
|
||||
|
||||
<div class="style-selector">
|
||||
<label>{"Style:"}</label>
|
||||
<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>
|
||||
|
||||
@@ -353,6 +353,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||
let is_today = *date == props.today;
|
||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||
let event_layouts = calculate_event_layout(&day_events, *date);
|
||||
|
||||
// Drag event handlers
|
||||
let drag_state_clone = drag_state.clone();
|
||||
@@ -398,6 +399,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
let time_increment = props.time_increment;
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only process mouse move if a button is still pressed
|
||||
if e.buttons() == 0 {
|
||||
// No mouse button pressed, clear drag state
|
||||
drag_state.set(None);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
||||
if current_drag.is_dragging {
|
||||
// Use layer_y for consistent coordinate calculation
|
||||
@@ -567,9 +575,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
// Check if currently dragging to create an event
|
||||
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
|
||||
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
||||
class={classes!(
|
||||
"week-day-column",
|
||||
if is_today { Some("today") } else { None },
|
||||
if is_creating_event { Some("creating-event") } else { None }
|
||||
)}
|
||||
{onmousedown}
|
||||
{onmousemove}
|
||||
{onmouseup}
|
||||
@@ -589,7 +608,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Events positioned absolutely based on their actual times
|
||||
<div class="events-container">
|
||||
{
|
||||
day_events.iter().filter_map(|event| {
|
||||
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||
|
||||
// Skip events that don't belong on this date or have invalid positioning
|
||||
@@ -782,12 +801,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
if is_refreshing { Some("refreshing") } else { None },
|
||||
if is_all_day { Some("all-day") } else { None }
|
||||
)}
|
||||
style={format!(
|
||||
"background-color: {}; top: {}px; height: {}px;",
|
||||
event_color,
|
||||
start_pixels,
|
||||
duration_pixels
|
||||
)}
|
||||
style={
|
||||
let (column_idx, total_columns) = event_layouts[event_idx];
|
||||
let column_width = if total_columns > 1 {
|
||||
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
|
||||
} else {
|
||||
"calc(100% - 8px)".to_string()
|
||||
};
|
||||
let left_offset = if total_columns > 1 {
|
||||
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
|
||||
} else {
|
||||
"4px".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
|
||||
event_color,
|
||||
start_pixels,
|
||||
duration_pixels,
|
||||
left_offset,
|
||||
column_width
|
||||
)
|
||||
}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
onmousedown={onmousedown_event}
|
||||
@@ -835,7 +870,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Temporary event box during drag
|
||||
{
|
||||
if let Some(drag) = (*drag_state).clone() {
|
||||
if drag.is_dragging && drag.start_date == *date {
|
||||
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
|
||||
match &drag.drag_type {
|
||||
DragType::CreateEvent => {
|
||||
let start_y = drag.start_y.min(drag.current_y);
|
||||
@@ -1065,3 +1100,82 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
||||
|
||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||
}
|
||||
|
||||
// Check if two events overlap in time
|
||||
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
||||
let end1 = if let Some(end) = event1.dtend {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
} else {
|
||||
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||
};
|
||||
|
||||
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
||||
let end2 = if let Some(end) = event2.dtend {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
} else {
|
||||
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||
};
|
||||
|
||||
// Events overlap if one starts before the other ends
|
||||
start1 < end2 && start2 < end1
|
||||
}
|
||||
|
||||
// Calculate layout columns for overlapping events
|
||||
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
|
||||
// Filter events that should appear on this date and sort by start time
|
||||
let mut day_events: Vec<_> = events.iter()
|
||||
.filter(|event| {
|
||||
let (_, _, _) = calculate_event_position(event, date);
|
||||
// Only include events that would be positioned (non-zero dimensions or all-day)
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
event_date == date ||
|
||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by start time
|
||||
day_events.sort_by_key(|event| event.dtstart.with_timezone(&Local).naive_local());
|
||||
|
||||
// Calculate layout: (column_index, total_columns)
|
||||
let mut layout = Vec::with_capacity(day_events.len());
|
||||
let mut columns: Vec<Vec<&VEvent>> = Vec::new();
|
||||
|
||||
for event in &day_events {
|
||||
// Find the first column where this event doesn't overlap with any existing event
|
||||
let mut placed = false;
|
||||
for (col_idx, column) in columns.iter_mut().enumerate() {
|
||||
if !column.iter().any(|existing_event| events_overlap(event, existing_event)) {
|
||||
column.push(event);
|
||||
layout.push((col_idx, 0)); // total_columns will be set later
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !placed {
|
||||
// Create new column
|
||||
columns.push(vec![event]);
|
||||
layout.push((columns.len() - 1, 0)); // total_columns will be set later
|
||||
}
|
||||
}
|
||||
|
||||
// Update total_columns for all events
|
||||
let total_columns = columns.len();
|
||||
for (_, total_cols) in layout.iter_mut() {
|
||||
*total_cols = total_columns;
|
||||
}
|
||||
|
||||
// Create result mapping original events to their layout
|
||||
let mut result = Vec::with_capacity(events.len());
|
||||
for event in events {
|
||||
if let Some(pos) = day_events.iter().position(|e| e.uid == event.uid) {
|
||||
result.push(layout[pos]);
|
||||
} else {
|
||||
result.push((0, 1)); // Default: single column
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -782,8 +782,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;
|
||||
@@ -803,6 +802,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;
|
||||
|
||||
Reference in New Issue
Block a user