Compare commits
5 Commits
a4476dcfae
...
890940fe31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 |
@@ -4,7 +4,7 @@
|
|||||||

|

|
||||||
|
|
||||||
>[!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 decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
|
||||||
|
|
||||||
A modern CalDAV web client built with Rust WebAssembly.
|
A modern CalDAV web client built with Rust WebAssembly.
|
||||||
|
|
||||||
|
|||||||
@@ -164,17 +164,20 @@ impl EventCreationData {
|
|||||||
self.end_time.format("%H:%M").to_string(),
|
self.end_time.format("%H:%M").to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Convert local date/time to UTC
|
// Convert local date/time to UTC, but preserve original local dates
|
||||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
|
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
|
||||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
|
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
|
||||||
|
|
||||||
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
|
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
|
||||||
let start_utc = start_dt.with_timezone(&chrono::Utc);
|
let start_utc = start_dt.with_timezone(&chrono::Utc);
|
||||||
let end_utc = end_dt.with_timezone(&chrono::Utc);
|
let end_utc = end_dt.with_timezone(&chrono::Utc);
|
||||||
|
|
||||||
|
// IMPORTANT: Use original local dates, not UTC dates!
|
||||||
|
// This ensures events display on the correct day regardless of timezone conversion
|
||||||
(
|
(
|
||||||
start_utc.format("%Y-%m-%d").to_string(),
|
self.start_date.format("%Y-%m-%d").to_string(),
|
||||||
start_utc.format("%H:%M").to_string(),
|
start_utc.format("%H:%M").to_string(),
|
||||||
end_utc.format("%Y-%m-%d").to_string(),
|
self.end_date.format("%Y-%m-%d").to_string(),
|
||||||
end_utc.format("%H:%M").to_string(),
|
end_utc.format("%H:%M").to_string(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
// Remember checkboxes state - default to checked
|
// Remember checkboxes state - default to checked
|
||||||
let remember_server = use_state(|| true);
|
let remember_server = use_state(|| true);
|
||||||
let remember_username = use_state(|| true);
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
|
// Password visibility toggle
|
||||||
|
let show_password = use_state(|| false);
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
let server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
let on_server_url_change = {
|
let on_server_url_change = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
server_url.set(target.value());
|
let new_value = target.value();
|
||||||
|
server_url.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_server {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_username_change = {
|
let on_username_change = {
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
username.set(target.value());
|
let new_value = target.value();
|
||||||
|
username.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_username {
|
||||||
|
let _ = LocalStorage::set("remembered_username", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +100,13 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_toggle_password_visibility = {
|
||||||
|
let show_password = show_password.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_password.set(!*show_password);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_submit = {
|
let on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = password.clone();
|
let password = password.clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
let on_login = props.on_login.clone();
|
let on_login = props.on_login.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server_value = *remember_server;
|
||||||
|
let remember_username_value = *remember_username;
|
||||||
let on_login = on_login.clone();
|
let on_login = on_login.clone();
|
||||||
|
|
||||||
// Basic client-side validation
|
// Basic client-side validation
|
||||||
@@ -140,6 +168,14 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save server URL and username to LocalStorage if remember checkboxes are checked
|
||||||
|
if remember_server_value {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
|
||||||
|
}
|
||||||
|
if remember_username_value {
|
||||||
|
let _ = LocalStorage::set("remembered_username", username.clone());
|
||||||
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -164,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
<form onsubmit={on_submit}>
|
<form onsubmit={on_submit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={server_url_ref}
|
|
||||||
type="text"
|
|
||||||
id="server_url"
|
|
||||||
placeholder="https://your-caldav-server.com/dav/"
|
|
||||||
value={(*server_url).clone()}
|
|
||||||
onchange={on_server_url_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={server_url_ref}
|
||||||
id="remember_server"
|
type="text"
|
||||||
checked={*remember_server}
|
id="server_url"
|
||||||
onchange={on_remember_server_change}
|
placeholder="https://your-caldav-server.com/dav/"
|
||||||
|
value={(*server_url).clone()}
|
||||||
|
onchange={on_server_url_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
<label for="remember_server">{"Remember server"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_server">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
tabindex="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">{"Username"}</label>
|
<label for="username">{"Username"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={username_ref}
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
value={(*username).clone()}
|
|
||||||
onchange={on_username_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={username_ref}
|
||||||
id="remember_username"
|
type="text"
|
||||||
checked={*remember_username}
|
id="username"
|
||||||
onchange={on_remember_username_change}
|
placeholder="Enter your username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="2"
|
||||||
/>
|
/>
|
||||||
<label for="remember_username">{"Remember username"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_username">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
tabindex="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">{"Password"}</label>
|
<label for="password">{"Password"}</label>
|
||||||
<input
|
<div class="password-input-container">
|
||||||
ref={password_ref}
|
<input
|
||||||
type="password"
|
ref={password_ref}
|
||||||
id="password"
|
type={if *show_password { "text" } else { "password" }}
|
||||||
placeholder="Enter your password"
|
id="password"
|
||||||
value={(*password).clone()}
|
placeholder="Enter your password"
|
||||||
onchange={on_password_change}
|
value={(*password).clone()}
|
||||||
disabled={*is_loading}
|
onchange={on_password_change}
|
||||||
/>
|
disabled={*is_loading}
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="password-toggle-btn"
|
||||||
|
onclick={on_toggle_password_visibility}
|
||||||
|
tabindex="6"
|
||||||
|
title={if *show_password { "Hide password" } else { "Show password" }}
|
||||||
|
>
|
||||||
|
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1229,15 +1229,12 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
|||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
let event_date = local_start.date_naive();
|
|
||||||
|
|
||||||
// Position events based on when they appear in local time, not their original date
|
|
||||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
|
||||||
// but should still display on Sunday's column since that's when the user sees it
|
|
||||||
let should_display_here = event_date == date ||
|
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
|
||||||
|
|
||||||
if !should_display_here {
|
// Events should display based on their stored date (which now preserves the original local date)
|
||||||
|
// not the calculated local date from UTC conversion, since we fixed the creation logic
|
||||||
|
let event_date = event.dtstart.date_naive(); // Use the stored date, not the converted local date
|
||||||
|
|
||||||
|
if event_date != date {
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ body {
|
|||||||
border-radius: var(--border-radius-medium);
|
border-radius: var(--border-radius-medium);
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form h2, .register-form h2 {
|
.login-form h2, .register-form h2 {
|
||||||
@@ -492,30 +492,83 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox {
|
.input-with-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 1rem;
|
||||||
margin-top: 0.375rem;
|
}
|
||||||
|
|
||||||
|
.input-with-checkbox input[type="text"],
|
||||||
|
.input-with-checkbox input[type="password"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox input[type="checkbox"] {
|
.remember-checkbox input[type="checkbox"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transform: scale(0.85);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox label {
|
.remember-checkbox label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.55rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-input-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-container input {
|
||||||
|
padding-right: 3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:hover {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:focus {
|
||||||
|
outline: none;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button, .register-button {
|
.login-button, .register-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--control-padding);
|
padding: var(--control-padding);
|
||||||
@@ -1826,7 +1879,7 @@ body {
|
|||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.4rem;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user