Added dark theme to admin portal. Fixes #7

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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
@@ -46,5 +46,7 @@ paths:
- UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (`<i class="bx bx-...">`). Reuse its classes (`card`, `table`, `btn btn-primary`, `form-control`, `row`/`col`) before writing CSS.
- Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`).
- The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views.
- Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`.
- The public page banner style is `assets/data/banner.css`, rewritten at runtime by the appearance upload (`/*bg-img*/` marker) — keep that marker / the `background-image: url('...')` form intact.
- Stylesheets and meta tags are declared in `src/components/header.rs`.
+1 -1
View File
@@ -38,7 +38,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/upload`.
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
- `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`).
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`).
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal).
- `src/locales/` — `trl()` and the translation catalogue.
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it. App styles are plain CSS files in `assets/css/`, linked in `src/components/header.rs`.
Generated
+1
View File
@@ -3341,6 +3341,7 @@ dependencies = [
"cfg-if",
"charts-rs",
"chrono",
"codee",
"console_error_panic_hook",
"env_logger",
"futures-util",
+1
View File
@@ -36,6 +36,7 @@ log = "0.4.21"
env_logger = "0.11.3"
getopts = "0.2.21"
leptos-use = "0.19.2"
codee = "0.3"
lettre = {version = "0.11.6", features = ["tokio1-native-tls", "smtp-transport", "file-transport"], optional = true}
leptos-captcha = "0.4.0"
charts-rs = { version = "0.3.5", optional = true}
+249
View File
@@ -0,0 +1,249 @@
/*
* Dark theme of the admin portal.
*
* The file is linked only from the admin portal (src/components/admin_portal.rs) and switched
* on and off with the media attribute of the link, so the rules are not scoped by any class.
* It overrides colours of the vendor template for the components the admin portal uses.
******************************************************************************/
:root {
color-scheme: dark;
--dark-bg: #232333;
--dark-card: #2b2c40;
--dark-text: #a3a4cc;
--dark-heading: #cbcbe2;
--dark-muted: #7071a4;
--dark-border: #444564;
--dark-hover: rgba(255, 255, 255, 0.06);
--bs-body-bg: var(--dark-bg);
--bs-body-bg-rgb: 35, 35, 51;
--bs-body-color: var(--dark-text);
--bs-body-color-rgb: 163, 164, 204;
}
/* Typography */
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 {
color: var(--dark-heading);
}
hr {
color: var(--dark-border);
}
.text-muted {
color: var(--dark-muted) !important;
}
/* Layout: side menu, navbar, footer */
.bg-menu-theme,
.bg-navbar-theme {
background-color: var(--dark-card) !important;
color: var(--dark-text);
}
.bg-footer-theme {
background-color: var(--dark-bg) !important;
color: var(--dark-text);
}
.bg-menu-theme .menu-link,
.bg-menu-theme .menu-horizontal-prev,
.bg-menu-theme .menu-horizontal-next,
.bg-navbar-theme .navbar-nav > .nav-link,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link,
.bg-navbar-theme .navbar-nav > .nav > .nav-item > .nav-link {
color: var(--dark-text);
}
.bg-menu-theme .menu-link:hover,
.bg-menu-theme .menu-link:focus,
.bg-navbar-theme .navbar-nav > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:focus {
color: var(--dark-heading);
}
.bg-menu-theme .menu-inner > .menu-item:not(.active) > .menu-link:hover {
background-color: var(--dark-hover);
}
.layout-navbar,
.layout-navbar h4 {
color: var(--dark-heading);
}
.layout-menu,
.layout-navbar.navbar-detached {
box-shadow: 0 0 0.375rem 0.25rem rgba(0, 0, 0, 0.2);
}
.layout-overlay {
background: #000;
}
/* Year and month bar on the booking overview */
.filter-bar.bg-light {
background-color: var(--dark-card) !important;
border-radius: 0.5rem;
}
.filter-bar .nav-link {
color: var(--dark-text);
}
/* Cards */
.card {
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2);
}
.card-link.disabled,
a.disabled {
color: var(--dark-border);
}
/* Tables */
.table {
--bs-table-striped-color: var(--dark-text);
--bs-table-striped-bg: rgba(255, 255, 255, 0.03);
--bs-table-active-color: var(--dark-text);
--bs-table-active-bg: rgba(255, 255, 255, 0.08);
--bs-table-hover-color: var(--dark-text);
--bs-table-hover-bg: var(--dark-hover);
color: var(--dark-text);
border-color: var(--dark-border);
}
.table:not(.table-dark) th {
color: var(--dark-heading);
}
.table > :not(:first-child) {
border-top-color: var(--dark-border);
}
/* Forms */
.form-label,
.col-form-label,
.form-check-label {
color: var(--dark-heading);
}
.form-control,
.form-select,
.input-group-text {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: var(--dark-border);
}
.form-control::placeholder {
color: var(--dark-muted);
}
.form-control:focus,
.form-select:focus {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: #696cff;
}
.form-control:disabled,
.form-control[readonly],
.form-select:disabled {
background-color: var(--dark-bg);
}
.form-control[type="file"]::file-selector-button {
color: var(--dark-heading);
background-color: var(--dark-bg);
}
.form-control[type="file"]:hover:not(:disabled):not([readonly])::file-selector-button {
background-color: var(--dark-border);
}
.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a3a4cc' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}
.form-check-input:not(:checked) {
background-color: var(--dark-card);
border-color: var(--dark-border);
}
/* Buttons */
.btn-outline-secondary {
color: var(--dark-text);
border-color: var(--dark-muted);
}
.btn-outline-secondary:hover {
color: var(--dark-heading);
background-color: var(--dark-hover);
border-color: var(--dark-text);
}
.btn.p-0,
.btn:not([class*="btn-"]) {
color: var(--dark-text);
}
/* Dropdown menus */
.dropdown-menu {
color: var(--dark-text);
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
}
.dropdown-item {
color: var(--dark-text);
}
.dropdown-item:hover,
.dropdown-item:focus {
color: var(--dark-heading);
background-color: var(--dark-hover);
}
.dropdown-divider {
border-top-color: var(--dark-border);
}
/* Dialogs */
.modal-content {
background-color: var(--dark-card);
box-shadow: 0 0.25rem 1.5rem rgba(0, 0, 0, 0.5);
}
.modal .btn-close {
background-color: var(--dark-border);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.4);
}
/* Alerts */
.alert-danger {
background-color: #55303a;
border-color: #6b3540;
color: #ff8a75;
}
/* Public booking form embedded in the "Create booking" dialog */
.leptos-captcha {
color: var(--dark-text);
}
+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">