diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 7d0c510..2e5c345 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -46,5 +46,7 @@ paths: - UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (``). 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 `` 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`. diff --git a/CLAUDE.md b/CLAUDE.md index cfb57c3..c2c4421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` — ``, 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`. diff --git a/Cargo.lock b/Cargo.lock index 852fece..b8c51bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3341,6 +3341,7 @@ dependencies = [ "cfg-if", "charts-rs", "chrono", + "codee", "console_error_panic_hook", "env_logger", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index e34d0bb..b436b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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} diff --git a/assets/css/dark.css b/assets/css/dark.css new file mode 100644 index 0000000..e95af76 --- /dev/null +++ b/assets/css/dark.css @@ -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); +} diff --git a/src/app.rs b/src/app.rs index 36df723..cd3bcc7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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! {
diff --git a/src/backend/reservation.rs b/src/backend/reservation.rs index 28ffa91..df7eb0f 100644 --- a/src/backend/reservation.rs +++ b/src/backend/reservation.rs @@ -449,8 +449,8 @@ pub async fn cancel(uuid: String) -> Result, ServerFnError> { } #[cfg(feature = "ssr")] -fn chart(data: &Vec, title: &str, month: bool) -> String { - use charts_rs::{BarChart, THEME_ANT}; +fn chart(data: &Vec, 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, 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, title: &str, month: bool) -> String { } #[server] -pub async fn month_chart(year: i32) -> Result, ServerFnError> { +pub async fn month_chart(year: i32, dark: bool) -> Result, 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, ServerFnError> { +pub async fn year_chart(dark: bool) -> Result, 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] diff --git a/src/components/admin_portal.rs b/src/components/admin_portal.rs index de471e0..369a1f5 100644 --- a/src/components/admin_portal.rs +++ b/src/components/admin_portal.rs @@ -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::().expect("No drawer opener"); let about_dlg = DialogOpener::new(); + let theme = use_context::().expect("No theme"); view! { + // Dark theme is limited to the admin portal: the stylesheet is loaded only here +
// @@ -141,6 +145,15 @@ pub fn AdminPortal(children: Children) -> impl IntoView {