Added dark theme to admin portal. Fixes #7
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
@@ -3341,6 +3341,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"charts-rs",
|
||||
"chrono",
|
||||
"codee",
|
||||
"console_error_panic_hook",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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