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:
@@ -61,6 +61,9 @@ pub struct CalendarEvent {
|
||||
|
||||
/// URL/href of this event on the CalDAV server
|
||||
pub href: Option<String>,
|
||||
|
||||
/// Calendar path this event belongs to
|
||||
pub calendar_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Event status enumeration
|
||||
@@ -182,11 +185,11 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||
self.parse_calendar_response(&body)
|
||||
self.parse_calendar_response(&body, calendar_path)
|
||||
}
|
||||
|
||||
/// Parse CalDAV XML response containing calendar data
|
||||
fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Extract calendar data from XML response
|
||||
@@ -198,6 +201,7 @@ impl CalDAVClient {
|
||||
for mut event in parsed_events {
|
||||
event.etag = calendar_data.etag.clone();
|
||||
event.href = calendar_data.href.clone();
|
||||
event.calendar_path = Some(calendar_path.to_string());
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +381,7 @@ impl CalDAVClient {
|
||||
reminders: self.parse_alarms(&event)?,
|
||||
etag: None, // Set by caller
|
||||
href: None, // Set by caller
|
||||
calendar_path: None, // Set by caller
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -145,8 +145,9 @@ pub async fn get_user_info(
|
||||
None
|
||||
} else {
|
||||
Some(CalendarInfo {
|
||||
path,
|
||||
path: path.clone(),
|
||||
display_name,
|
||||
color: generate_calendar_color(&path),
|
||||
})
|
||||
}
|
||||
}).collect();
|
||||
@@ -158,6 +159,39 @@ pub async fn get_user_info(
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper function to generate a consistent color for a calendar based on its path
|
||||
fn generate_calendar_color(path: &str) -> String {
|
||||
// Predefined set of attractive, accessible colors for calendars
|
||||
let colors = [
|
||||
"#3B82F6", // Blue
|
||||
"#10B981", // Emerald
|
||||
"#F59E0B", // Amber
|
||||
"#EF4444", // Red
|
||||
"#8B5CF6", // Violet
|
||||
"#06B6D4", // Cyan
|
||||
"#84CC16", // Lime
|
||||
"#F97316", // Orange
|
||||
"#EC4899", // Pink
|
||||
"#6366F1", // Indigo
|
||||
"#14B8A6", // Teal
|
||||
"#F3B806", // Yellow
|
||||
"#8B5A2B", // Brown
|
||||
"#6B7280", // Gray
|
||||
"#DC2626", // Red-600
|
||||
"#7C3AED", // Violet-600
|
||||
];
|
||||
|
||||
// Create a simple hash from the path to ensure consistent color assignment
|
||||
let mut hash: u32 = 0;
|
||||
for byte in path.bytes() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||
}
|
||||
|
||||
// Use the hash to select a color from our palette
|
||||
let color_index = (hash as usize) % colors.len();
|
||||
colors[color_index].to_string()
|
||||
}
|
||||
|
||||
// Helper function to extract a readable calendar name from path
|
||||
fn extract_calendar_name(path: &str) -> String {
|
||||
// Extract the last meaningful part of the path
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct UserInfo {
|
||||
pub struct CalendarInfo {
|
||||
pub path: String,
|
||||
pub display_name: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
// Error handling
|
||||
|
||||
111
src/app.rs
111
src/app.rs
@@ -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()} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
89
styles.css
89
styles.css
@@ -136,6 +136,7 @@ body {
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.calendar-list li:hover {
|
||||
@@ -143,10 +144,72 @@ body {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-list li:hover .calendar-color {
|
||||
border-color: rgba(255,255,255,0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
min-width: 120px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.2);
|
||||
border-color: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.color-option.selected {
|
||||
border-color: #333;
|
||||
border-width: 3px;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.no-calendars {
|
||||
@@ -459,7 +522,7 @@ body {
|
||||
}
|
||||
|
||||
.event-box {
|
||||
background: #2196f3;
|
||||
/* Background color will be set inline via style attribute */
|
||||
color: white;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
@@ -469,16 +532,34 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-box:hover {
|
||||
background: #1976d2;
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.event-box.refreshing {
|
||||
background: #ff9800;
|
||||
animation: pulse 1.5s ease-in-out infinite alternate;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.event-box.refreshing::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 152, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
Reference in New Issue
Block a user