Added support for external calendars #14
| @@ -0,0 +1,14 @@ | |||||||
|  | -- Create external calendar cache table for storing ICS data | ||||||
|  | CREATE TABLE external_calendar_cache ( | ||||||
|  |     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  |     external_calendar_id INTEGER NOT NULL, | ||||||
|  |     ics_data TEXT NOT NULL, | ||||||
|  |     cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     etag TEXT, | ||||||
|  |     FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE, | ||||||
|  |     UNIQUE(external_calendar_id) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Index for faster lookups | ||||||
|  | CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id); | ||||||
|  | CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at); | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| use chrono::{DateTime, Utc}; | use chrono::{DateTime, Duration, Utc}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | ||||||
| use sqlx::{FromRow, Result}; | use sqlx::{FromRow, Result}; | ||||||
| @@ -427,4 +427,62 @@ impl<'a> ExternalCalendarRepository<'a> { | |||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Get cached ICS data for an external calendar | ||||||
|  |     pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> { | ||||||
|  |         let result = sqlx::query_as::<_, (String, DateTime<Utc>)>( | ||||||
|  |             "SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(external_calendar_id) | ||||||
|  |         .fetch_optional(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(result) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Update cache with new ICS data | ||||||
|  |     pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> { | ||||||
|  |         sqlx::query( | ||||||
|  |             "INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at) | ||||||
|  |              VALUES (?, ?, ?, ?) | ||||||
|  |              ON CONFLICT(external_calendar_id) DO UPDATE SET | ||||||
|  |              ics_data = excluded.ics_data, | ||||||
|  |              etag = excluded.etag, | ||||||
|  |              cached_at = excluded.cached_at", | ||||||
|  |         ) | ||||||
|  |         .bind(external_calendar_id) | ||||||
|  |         .bind(ics_data) | ||||||
|  |         .bind(etag) | ||||||
|  |         .bind(Utc::now()) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Check if cache is stale (older than max_age_minutes) | ||||||
|  |     pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> { | ||||||
|  |         let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes); | ||||||
|  |          | ||||||
|  |         let result = sqlx::query_scalar::<_, i64>( | ||||||
|  |             "SELECT COUNT(*) FROM external_calendar_cache  | ||||||
|  |              WHERE external_calendar_id = ? AND cached_at > ?", | ||||||
|  |         ) | ||||||
|  |         .bind(external_calendar_id) | ||||||
|  |         .bind(cutoff_time) | ||||||
|  |         .fetch_one(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(result == 0) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Clear cache for an external calendar | ||||||
|  |     pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> { | ||||||
|  |         sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?") | ||||||
|  |             .bind(external_calendar_id) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -53,7 +53,31 @@ pub async fn fetch_external_calendar_events( | |||||||
|         })); |         })); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Fetch ICS content from URL |     // Check cache first | ||||||
|  |     let cache_max_age_minutes = 5; | ||||||
|  |     let mut ics_content = String::new(); | ||||||
|  |     let mut last_fetched = Utc::now(); | ||||||
|  |     let mut fetched_from_cache = false; | ||||||
|  |  | ||||||
|  |     // Try to get from cache if not stale | ||||||
|  |     match repo.is_cache_stale(id, cache_max_age_minutes).await { | ||||||
|  |         Ok(is_stale) => { | ||||||
|  |             if !is_stale { | ||||||
|  |                 // Cache is fresh, use it | ||||||
|  |                 if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await { | ||||||
|  |                     ics_content = cached_data; | ||||||
|  |                     last_fetched = cached_at; | ||||||
|  |                     fetched_from_cache = true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         Err(_) => { | ||||||
|  |             // If cache check fails, proceed to fetch from URL | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // If not fetched from cache, get from external URL | ||||||
|  |     if !fetched_from_cache { | ||||||
|         let client = Client::new(); |         let client = Client::new(); | ||||||
|         let response = client |         let response = client | ||||||
|             .get(&calendar.url) |             .get(&calendar.url) | ||||||
| @@ -65,23 +89,33 @@ pub async fn fetch_external_calendar_events( | |||||||
|             return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); |             return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     let ics_content = response |         ics_content = response | ||||||
|             .text() |             .text() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; |             .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; | ||||||
|  |  | ||||||
|  |         // Store in cache for future requests | ||||||
|  |         let etag = None; // TODO: Extract ETag from response headers if available | ||||||
|  |         if let Err(e) = repo.update_cache(id, &ics_content, etag).await { | ||||||
|  |             // Log error but don't fail the request | ||||||
|  |             eprintln!("Failed to update cache for calendar {}: {}", id, e); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Update last_fetched timestamp | ||||||
|  |         if let Err(e) = repo.update_last_fetched(id, &user.id).await { | ||||||
|  |             eprintln!("Failed to update last_fetched for calendar {}: {}", id, e); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         last_fetched = Utc::now(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     // Parse ICS content |     // Parse ICS content | ||||||
|     let events = parse_ics_content(&ics_content) |     let events = parse_ics_content(&ics_content) | ||||||
|         .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; |         .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; | ||||||
|  |  | ||||||
|     // Update last_fetched timestamp |  | ||||||
|     repo.update_last_fetched(id, &user.id) |  | ||||||
|         .await |  | ||||||
|         .map_err(|e| ApiError::Database(format!("Failed to update last_fetched: {}", e)))?; |  | ||||||
|  |  | ||||||
|     Ok(Json(ExternalCalendarEventsResponse { |     Ok(Json(ExternalCalendarEventsResponse { | ||||||
|         events, |         events, | ||||||
|         last_fetched: Utc::now(), |         last_fetched, | ||||||
|     })) |     })) | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ use crate::models::ical::VEvent; | |||||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||||
| use chrono::NaiveDate; | use chrono::NaiveDate; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
|  | use gloo_timers::callback::Interval; | ||||||
| use wasm_bindgen::JsCast; | use wasm_bindgen::JsCast; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| @@ -79,6 +80,7 @@ pub fn App() -> Html { | |||||||
|     let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() }); |     let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() }); | ||||||
|     let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() }); |     let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() }); | ||||||
|     let external_calendar_modal_open = use_state(|| false); |     let external_calendar_modal_open = use_state(|| false); | ||||||
|  |     let refresh_interval = use_state(|| -> Option<Interval> { None }); | ||||||
|  |  | ||||||
|     // Calendar view state - load from localStorage if available |     // Calendar view state - load from localStorage if available | ||||||
|     let current_view = use_state(|| { |     let current_view = use_state(|| { | ||||||
| @@ -307,14 +309,11 @@ pub fn App() -> Html { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Load external calendars when auth token is available |     // Function to refresh external calendars | ||||||
|     { |     let refresh_external_calendars = { | ||||||
|         let auth_token = auth_token.clone(); |  | ||||||
|         let external_calendars = external_calendars.clone(); |         let external_calendars = external_calendars.clone(); | ||||||
|         let external_calendar_events = external_calendar_events.clone(); |         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |         Callback::from(move |_| { | ||||||
|         use_effect_with((*auth_token).clone(), move |token| { |  | ||||||
|             if token.is_some() { |  | ||||||
|             let external_calendars = external_calendars.clone(); |             let external_calendars = external_calendars.clone(); | ||||||
|             let external_calendar_events = external_calendar_events.clone(); |             let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |  | ||||||
| @@ -346,12 +345,41 @@ pub fn App() -> Html { | |||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             }); |             }); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Load external calendars when auth token is available and set up auto-refresh | ||||||
|  |     { | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |         let refresh_external_calendars = refresh_external_calendars.clone(); | ||||||
|  |         let refresh_interval = refresh_interval.clone(); | ||||||
|  |         let external_calendars = external_calendars.clone(); | ||||||
|  |         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |  | ||||||
|  |         use_effect_with((*auth_token).clone(), move |token| { | ||||||
|  |             if let Some(_) = token { | ||||||
|  |                 // Initial load | ||||||
|  |                 refresh_external_calendars.emit(()); | ||||||
|  |                  | ||||||
|  |                 // Set up 5-minute refresh interval | ||||||
|  |                 let refresh_external_calendars = refresh_external_calendars.clone(); | ||||||
|  |                 let interval = Interval::new(5 * 60 * 1000, move || { | ||||||
|  |                     refresh_external_calendars.emit(()); | ||||||
|  |                 }); | ||||||
|  |                 refresh_interval.set(Some(interval)); | ||||||
|             } else { |             } else { | ||||||
|  |                 // Clear data and interval when logged out | ||||||
|                 external_calendars.set(Vec::new()); |                 external_calendars.set(Vec::new()); | ||||||
|                 external_calendar_events.set(Vec::new()); |                 external_calendar_events.set(Vec::new()); | ||||||
|  |                 refresh_interval.set(None); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             || () |             // Cleanup function | ||||||
|  |             let refresh_interval = refresh_interval.clone(); | ||||||
|  |             move || { | ||||||
|  |                 // Clear interval on cleanup | ||||||
|  |                 refresh_interval.set(None); | ||||||
|  |             } | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -1062,6 +1090,42 @@ pub fn App() -> Html { | |||||||
|                                             }); |                                             }); | ||||||
|                                         } |                                         } | ||||||
|                                     })} |                                     })} | ||||||
|  |                                     on_external_calendar_refresh={Callback::from({ | ||||||
|  |                                         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                                         let external_calendars = external_calendars.clone(); | ||||||
|  |                                         move |id: i32| { | ||||||
|  |                                             let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                                             let external_calendars = external_calendars.clone(); | ||||||
|  |                                             wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                                                 // Force refresh of this specific calendar | ||||||
|  |                                                 if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await { | ||||||
|  |                                                     // Set calendar_path for color matching | ||||||
|  |                                                     for event in &mut events { | ||||||
|  |                                                         event.calendar_path = Some(format!("external_{}", id)); | ||||||
|  |                                                     } | ||||||
|  |                                                      | ||||||
|  |                                                     // Update events for this calendar | ||||||
|  |                                                     let mut all_events = (*external_calendar_events).clone(); | ||||||
|  |                                                     // Remove old events from this calendar | ||||||
|  |                                                     all_events.retain(|e| { | ||||||
|  |                                                         if let Some(ref calendar_path) = e.calendar_path { | ||||||
|  |                                                             calendar_path != &format!("external_{}", id) | ||||||
|  |                                                         } else { | ||||||
|  |                                                             true | ||||||
|  |                                                         } | ||||||
|  |                                                     }); | ||||||
|  |                                                     // Add new events | ||||||
|  |                                                     all_events.extend(events); | ||||||
|  |                                                     external_calendar_events.set(all_events); | ||||||
|  |                                                      | ||||||
|  |                                                     // Update the last_fetched timestamp in calendars list | ||||||
|  |                                                     if let Ok(calendars) = CalendarService::get_external_calendars().await { | ||||||
|  |                                                         external_calendars.set(calendars); | ||||||
|  |                                                     } | ||||||
|  |                                                 } | ||||||
|  |                                             }); | ||||||
|  |                                         } | ||||||
|  |                                     })} | ||||||
|                                     color_picker_open={(*color_picker_open).clone()} |                                     color_picker_open={(*color_picker_open).clone()} | ||||||
|                                     on_color_change={on_color_change} |                                     on_color_change={on_color_change} | ||||||
|                                     on_color_picker_toggle={on_color_picker_toggle} |                                     on_color_picker_toggle={on_color_picker_toggle} | ||||||
| @@ -1355,42 +1419,7 @@ pub fn App() -> Html { | |||||||
|                         let external_calendar_modal_open = external_calendar_modal_open.clone(); |                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||||
|                         move |_| external_calendar_modal_open.set(false) |                         move |_| external_calendar_modal_open.set(false) | ||||||
|                     })} |                     })} | ||||||
|                     on_success={Callback::from({ |                     on_success={refresh_external_calendars.clone()} | ||||||
|                         let external_calendars = external_calendars.clone(); |  | ||||||
|                         let external_calendar_events = external_calendar_events.clone(); |  | ||||||
|                         move |_| { |  | ||||||
|                             // Reload external calendars |  | ||||||
|                             let external_calendars = external_calendars.clone(); |  | ||||||
|                             let external_calendar_events = external_calendar_events.clone(); |  | ||||||
|                             wasm_bindgen_futures::spawn_local(async move { |  | ||||||
|                                 match CalendarService::get_external_calendars().await { |  | ||||||
|                                     Ok(calendars) => { |  | ||||||
|                                         external_calendars.set(calendars.clone()); |  | ||||||
|                                          |  | ||||||
|                                         // Load events for visible external calendars |  | ||||||
|                                         let mut all_events = Vec::new(); |  | ||||||
|                                         for calendar in calendars { |  | ||||||
|                                             if calendar.is_visible { |  | ||||||
|                                                 if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { |  | ||||||
|                                                     // Set calendar_path for color matching |  | ||||||
|                                                     for event in &mut events { |  | ||||||
|                                                         event.calendar_path = Some(format!("external_{}", calendar.id)); |  | ||||||
|                                                     } |  | ||||||
|                                                     all_events.extend(events); |  | ||||||
|                                                 } |  | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                         external_calendar_events.set(all_events); |  | ||||||
|                                     } |  | ||||||
|                                     Err(err) => { |  | ||||||
|                                         web_sys::console::log_1( |  | ||||||
|                                             &format!("Failed to reload external calendars: {}", err).into(), |  | ||||||
|                                         ); |  | ||||||
|                                     } |  | ||||||
|                                 } |  | ||||||
|                             }); |  | ||||||
|                         } |  | ||||||
|                     })} |  | ||||||
|                 /> |                 /> | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| use crate::components::CalendarListItem; | use crate::components::CalendarListItem; | ||||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
|  | use chrono::{DateTime, Local, TimeZone, Utc}; | ||||||
| use web_sys::HtmlSelectElement; | use web_sys::HtmlSelectElement; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| @@ -105,6 +106,7 @@ pub struct SidebarProps { | |||||||
|     pub external_calendars: Vec<ExternalCalendar>, |     pub external_calendars: Vec<ExternalCalendar>, | ||||||
|     pub on_external_calendar_toggle: Callback<i32>, |     pub on_external_calendar_toggle: Callback<i32>, | ||||||
|     pub on_external_calendar_delete: Callback<i32>, |     pub on_external_calendar_delete: Callback<i32>, | ||||||
|  |     pub on_external_calendar_refresh: Callback<i32>, | ||||||
|     pub color_picker_open: Option<String>, |     pub color_picker_open: Option<String>, | ||||||
|     pub on_color_change: Callback<(String, String)>, |     pub on_color_change: Callback<(String, String)>, | ||||||
|     pub on_color_picker_toggle: Callback<String>, |     pub on_color_picker_toggle: Callback<String>, | ||||||
| @@ -277,7 +279,36 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                                                         style={format!("background-color: {}", cal.color)} |                                                         style={format!("background-color: {}", cal.color)} | ||||||
|                                                     /> |                                                     /> | ||||||
|                                                     <span class="external-calendar-name">{&cal.name}</span> |                                                     <span class="external-calendar-name">{&cal.name}</span> | ||||||
|                                                     <span class="external-calendar-indicator"></span> |                                                     <div class="external-calendar-actions"> | ||||||
|  |                                                         { | ||||||
|  |                                                             if let Some(last_fetched) = cal.last_fetched { | ||||||
|  |                                                                 let local_time = last_fetched.with_timezone(&chrono::Local); | ||||||
|  |                                                                 html! { | ||||||
|  |                                                                     <span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}> | ||||||
|  |                                                                         {format!("{}", local_time.format("%H:%M"))} | ||||||
|  |                                                                     </span> | ||||||
|  |                                                                 } | ||||||
|  |                                                             } else { | ||||||
|  |                                                                 html! { | ||||||
|  |                                                                     <span class="last-updated">{"Never"}</span> | ||||||
|  |                                                                 } | ||||||
|  |                                                             } | ||||||
|  |                                                         } | ||||||
|  |                                                         <button  | ||||||
|  |                                                             class="external-calendar-refresh-btn"  | ||||||
|  |                                                             title="Refresh calendar" | ||||||
|  |                                                             onclick={{ | ||||||
|  |                                                                 let on_refresh = props.on_external_calendar_refresh.clone(); | ||||||
|  |                                                                 let cal_id = cal.id; | ||||||
|  |                                                                 Callback::from(move |e: MouseEvent| { | ||||||
|  |                                                                     e.stop_propagation(); | ||||||
|  |                                                                     on_refresh.emit(cal_id); | ||||||
|  |                                                                 }) | ||||||
|  |                                                             }} | ||||||
|  |                                                         > | ||||||
|  |                                                             {"🔄"} | ||||||
|  |                                                         </button> | ||||||
|  |                                                     </div> | ||||||
|                                                 </div> |                                                 </div> | ||||||
|                                                 { |                                                 { | ||||||
|                                                     if *external_context_menu_open == Some(cal.id) { |                                                     if *external_context_menu_open == Some(cal.id) { | ||||||
|   | |||||||
| @@ -2089,6 +2089,7 @@ impl CalendarService { | |||||||
|         #[derive(Deserialize)] |         #[derive(Deserialize)] | ||||||
|         struct ExternalCalendarEventsResponse { |         struct ExternalCalendarEventsResponse { | ||||||
|             events: Vec<VEvent>, |             events: Vec<VEvent>, | ||||||
|  |             last_fetched: chrono::DateTime<chrono::Utc>, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) |         let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) | ||||||
|   | |||||||
| @@ -3816,6 +3816,37 @@ body { | |||||||
|     white-space: nowrap; |     white-space: nowrap; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .external-calendar-actions { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 0.5rem; | ||||||
|  |     flex-shrink: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .last-updated { | ||||||
|  |     font-size: 0.7rem; | ||||||
|  |     color: rgba(255, 255, 255, 0.6); | ||||||
|  |     opacity: 0.8; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .external-calendar-refresh-btn { | ||||||
|  |     background: none; | ||||||
|  |     border: none; | ||||||
|  |     color: rgba(255, 255, 255, 0.7); | ||||||
|  |     cursor: pointer; | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     padding: 2px 4px; | ||||||
|  |     border-radius: 3px; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     line-height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .external-calendar-refresh-btn:hover { | ||||||
|  |     color: rgba(255, 255, 255, 0.9); | ||||||
|  |     background: rgba(255, 255, 255, 0.1); | ||||||
|  |     transform: rotate(180deg); | ||||||
|  | } | ||||||
|  |  | ||||||
| .external-calendar-indicator { | .external-calendar-indicator { | ||||||
|     font-size: 0.8rem; |     font-size: 0.8rem; | ||||||
|     opacity: 0.7; |     opacity: 0.7; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user