Implement interactive calendar color picker

Backend enhancements:
- Add calendar_path field to CalendarEvent for color mapping
- Generate consistent colors for calendars using path-based hashing
- Update CalDAV parsing to associate events with their calendar paths
- Add 16-color palette with hash-based assignment algorithm

Frontend features:
- Interactive color picker with 4x4 grid of selectable colors
- Click color swatches to open dropdown with all available colors
- Instant color changes for both sidebar and calendar events
- Persistent color preferences using local storage
- Enhanced UX with hover effects and visual feedback

Styling improvements:
- Larger 16px color swatches for better clickability
- Professional color picker dropdown with smooth animations
- Dynamic event coloring based on calendar assignment
- Improved contrast with text shadows and borders
- Click-outside-to-close functionality for better UX

Users can now personalize their calendar organization with custom colors
that persist across sessions and immediately update throughout the app.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 20:14:56 -04:00
parent 5d519fd875
commit f94d057f81
7 changed files with 255 additions and 16 deletions

View File

@@ -23,6 +23,15 @@ pub fn App() -> Html {
});
let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None }); // Store calendar path of open picker
// Available colors for calendar customization
let available_colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
"#EC4899", "#6366F1", "#14B8A6", "#F3B806",
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED"
];
let on_login = {
let auth_token = auth_token.clone();
@@ -67,7 +76,20 @@ pub fn App() -> Html {
if !password.is_empty() {
match calendar_service.fetch_user_info(&token, &password).await {
Ok(info) => {
Ok(mut info) => {
// Load saved colors from local storage
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
// Update colors with saved preferences
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info));
}
Err(err) => {
@@ -84,9 +106,16 @@ pub fn App() -> Html {
});
}
let on_outside_click = {
let color_picker_open = color_picker_open.clone();
Callback::from(move |_: MouseEvent| {
color_picker_open.set(None);
})
};
html! {
<BrowserRouter>
<div class="app">
<div class="app" onclick={on_outside_click}>
{
if auth_token.is_some() {
html! {
@@ -119,8 +148,71 @@ pub fn App() -> Html {
<ul>
{
info.calendars.iter().map(|cal| {
let cal_clone = cal.clone();
let color_picker_open_clone = color_picker_open.clone();
let on_color_click = {
let cal_path = cal.path.clone();
let color_picker_open = color_picker_open.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
color_picker_open.set(Some(cal_path.clone()));
})
};
html! {
<li key={cal.path.clone()}>
<span class="calendar-color"
style={format!("background-color: {}", cal.color)}
onclick={on_color_click}>
{
if color_picker_open_clone.as_ref() == Some(&cal.path) {
html! {
<div class="color-picker">
{
available_colors.iter().map(|&color| {
let color_str = color.to_string();
let cal_path = cal.path.clone();
let user_info_clone = user_info.clone();
let color_picker_open = color_picker_open.clone();
let on_color_select = Callback::from(move |_: MouseEvent| {
// Update the calendar color locally
if let Some(mut info) = (*user_info_clone).clone() {
for calendar in &mut info.calendars {
if calendar.path == cal_path {
calendar.color = color_str.clone();
break;
}
}
user_info_clone.set(Some(info.clone()));
// Save to local storage
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
}
}
color_picker_open.set(None);
});
let is_selected = cal.color == color;
let class_name = if is_selected { "color-option selected" } else { "color-option" };
html! {
<div class={class_name}
style={format!("background-color: {}", color)}
onclick={on_color_select}>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="calendar-name">{&cal.display_name}</span>
</li>
}
@@ -162,7 +254,7 @@ pub fn App() -> Html {
}
Route::Calendar => {
if auth_token.is_some() {
html! { <CalendarView /> }
html! { <CalendarView user_info={(*user_info).clone()} /> }
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
@@ -196,7 +288,7 @@ pub fn App() -> Html {
}
Route::Calendar => {
if auth_token.is_some() {
html! { <CalendarView /> }
html! { <CalendarView user_info={(*user_info).clone()} /> }
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
@@ -212,8 +304,13 @@ pub fn App() -> Html {
}
}
#[derive(Properties, PartialEq)]
pub struct CalendarViewProps {
pub user_info: Option<UserInfo>,
}
#[function_component]
fn CalendarView() -> Html {
fn CalendarView(props: &CalendarViewProps) -> Html {
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
@@ -367,12 +464,12 @@ fn CalendarView() -> Html {
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} />
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
</div>
}
} else {
html! {
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} />
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
}
}
}

View File

@@ -1,7 +1,7 @@
use yew::prelude::*;
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
use std::collections::HashMap;
use crate::services::calendar_service::CalendarEvent;
use crate::services::calendar_service::{CalendarEvent, UserInfo};
use crate::components::EventModal;
#[derive(Properties, PartialEq)]
@@ -11,6 +11,8 @@ pub struct CalendarProps {
pub on_event_click: Callback<CalendarEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
#[prop_or_default]
pub user_info: Option<UserInfo>,
}
#[function_component]
@@ -20,6 +22,21 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let selected_day = use_state(|| today);
let selected_event = use_state(|| None::<CalendarEvent>);
// Helper function to get calendar color for an event
let get_event_color = |event: &CalendarEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
// Find the calendar that matches this event's path
if let Some(calendar) = user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path) {
return calendar.color.clone();
}
}
}
// Default color if no match found
"#3B82F6".to_string()
};
let first_day_of_month = current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(*current_month);
let first_weekday = first_day_of_month.weekday();
@@ -118,10 +135,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let title = event.get_title();
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
let event_color = get_event_color(&event);
html! {
<div class={class_name}
title={title.clone()}
onclick={event_click}>
onclick={event_click}
style={format!("background-color: {}", event_color)}>
{
if is_refreshing {
"🔄 Refreshing...".to_string()

View File

@@ -36,6 +36,7 @@ pub struct UserInfo {
pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -59,6 +60,7 @@ pub struct CalendarEvent {
pub reminders: Vec<EventReminder>,
pub etag: Option<String>,
pub href: Option<String>,
pub calendar_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]