- Add comprehensive CSS custom properties for all theme colors - Include modal, button, input, text, and background color variables - Enhance dark theme with complete variable overrides for proper contrast - Replace hardcoded colors in print-preview.css with theme variables - Add FontAwesome CDN integration and replace emoji icons - Create minimalistic glass-effect checkbox styling with transparency - Fix white-on-white text issue in dark theme across all modals 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
440 lines
23 KiB
Rust
440 lines
23 KiB
Rust
use crate::components::CalendarListItem;
|
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
|
use web_sys::HtmlSelectElement;
|
|
use yew::prelude::*;
|
|
use yew_router::prelude::*;
|
|
|
|
#[derive(Clone, Routable, PartialEq)]
|
|
pub enum Route {
|
|
#[at("/")]
|
|
Home,
|
|
#[at("/login")]
|
|
Login,
|
|
#[at("/calendar")]
|
|
Calendar,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub enum ViewMode {
|
|
Month,
|
|
Week,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub enum Theme {
|
|
Default,
|
|
Ocean,
|
|
Forest,
|
|
Sunset,
|
|
Purple,
|
|
Dark,
|
|
Rose,
|
|
Mint,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub enum Style {
|
|
Default,
|
|
Google,
|
|
}
|
|
|
|
impl Theme {
|
|
pub fn value(&self) -> &'static str {
|
|
match self {
|
|
Theme::Default => "default",
|
|
Theme::Ocean => "ocean",
|
|
Theme::Forest => "forest",
|
|
Theme::Sunset => "sunset",
|
|
Theme::Purple => "purple",
|
|
Theme::Dark => "dark",
|
|
Theme::Rose => "rose",
|
|
Theme::Mint => "mint",
|
|
}
|
|
}
|
|
|
|
pub fn from_value(value: &str) -> Self {
|
|
match value {
|
|
"ocean" => Theme::Ocean,
|
|
"forest" => Theme::Forest,
|
|
"sunset" => Theme::Sunset,
|
|
"purple" => Theme::Purple,
|
|
"dark" => Theme::Dark,
|
|
"rose" => Theme::Rose,
|
|
"mint" => Theme::Mint,
|
|
_ => Theme::Default,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Style {
|
|
pub fn value(&self) -> &'static str {
|
|
match self {
|
|
Style::Default => "default",
|
|
Style::Google => "google",
|
|
}
|
|
}
|
|
|
|
pub fn from_value(value: &str) -> Self {
|
|
match value {
|
|
"google" => Style::Google,
|
|
_ => Style::Default,
|
|
}
|
|
}
|
|
|
|
|
|
pub fn stylesheet_path(&self) -> Option<&'static str> {
|
|
match self {
|
|
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
|
Style::Google => Some("google.css"), // Trunk copies to root level
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ViewMode {
|
|
fn default() -> Self {
|
|
ViewMode::Month
|
|
}
|
|
}
|
|
|
|
#[derive(Properties, PartialEq)]
|
|
pub struct SidebarProps {
|
|
pub user_info: Option<UserInfo>,
|
|
pub on_logout: Callback<()>,
|
|
pub on_add_calendar: Callback<()>,
|
|
pub external_calendars: Vec<ExternalCalendar>,
|
|
pub on_external_calendar_toggle: Callback<i32>,
|
|
pub on_external_calendar_delete: Callback<i32>,
|
|
pub on_external_calendar_refresh: Callback<i32>,
|
|
pub color_picker_open: Option<String>,
|
|
pub on_color_change: Callback<(String, String)>,
|
|
pub on_color_picker_toggle: Callback<String>,
|
|
pub available_colors: Vec<String>,
|
|
pub refreshing_calendar_id: Option<i32>,
|
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
|
pub on_calendar_visibility_toggle: Callback<String>,
|
|
pub current_view: ViewMode,
|
|
pub on_view_change: Callback<ViewMode>,
|
|
pub current_theme: Theme,
|
|
pub on_theme_change: Callback<Theme>,
|
|
pub current_style: Style,
|
|
pub on_style_change: Callback<Style>,
|
|
}
|
|
|
|
#[function_component(Sidebar)]
|
|
pub fn sidebar(props: &SidebarProps) -> Html {
|
|
let external_context_menu_open = use_state(|| None::<i32>);
|
|
let on_view_change = {
|
|
let on_view_change = props.on_view_change.clone();
|
|
Callback::from(move |e: Event| {
|
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
|
if let Some(select) = target {
|
|
let value = select.value();
|
|
let new_view = match value.as_str() {
|
|
"week" => ViewMode::Week,
|
|
_ => ViewMode::Month,
|
|
};
|
|
on_view_change.emit(new_view);
|
|
}
|
|
})
|
|
};
|
|
|
|
let on_theme_change = {
|
|
let on_theme_change = props.on_theme_change.clone();
|
|
Callback::from(move |e: Event| {
|
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
|
if let Some(select) = target {
|
|
let value = select.value();
|
|
let new_theme = Theme::from_value(&value);
|
|
on_theme_change.emit(new_theme);
|
|
}
|
|
})
|
|
};
|
|
|
|
let on_style_change = {
|
|
let on_style_change = props.on_style_change.clone();
|
|
Callback::from(move |e: Event| {
|
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
|
if let Some(select) = target {
|
|
let value = select.value();
|
|
let new_style = Style::from_value(&value);
|
|
on_style_change.emit(new_style);
|
|
}
|
|
})
|
|
};
|
|
|
|
let on_external_calendar_context_menu = {
|
|
let external_context_menu_open = external_context_menu_open.clone();
|
|
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
|
|
e.prevent_default();
|
|
external_context_menu_open.set(Some(cal_id));
|
|
})
|
|
};
|
|
|
|
let on_external_calendar_delete = {
|
|
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
|
|
let external_context_menu_open = external_context_menu_open.clone();
|
|
Callback::from(move |cal_id: i32| {
|
|
on_external_calendar_delete.emit(cal_id);
|
|
external_context_menu_open.set(None);
|
|
})
|
|
};
|
|
|
|
let close_external_context_menu = {
|
|
let external_context_menu_open = external_context_menu_open.clone();
|
|
Callback::from(move |_| {
|
|
external_context_menu_open.set(None);
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<aside class="app-sidebar">
|
|
<div class="sidebar-header">
|
|
<h1>{"Runway"}</h1>
|
|
{
|
|
if let Some(ref info) = props.user_info {
|
|
html! {
|
|
<div class="user-info">
|
|
<div class="username">{&info.username}</div>
|
|
<div class="server-url">{&info.server_url}</div>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! { <div class="user-info loading">{"Loading..."}</div> }
|
|
}
|
|
}
|
|
</div>
|
|
{
|
|
if let Some(ref info) = props.user_info {
|
|
if !info.calendars.is_empty() {
|
|
html! {
|
|
<div class="calendar-list">
|
|
<h3>{"My Calendars"}</h3>
|
|
<ul>
|
|
{
|
|
info.calendars.iter().map(|cal| {
|
|
html! {
|
|
<CalendarListItem
|
|
calendar={cal.clone()}
|
|
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
|
|
on_color_change={props.on_color_change.clone()}
|
|
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
|
available_colors={props.available_colors.clone()}
|
|
on_context_menu={props.on_calendar_context_menu.clone()}
|
|
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
|
|
/>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</ul>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! { <div class="no-calendars">{"No calendars found"}</div> }
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
|
|
// External calendars section
|
|
<div class="external-calendar-list">
|
|
<h3>{"External Calendars"}</h3>
|
|
{
|
|
if !props.external_calendars.is_empty() {
|
|
html! {
|
|
<ul class="external-calendar-items">
|
|
{
|
|
props.external_calendars.iter().map(|cal| {
|
|
let on_toggle = {
|
|
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
|
|
let cal_id = cal.id;
|
|
Callback::from(move |_| {
|
|
on_external_calendar_toggle.emit(cal_id);
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<li class="external-calendar-item" style="position: relative;">
|
|
<div
|
|
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
|
"external-calendar-info color-picker-active"
|
|
} else {
|
|
"external-calendar-info"
|
|
}}
|
|
oncontextmenu={{
|
|
let on_context_menu = on_external_calendar_context_menu.clone();
|
|
let cal_id = cal.id;
|
|
Callback::from(move |e: MouseEvent| {
|
|
on_context_menu.emit((e, cal_id));
|
|
})
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={cal.is_visible}
|
|
onchange={on_toggle}
|
|
/>
|
|
<span
|
|
class="external-calendar-color"
|
|
style={format!("background-color: {}", cal.color)}
|
|
onclick={{
|
|
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
|
let external_id = format!("external_{}", cal.id);
|
|
Callback::from(move |e: MouseEvent| {
|
|
e.stop_propagation();
|
|
on_color_picker_toggle.emit(external_id.clone());
|
|
})
|
|
}}
|
|
>
|
|
{
|
|
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
|
html! {
|
|
<div class="color-picker-dropdown">
|
|
{
|
|
props.available_colors.iter().map(|color| {
|
|
let color_str = color.clone();
|
|
let external_id = format!("external_{}", cal.id);
|
|
let on_color_change = props.on_color_change.clone();
|
|
|
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
|
on_color_change.emit((external_id.clone(), color_str.clone()));
|
|
});
|
|
|
|
let is_selected = cal.color == *color;
|
|
|
|
html! {
|
|
<div
|
|
key={color.clone()}
|
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
|
style={format!("background-color: {}", color)}
|
|
onclick={on_color_select}
|
|
/>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
</span>
|
|
<span class="external-calendar-name">{&cal.name}</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);
|
|
})
|
|
}}
|
|
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
|
>
|
|
{
|
|
if props.refreshing_calendar_id == Some(cal.id) {
|
|
html! { <i class="fas fa-spinner fa-spin"></i> }
|
|
} else {
|
|
html! { <i class="fas fa-sync-alt"></i> }
|
|
}
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{
|
|
if *external_context_menu_open == Some(cal.id) {
|
|
html! {
|
|
<>
|
|
<div
|
|
class="context-menu-overlay"
|
|
onclick={close_external_context_menu.clone()}
|
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
|
|
/>
|
|
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
|
|
<div
|
|
class="context-menu-item"
|
|
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
|
|
onclick={{
|
|
let on_delete = on_external_calendar_delete.clone();
|
|
let cal_id = cal.id;
|
|
Callback::from(move |_| {
|
|
on_delete.emit(cal_id);
|
|
})
|
|
}}
|
|
>
|
|
{"Delete Calendar"}
|
|
</div>
|
|
</div>
|
|
</>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
</li>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</ul>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<div class="sidebar-footer">
|
|
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
|
|
{"+ Add Calendar"}
|
|
</button>
|
|
|
|
<div class="view-selector">
|
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
|
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
|
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="theme-selector">
|
|
<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>
|
|
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"Forest"}</option>
|
|
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"Sunset"}</option>
|
|
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"Purple"}</option>
|
|
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
|
|
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
|
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="style-selector">
|
|
<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>
|
|
</select>
|
|
</div>
|
|
|
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
|
</div>
|
|
</aside>
|
|
}
|
|
}
|