Added dark theme to admin portal. Fixes #7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ use leptos_router::hooks::use_location;
|
||||
use leptos_router::path;
|
||||
use crate::components::admin_portal::AdminPortal;
|
||||
use crate::components::header::Header;
|
||||
use crate::components::theme::Theme;
|
||||
use crate::components::user_menu::MenuOpener;
|
||||
use crate::pages::all_reservations::Bookings;
|
||||
use crate::pages::appearance_settings::Appearance;
|
||||
@@ -87,6 +88,7 @@ pub fn App() -> impl IntoView {
|
||||
provide_context(MenuHelper::new()); // Only one menu can be opened
|
||||
provide_context(MenuOpener::new()); // Drawer opener
|
||||
provide_context(DialogHelper::new()); // Gray dialog background
|
||||
provide_context(Theme::new()); // Light/dark theme of admin portal
|
||||
|
||||
view! {
|
||||
<Header/>
|
||||
|
||||
@@ -449,8 +449,8 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
|
||||
use charts_rs::{BarChart, THEME_ANT};
|
||||
fn chart(data: &Vec<ChartData>, title: &str, month: bool, dark: bool) -> String {
|
||||
use charts_rs::{BarChart, THEME_ANT, THEME_DARK};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use std::ops::Add;
|
||||
@@ -463,7 +463,11 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
|
||||
} else {
|
||||
data.iter().map(|d| d.period.to_string()).collect()
|
||||
},
|
||||
THEME_ANT);
|
||||
if dark { THEME_DARK } else { THEME_ANT });
|
||||
if dark {
|
||||
// Card background of the dark theme, see assets/css/dark.css
|
||||
chart.background_color = (43, 44, 64).into();
|
||||
}
|
||||
chart.title_text = title.to_string();
|
||||
chart.legend_show = Some(false);
|
||||
chart.series_label_font_size = 8.0;
|
||||
@@ -480,21 +484,21 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn month_chart(year: i32) -> Result<ApiResponse<String>, ServerFnError> {
|
||||
pub async fn month_chart(year: i32, dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
|
||||
use crate::perm_check;
|
||||
perm_check!(is_logged_in);
|
||||
|
||||
let data = month_chart_data(year).await?;
|
||||
Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true)))
|
||||
Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true, dark)))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn year_chart() -> Result<ApiResponse<String>, ServerFnError> {
|
||||
pub async fn year_chart(dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
|
||||
use crate::perm_check;
|
||||
perm_check!(is_logged_in);
|
||||
|
||||
let data = year_chart_data().await?;
|
||||
Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false)))
|
||||
Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false, dark)))
|
||||
}
|
||||
|
||||
#[server]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use leptos::prelude::*;
|
||||
use crate::backend::data::User;
|
||||
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
|
||||
use crate::components::theme::Theme;
|
||||
use crate::components::user_menu::{MenuOpener, UserMenu};
|
||||
use crate::locales::trl;
|
||||
use crate::pages::change_pwd::ChangePassword;
|
||||
@@ -71,8 +72,11 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
|
||||
let pw_changer = DialogOpener::new();
|
||||
let drawer = use_context::<MenuOpener>().expect("No drawer opener");
|
||||
let about_dlg = DialogOpener::new();
|
||||
let theme = use_context::<Theme>().expect("No theme");
|
||||
|
||||
view! {
|
||||
// Dark theme is limited to the admin portal: the stylesheet is loaded only here
|
||||
<link rel="stylesheet" href="/css/dark.css" media=move || theme.mode().media()/>
|
||||
<div class="layout-wrapper layout-content-navbar">
|
||||
<div class="layout-container">
|
||||
//<!-- Menu -->
|
||||
@@ -141,6 +145,15 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
|
||||
|
||||
<ul class="navbar-nav flex-row align-items-center ms-auto">
|
||||
|
||||
//<!-- Theme -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="javascript:void(0)"
|
||||
title=move || trl(theme.mode().label())()
|
||||
on:click=move |_| theme.cycle()>
|
||||
<i class=move || theme.mode().icon()></i>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item navbar-dropdown dropdown-user dropdown">
|
||||
<a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()>
|
||||
<i class="bx bx-cog fs-3 lh-0"></i>
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod header;
|
||||
pub mod admin_portal;
|
||||
pub mod user_menu;
|
||||
pub mod data_form;
|
||||
pub mod theme;
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
use codee::string::FromToStringCodec;
|
||||
use leptos::prelude::*;
|
||||
use leptos_use::{use_cookie_with_options, use_preferred_dark, SameSite, UseCookieOptions};
|
||||
|
||||
const THEME_COOKIE: &str = "theme";
|
||||
const YEAR_MS: i64 = 365 * 24 * 3600 * 1000;
|
||||
|
||||
/// Colour theme of the admin portal chosen by the user
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ThemeMode {
|
||||
/// Follow the operating system setting
|
||||
#[default]
|
||||
Auto,
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
|
||||
impl ThemeMode {
|
||||
/// Next mode in the order of the switch: auto -> light -> dark -> auto
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
ThemeMode::Auto => ThemeMode::Light,
|
||||
ThemeMode::Light => ThemeMode::Dark,
|
||||
ThemeMode::Dark => ThemeMode::Auto
|
||||
}
|
||||
}
|
||||
|
||||
/// Media query for the dark stylesheet link
|
||||
pub fn media(&self) -> &'static str {
|
||||
match self {
|
||||
ThemeMode::Auto => "(prefers-color-scheme: dark)",
|
||||
ThemeMode::Light => "not all",
|
||||
ThemeMode::Dark => "all"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
ThemeMode::Auto => "bx bx-adjust fs-3 lh-0",
|
||||
ThemeMode::Light => "bx bx-sun fs-3 lh-0",
|
||||
ThemeMode::Dark => "bx bx-moon fs-3 lh-0"
|
||||
}
|
||||
}
|
||||
|
||||
/// Translation key of the mode description
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ThemeMode::Auto => "Theme: by system",
|
||||
ThemeMode::Light => "Theme: light",
|
||||
ThemeMode::Dark => "Theme: dark"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dark(&self, system_dark: bool) -> bool {
|
||||
match self {
|
||||
ThemeMode::Auto => system_dark,
|
||||
ThemeMode::Light => false,
|
||||
ThemeMode::Dark => true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ThemeMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", match self {
|
||||
ThemeMode::Auto => "auto",
|
||||
ThemeMode::Light => "light",
|
||||
ThemeMode::Dark => "dark"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThemeMode {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"auto" => Ok(ThemeMode::Auto),
|
||||
"light" => Ok(ThemeMode::Light),
|
||||
"dark" => Ok(ThemeMode::Dark),
|
||||
_ => Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme of the admin portal. The choice is kept in a cookie, so the server renders the same theme as the browser.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Theme {
|
||||
cookie: Signal<Option<String>>,
|
||||
set_cookie: WriteSignal<Option<String>>,
|
||||
system_dark: Signal<bool>
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn new() -> Self {
|
||||
let (cookie, set_cookie) = use_cookie_with_options::<String, FromToStringCodec>(
|
||||
THEME_COOKIE,
|
||||
UseCookieOptions::default()
|
||||
.max_age(YEAR_MS)
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax));
|
||||
|
||||
Self {
|
||||
cookie,
|
||||
set_cookie,
|
||||
system_dark: use_preferred_dark()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> ThemeMode {
|
||||
self.cookie.get().and_then(|m| m.parse().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Switch to the next mode
|
||||
pub fn cycle(&self) {
|
||||
let next = self.cookie.get_untracked().and_then(|m| m.parse::<ThemeMode>().ok()).unwrap_or_default().next();
|
||||
self.set_cookie.set(Some(next.to_string()));
|
||||
}
|
||||
|
||||
/// Is the dark theme displayed right now? The server can't know the system setting, there it is false in auto mode.
|
||||
pub fn is_dark(&self) -> bool {
|
||||
self.mode().is_dark(self.system_dark.get())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mode_cycles_through_all_states() {
|
||||
assert_eq!(ThemeMode::Auto.next(), ThemeMode::Light);
|
||||
assert_eq!(ThemeMode::Light.next(), ThemeMode::Dark);
|
||||
assert_eq!(ThemeMode::Dark.next(), ThemeMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_string_round_trip() {
|
||||
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
|
||||
assert_eq!(mode.to_string().parse::<ThemeMode>(), Ok(mode));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cookie_value_is_rejected() {
|
||||
assert!("".parse::<ThemeMode>().is_err());
|
||||
assert!("Dark".parse::<ThemeMode>().is_err());
|
||||
assert!("blue".parse::<ThemeMode>().is_err());
|
||||
assert_eq!(ThemeMode::default(), ThemeMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_query_for_dark_stylesheet() {
|
||||
assert_eq!(ThemeMode::Auto.media(), "(prefers-color-scheme: dark)");
|
||||
assert_eq!(ThemeMode::Light.media(), "not all");
|
||||
assert_eq!(ThemeMode::Dark.media(), "all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_follows_system_only_in_auto_mode() {
|
||||
assert!(ThemeMode::Auto.is_dark(true));
|
||||
assert!(!ThemeMode::Auto.is_dark(false));
|
||||
assert!(!ThemeMode::Light.is_dark(true));
|
||||
assert!(ThemeMode::Dark.is_dark(false));
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ lazy_static! {
|
||||
("Enter your username", "Zadejte své uživatelské jméno"),
|
||||
("Hours must be in HH:MM - HH:MM format", "Hodiny musí být zadány ve tvaru HH:MM - HH:MM"),
|
||||
("Time 'to' must be after time 'from'", "Čas 'do' musí být po čase 'od'"),
|
||||
("Theme: by system", "Vzhled: podle systému"),
|
||||
("Theme: light", "Vzhled: světlý"),
|
||||
("Theme: dark", "Vzhled: tmavý"),
|
||||
("Edit profile", "Upravit profil"),
|
||||
("Get emails", "Dostávat e-maily"),
|
||||
("Enter username", "Zadejte uživatelské jméno"),
|
||||
|
||||
@@ -3,6 +3,7 @@ use leptos::prelude::*;
|
||||
use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems};
|
||||
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
|
||||
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
|
||||
use crate::components::theme::Theme;
|
||||
use crate::components::user_menu::MenuOpener;
|
||||
use crate::locales::{loc_date, show_day, trl};
|
||||
use crate::pages::new_reservations::CancelDialog;
|
||||
@@ -71,11 +72,12 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
|
||||
|
||||
#[component]
|
||||
pub fn bookings() -> impl IntoView {
|
||||
let year_chart = Resource::new_blocking(||(), |_| year_chart());
|
||||
let theme = use_context::<Theme>().expect("No theme");
|
||||
let year_chart = Resource::new_blocking(move || theme.is_dark(), |dark| year_chart(dark));
|
||||
let years = Resource::new_blocking(||(), |_| years());
|
||||
let year = RwSignal::new(Local::now().year());
|
||||
let month = RwSignal::new(Local::now().month());
|
||||
let chart = Resource::new_blocking(move || year.get(),move |y| month_chart(y));
|
||||
let chart = Resource::new_blocking(move || (year.get(), theme.is_dark()), move |(y, dark)| month_chart(y, dark));
|
||||
let detail_dlg = DialogOpener::new();
|
||||
let reservations = Resource::new_blocking(move || (year.get(), month.get(), detail_dlg.visible()), move |p| reservations_in_month(p.0, p.1));
|
||||
let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12];
|
||||
@@ -84,7 +86,7 @@ pub fn bookings() -> impl IntoView {
|
||||
view! {
|
||||
<h1>{trl("Booking overview")}</h1>
|
||||
<BookingDetail reservation=res_detail.read_only() opener=detail_dlg />
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5">
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light filter-bar mb-5">
|
||||
<div class="container-fluid">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
|
||||
Reference in New Issue
Block a user