Added dark theme to admin portal. Fixes #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:07:29 +02:00
co-authored by Claude Opus 5.5
parent 9abf87c34d
commit ed72dc20f9
12 changed files with 457 additions and 11 deletions
+2
View File
@@ -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/>
+11 -7
View File
@@ -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]
+13
View File
@@ -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>
+1
View File
@@ -5,4 +5,5 @@ pub mod header;
pub mod admin_portal;
pub mod user_menu;
pub mod data_form;
pub mod theme;
+168
View File
@@ -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));
}
}
+3
View File
@@ -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"),
+5 -3
View File
@@ -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">