From b671f835a6c21c95a12bcb8de9cac5dfde1d22c8 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Mon, 9 Dec 2024 12:19:31 +0100 Subject: [PATCH 1/9] Change logo configuration to allow multiple languages (backend) This will allow admins to configure logos in any number of languages, each with the optional properties of size (wide/narrow) and mode (light/dark). The language prop can also be omitted, in which case the respective logo will be used for all languages. --- backend/src/cmd/check.rs | 9 +---- backend/src/config/mod.rs | 9 +---- backend/src/config/theme.rs | 78 +++++++++++++++++++++++-------------- backend/src/http/assets.rs | 74 +++++++++++++++++++---------------- backend/src/sync/stats.rs | 3 -- docs/docs/setup/config.toml | 31 ++++++--------- 6 files changed, 104 insertions(+), 100 deletions(-) diff --git a/backend/src/cmd/check.rs b/backend/src/cmd/check.rs index d2676e10e..86a5afd65 100644 --- a/backend/src/cmd/check.rs +++ b/backend/src/cmd/check.rs @@ -103,13 +103,8 @@ fn print_outcome(any_errors: &mut bool, label: &str, result: &Result) { async fn check_referenced_files(config: &Config) -> Result<()> { // TODO: log file & unix socket? - let mut files = vec![ - &config.theme.favicon, - &config.theme.logo.large.path, - ]; - files.extend(config.theme.logo.small.as_ref().map(|l| &l.path)); - files.extend(config.theme.logo.large_dark.as_ref().map(|l| &l.path)); - files.extend(config.theme.logo.small_dark.as_ref().map(|l| &l.path)); + let mut files = vec![&config.theme.favicon]; + files.extend(config.theme.logos.iter().map(|logo| &logo.path)); files.extend(&config.theme.font.files); files.extend(&config.theme.font.extra_css); files.extend(config.auth.jwt.secret_key.as_ref()); diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 4ecc56e94..5e4fdf075 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -161,14 +161,7 @@ impl Config { fix_path(&base, p); } - fix_path(&base, &mut self.theme.logo.large.path); - if let Some(logo) = &mut self.theme.logo.small { - fix_path(&base, &mut logo.path); - } - if let Some(logo) = &mut self.theme.logo.large_dark { - fix_path(&base, &mut logo.path); - } - if let Some(logo) = &mut self.theme.logo.small_dark { + for logo in &mut self.theme.logos { fix_path(&base, &mut logo.path); } fix_path(&base, &mut self.theme.favicon); diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index 456ee8cfe..d1f3c9027 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -1,4 +1,5 @@ -use std::{path::PathBuf, fmt}; +use std::{fmt, path::PathBuf}; +use serde::{Deserialize, Serialize}; use super::color::ColorConfig; @@ -10,14 +11,25 @@ pub(crate) struct ThemeConfig { #[config(default = 85)] pub(crate) header_height: u32, - /// Logo used in the top left corner of the page. Using SVG logos is recommended. - /// See the documentation on theming/logos for more info! - #[config(nested)] - pub(crate) logo: LogoConfig, - /// Path to an SVG file that is used as favicon. pub(crate) favicon: PathBuf, + /// Logo used in the top left corner of the page. Using SVG logos is recommended. + /// You can configure specific logos for small and large screens, dark and light mode, + /// and any number of languages. Example: + /// + /// ``` + /// logos = [ + /// { path = "logo-large.svg", resolution = [425, 182] }, + /// { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, + /// { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, + /// { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, + /// ] + /// ``` + /// + /// See the documentation on theming/logos for more info and additional examples! + pub(crate) logos: Vec, + /// Colors used in the UI. Specified in sRGB. #[config(nested)] pub(crate) color: ColorConfig, @@ -26,36 +38,42 @@ pub(crate) struct ThemeConfig { pub(crate) font: FontConfig, } - -#[derive(Debug, confique::Config)] -pub(crate) struct LogoConfig { - /// The normal, usually wide logo that is shown on desktop screens. The - /// value is a map with a `path` and `resolution` key: - /// - /// large = { path = "logo.svg", resolution = [20, 8] } - /// - /// The resolution is only an aspect ratio. It is used to avoid layout - /// shifts in the frontend by allocating the correct size for the logo - /// before the browser loaded the file. - pub(crate) large: LogoDef, - - /// A less wide logo used for narrow screens. - pub(crate) small: Option, - - /// Large logo for dark mode usage. - pub(crate) large_dark: Option, - - /// Small logo for dark mode usage. - pub(crate) small_dark: Option, -} - #[derive(Debug, Clone, serde::Deserialize)] pub(crate) struct LogoDef { + pub(crate) size: Option, + pub(crate) mode: Option, + pub(crate) lang: Option, pub(crate) path: PathBuf, pub(crate) resolution: LogoResolution, } -#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LogoSize { + Wide, + Narrow, +} + +impl fmt::Display for LogoSize { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LogoMode { + Light, + Dark, +} + +impl fmt::Display for LogoMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} + +#[derive(Clone, Serialize, Deserialize)] pub(crate) struct LogoResolution(pub(crate) [u32; 2]); impl fmt::Debug for LogoResolution { diff --git a/backend/src/http/assets.rs b/backend/src/http/assets.rs index 0f476b0b0..d63796845 100644 --- a/backend/src/http/assets.rs +++ b/backend/src/http/assets.rs @@ -4,7 +4,12 @@ use reinda::Embeds; use secrecy::ExposeSecret; use serde_json::json; -use crate::{auth::AuthSource, config::{Config, LogoDef}, prelude::*, util::ByteBody}; +use crate::{ + auth::AuthSource, + config::{Config,LogoDef}, + prelude::*, + util::ByteBody, +}; use super::{handlers::CommonHeadersExt, Response}; @@ -43,50 +48,43 @@ impl Assets { // // TODO: adjust file extension according to actual file path, to avoid // PNG files being served as `.svg`. - let logo_files = || [ - ("large", "logo-large.svg", Some(&config.theme.logo.large)), - ("small", "logo-small.svg", config.theme.logo.small.as_ref()), - ("largeDark", "logo-large-dark.svg", config.theme.logo.large_dark.as_ref()), - ("smallDark", "logo-small-dark.svg", config.theme.logo.small_dark.as_ref()), - ].into_iter().filter_map(|(config_field, http_path, logo)| { - logo.map(|logo| (config_field, http_path, &logo.path)) - }); + let logo_files: Vec<_> = config.theme.logos + .iter() + .map(|logo| (generate_http_path(logo), logo.path.clone())) + .collect(); let mut builder = reinda::Assets::builder(); // Add logo & favicon files builder.add_file(FAVICON_FILE, &config.theme.favicon).with_hash(); - for (_, http_path, logo_path) in logo_files() { - builder.add_file(http_path, logo_path).with_hash(); + for (http_path, logo_path) in &logo_files { + builder.add_file(http_path.clone(), logo_path.clone()).with_hash(); } - // ----- Main HTML file ----------------------------------------------------- // // We use a "modifier" to adjust the file, including the frontend // config, and in particular: refer to the correct paths (which are // potentially hashed). We also insert other variables and code. - let deps = [FAVICON_FILE, FONTS_CSS_FILE] - .into_iter() - .chain(logo_files().map(|(_, http_path, _)| http_path)); + let deps = logo_files.into_iter() + .map(|(http_path, _)| http_path) + .chain([FAVICON_FILE, FONTS_CSS_FILE].map(ToString::to_string)); builder.add_embedded(INDEX_FILE, &EMBEDS[INDEX_FILE]).with_modifier(deps, { let frontend_config = frontend_config(config); let html_title = config.general.site_title.en().to_owned(); let global_style = config.theme.to_css(); let matomo_code = config.matomo.js_code().unwrap_or_default(); - let logo_paths = logo_files() - .map(|(config_field, http_path, _)| (config_field, http_path)) - .collect::>(); move |original, ctx| { - // Fill logo path in frontend config and convert it to string. let mut frontend_config = frontend_config.clone(); - for (config_field, http_path) in &logo_paths { - let actual_path = format!("/~assets/{}", ctx.resolve_path(http_path)); - frontend_config["logo"][config_field]["path"] = json!(actual_path); + for logo in frontend_config["logos"].as_array_mut().expect("logos is not an array") { + let original_path = logo["path"].as_str().unwrap(); + let resolved = ctx.resolve_path(original_path); + logo["path"] = format!("/~assets/{}", resolved).into(); } + let frontend_config = if cfg!(debug_assertions) { serde_json::to_string_pretty(&frontend_config).unwrap() } else { @@ -245,11 +243,15 @@ impl Assets { } fn frontend_config(config: &Config) -> serde_json::Value { - let logo_obj = |logo_def: &LogoDef| json!({ - // The path will be added later in the modifier - "path": "", - "resolution": logo_def.resolution, - }); + let logo_entries = config.theme.logos.iter() + .map(|logo| json!({ + "size": logo.size.as_ref().map(ToString::to_string), + "mode": logo.mode.as_ref().map(ToString::to_string), + "lang": logo.lang.clone(), + "path": generate_http_path(logo), + "resolution": logo.resolution, + })) + .collect::>(); json!({ "version": { @@ -289,14 +291,20 @@ fn frontend_config(config: &Config) -> serde_json::Value { "studioUrl": config.opencast.studio_url().to_string(), "editorUrl": config.opencast.editor_url().to_string(), }, - "logo": { - "large": logo_obj(&config.theme.logo.large), - "small": config.theme.logo.small.as_ref().map(logo_obj), - "largeDark": config.theme.logo.large_dark.as_ref().map(logo_obj), - "smallDark": config.theme.logo.small_dark.as_ref().map(logo_obj), - }, + "logos": logo_entries, "sync": { "pollPeriod": config.sync.poll_period.as_secs_f64(), }, }) } + +/// Generates HTTP path for a logo based on its `size`, `mode` and `lang` attributes. +/// These are joined with `-`. +/// Defaults to `"logo"` if no optional attributes were provided. +fn generate_http_path(logo: &LogoDef) -> String { + let size = logo.size.as_ref().map(|s| format!("-{}", s)).unwrap_or_default(); + let mode = logo.mode.as_ref().map(|m| format!("-{}", m)).unwrap_or_default(); + let lang = logo.lang.as_ref().map(|l| format!("-{}", l)).unwrap_or_default(); + + format!("logo{size}{mode}{lang}.svg") +} diff --git a/backend/src/sync/stats.rs b/backend/src/sync/stats.rs index 6a160aa6a..4d1f42441 100644 --- a/backend/src/sync/stats.rs +++ b/backend/src/sync/stats.rs @@ -86,8 +86,6 @@ struct ConfigStats { logout_link_overridden: bool, /// Value of `auth.pre_auth_external_links`. uses_pre_auth: bool, - /// Whether `theme.logo.small` is set. - has_narrow_logo: bool, } @@ -118,7 +116,6 @@ impl Stats { login_link_overridden: config.auth.login_link.is_some(), logout_link_overridden: config.auth.logout_link.is_some(), uses_pre_auth: config.auth.pre_auth_external_links, - has_narrow_logo: config.theme.logo.small.is_some(), }, }) } diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index a7ab27fa5..c4ef247c7 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -533,30 +533,23 @@ # Required! This value must be specified. #favicon = - # Logo used in the top left corner of the page. Using SVG logos is recommended. -# See the documentation on theming/logos for more info! -[theme.logo] -# The normal, usually wide logo that is shown on desktop screens. The -# value is a map with a `path` and `resolution` key: +# You can configure specific logos for small and large screens, dark and light mode, +# and any number of languages. Example: # -# large = { path = "logo.svg", resolution = [20, 8] } +# ``` +# logos = [ +# { path = "logo-large.svg", resolution = [425, 182] }, +# { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, +# { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, +# { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, +# ] +# ``` # -# The resolution is only an aspect ratio. It is used to avoid layout -# shifts in the frontend by allocating the correct size for the logo -# before the browser loaded the file. +# See the documentation on theming/logos for more info and additional examples! # # Required! This value must be specified. -#large = - -# A less wide logo used for narrow screens. -#small = - -# Large logo for dark mode usage. -#large_dark = - -# Small logo for dark mode usage. -#small_dark = +#logos = # Colors used in the UI. Specified in sRGB. From 193564d08a189222fca3f2d6b00530af2df086d1 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Mon, 9 Dec 2024 12:25:02 +0100 Subject: [PATCH 2/9] Change logo section of dev, deployment and test configs They now use the new configuration "syntax" for our logos. --- .deployment/templates/config.toml | 13 +++++-------- frontend/tests/util/isolation.ts | 14 ++++++-------- util/dev-config/config.toml | 14 ++++++-------- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/.deployment/templates/config.toml b/.deployment/templates/config.toml index 95c6a4271..43c83a895 100644 --- a/.deployment/templates/config.toml +++ b/.deployment/templates/config.toml @@ -53,12 +53,9 @@ poll_period = "1min" interpret_eth_passwords = true [theme] -logo.large.path = "/opt/tobira/{{ id }}/logo-large.svg" -logo.large.resolution = [643, 217] -logo.large_dark.path = "/opt/tobira/{{ id }}/logo-large-dark.svg" -logo.large_dark.resolution = [643, 217] -logo.small.path = "/opt/tobira/{{ id }}/logo-small.svg" -logo.small.resolution = [102, 115] -logo.small_dark.path = "/opt/tobira/{{ id }}/logo-small.svg" -logo.small_dark.resolution = [212, 182] favicon = "/opt/tobira/{{ id }}/favicon.svg" +logos = [ + { path = "/opt/tobira/{{ id }}/logo-large.svg", mode = "light", size = "wide", resolution = [425, 182] }, + { path = "/opt/tobira/{{ id }}/logo-large-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, + { path = "/opt/tobira/{{ id }}/logo-small.svg", size = "narrow", resolution = [212, 182] }, +] diff --git a/frontend/tests/util/isolation.ts b/frontend/tests/util/isolation.ts index a5284a4f9..9dfb11c93 100644 --- a/frontend/tests/util/isolation.ts +++ b/frontend/tests/util/isolation.ts @@ -112,6 +112,7 @@ const runTobiraCommand = async (tobira: TobiraProcess, args: string[]) => // TODO: DB +/* eslint-disable max-len */ const tobiraConfig = ({ index, port, dbName, rootPath }: { index: number; port: number; @@ -151,13 +152,10 @@ const tobiraConfig = ({ index, port, dbName, rootPath }: { password = "opencast" [theme] - logo.large.path = "${rootPath}/util/dev-config/logo-large.svg" - logo.large.resolution = [425, 182] - logo.large_dark.path = "${rootPath}/util/dev-config/logo-large-dark.svg" - logo.large_dark.resolution = [425, 182] - logo.small.path = "${rootPath}/util/dev-config/logo-small.svg" - logo.small.resolution = [212, 182] - logo.small_dark.path = "${rootPath}/util/dev-config/logo-small.svg" - logo.small_dark.resolution = [212, 182] favicon = "${rootPath}/util/dev-config/favicon.svg" + logos = [ + { path = "${rootPath}/util/dev-config/logo-large.svg", mode = "light", size = "wide", resolution = [425, 182] }, + { path = "${rootPath}/util/dev-config/logo-large-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, + { path = "${rootPath}/util/dev-config/logo-small.svg", size = "narrow", resolution = [212, 182] } + ] `; diff --git a/util/dev-config/config.toml b/util/dev-config/config.toml index 53eb09f29..60432a858 100644 --- a/util/dev-config/config.toml +++ b/util/dev-config/config.toml @@ -45,14 +45,6 @@ preferred_harvest_size = 3 interpret_eth_passwords = true [theme] -logo.large.path = "logo-large.svg" -logo.large.resolution = [425, 182] -logo.large_dark.path = "logo-large-dark.svg" -logo.large_dark.resolution = [425, 182] -logo.small.path = "logo-small.svg" -logo.small.resolution = [212, 182] -logo.small_dark.path = "logo-small.svg" -logo.small_dark.resolution = [212, 182] favicon = "favicon.svg" # color.primary = "#215CAF" # ETH Blau # color.primary = "#627313" # ETH Grün @@ -60,3 +52,9 @@ favicon = "favicon.svg" # color.primary = "#B7352D" # ETH Rot # color.primary = "#A7117A" # ETH Purpur # color.primary = "#4B67AB" # Bern Blue + +logos = [ + { path = "logo-large.svg", mode = "light", size = "wide", resolution = [425, 182] }, + { path = "logo-large-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, + { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, +] From b9d97cef51c1f830fa5e7e8372ae2ea7f1e56efe Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Mon, 9 Dec 2024 12:26:25 +0100 Subject: [PATCH 3/9] Apply frontend changes for updated logo config This will now change the logo based on current language, if applicable logos exist. --- frontend/src/config.ts | 14 ++++------ frontend/src/layout/header/Logo.tsx | 40 ++++++++--------------------- frontend/src/util/index.ts | 24 ++++++++++++++++- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 1a4293d7c..6e88a757d 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -31,7 +31,7 @@ type Config = { opencast: OpencastConfig; footerLinks: FooterLink[]; metadataLabels: Record>; - logo: LogoConfig; + logos: LogoConfig; plyr: PlyrConfig; upload: UploadConfig; paellaPluginConfig: object; @@ -63,16 +63,12 @@ type AuthConfig = { }; type LogoConfig = { - large: SingleLogoConfig; - small: SingleLogoConfig | null; - largeDark: SingleLogoConfig | null; - smallDark: SingleLogoConfig | null; -}; - -type SingleLogoConfig = { + size: "wide" | "narrow"| null; + mode: "light" | "dark"| null; + lang: string | null; path: string; resolution: number[]; -}; +}[]; type PlyrConfig = { blankVideo: string; diff --git a/frontend/src/layout/header/Logo.tsx b/frontend/src/layout/header/Logo.tsx index 3ed52e892..2defa9c24 100644 --- a/frontend/src/layout/header/Logo.tsx +++ b/frontend/src/layout/header/Logo.tsx @@ -1,18 +1,20 @@ import { useTranslation } from "react-i18next"; -import { screenWidthAbove, screenWidthAtMost, useColorScheme } from "@opencast/appkit"; +import { screenWidthAbove, screenWidthAtMost } from "@opencast/appkit"; import CONFIG from "../../config"; import { BREAKPOINT_SMALL } from "../../GlobalStyle"; import { Link } from "../../router"; import { focusStyle } from "../../ui"; -import { translatedConfig } from "../../util"; +import { translatedConfig, useLogoConfig } from "../../util"; import { HEADER_BASE_PADDING } from "./ui"; import { COLORS } from "../../color"; export const Logo: React.FC = () => { const { t, i18n } = useTranslation(); - const isDark = useColorScheme().scheme === "dark"; + const logos = useLogoConfig(); + + const alt = t("general.logo-alt", { title: translatedConfig(CONFIG.siteTitle, i18n) }); // This is a bit tricky: we want to specify the `width` and `height` // attributes on the `img` elements in order to avoid layout shift. That @@ -32,24 +34,6 @@ export const Logo: React.FC = () => { // The solution is to calculate the correct `flex-basis` for the `` // element manually. - const large = CONFIG.logo.large; - const small = CONFIG.logo.small ?? CONFIG.logo.large; - const largeDark = CONFIG.logo.largeDark ?? CONFIG.logo.large; - const smallDark = CONFIG.logo.smallDark - ?? CONFIG.logo.largeDark - ?? CONFIG.logo.small - ?? CONFIG.logo.large; - - // If the dark logos are not specified, we default to the white ones but - // inverting them. - const invertLargeDark = CONFIG.logo.largeDark === null; - const invertSmallDark = CONFIG.logo.smallDark === null && CONFIG.logo.largeDark === null; - - const alt = t("general.logo-alt", { title: translatedConfig(CONFIG.siteTitle, i18n) }); - const invertCss = { - filter: "invert(100%) hue-rotate(180deg)", - }; - return ( { }, }}> {alt} {alt} { export const credentialsStorageKey = (kind: IdKind, id: string) => CREDENTIALS_STORAGE_KEY + kind + "-" + id; + +export const useLogoConfig = () => { + const { i18n } = useTranslation(); + const mode = useColorScheme().scheme; + const lang = i18n.resolvedLanguage; + const logos = CONFIG.logos; + + const findLogo = (size: "wide" | "narrow") => logos + .filter(l => l.size === size || !l.size) + .filter(l => l.mode === mode || !l.mode) + .find(l => l.lang === lang || !l.lang); + + const wide = findLogo("wide"); + const narrow = findLogo("narrow"); + + if (!wide || !narrow) { + // Shouldn't happen™, but helps with type safety. + bug("missing logos in configuration"); + } + + return { wide, narrow }; +}; From c84b7f05b356b2583e1c236805d4cda3ae95a488 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Mon, 9 Dec 2024 12:43:51 +0100 Subject: [PATCH 4/9] Update docs on logo configuration --- docs/docs/setup/theme.md | 59 ++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/docs/setup/theme.md b/docs/docs/setup/theme.md index f56776f2f..171b9cb0c 100644 --- a/docs/docs/setup/theme.md +++ b/docs/docs/setup/theme.md @@ -21,14 +21,57 @@ Once the logo file is created and configured, adjust `header_height` to your lik This is the height of the header (and thus also your logo) in pixels. Only the logo is stretched, all other elements are vertically centered within the header. -You can also configure a second logo file as `logo.small` which is used for narrow screens (e.g. phones). -This is usually roughly square. -We strongly recommend setting this smaller logo, as otherwise, the main logo (especially if it is very wide) might get shrunk on narrow screens in order to still show the other elements in the header. - -You should also test if the logo is properly configured for dark mode: -- To use a different image for dark mode, set `logo.large_dark` and `logo.small_dark` appropriately. -- If your normal logo already works well for dark mode, set `logo.large_dark` and `logo.small_dark` to the same values as `large` and `small`, respectively. -- If `logo.large_dark` and `logo.small_dark` are not set, `large` and `small` are used, but with all colors inverted. This might work for you in special cases, e.g. if your logo is fully black or transparent. + +You can configure different logo files for different cases, depending on device-size (mobile vs desktop), color scheme (light vs dark) and language. In the simplest case of using a single logo for all cases, your config looks like this: + +```toml title=config.toml +[theme] +logos = [ + { path = "logo.svg", resolution = [20, 8] }, +] +``` + +Note that the resolution is only an aspect ratio that is used to prevent layout shifts. + +Most likely, you want to configure different logos for desktop and mobile, for example. +You can do that by duplicating the line and adding `size = "narrow"` and `size = "wide"` to the entries: + +```toml title=config.toml +[theme] +logos = [ + { size = "wide", path = "logo-desktop.svg", resolution = [20, 8] }, + { size = "narrow", path = "logo-mobile.svg", resolution = [1, 1] }, +] +``` + +You can split these entries further by adding `mode = "light"` and `mode = "dark"`. +Let's say you only need to configure a dark version of your large logo, as your small one works well in dark mode already: + +```toml title=config.toml +[theme] +logos = [ + { size = "wide", mode = "light", path = "logo-desktop-light.svg", resolution = [20, 8] }, + { size = "wide", mode = "dark", path = "logo-desktop-dark.svg", resolution = [20, 8] }, + { size = "narrow", path = "logo-mobile.svg", resolution = [1, 1] }, +] +``` + +Finally, you can add the `lang = ".."` field to specify different logos for different languages (e.g. `"en"` and `"de"`). +Here, `"*"` can be specified as the default logo when there is no logo specified for a specific language. +Continuing the example, lets say the wide logos are language specific in that we have a specific logo for German and want to use another one for all other languages. + +```toml title=config.toml +[theme] +logos = [ + { size = "wide", mode = "light", lang = "*", path = "logo-desktop-light.svg", resolution = [20, 8] }, + { size = "wide", mode = "light", lang = "de", path = "logo-desktop-light-de.svg", resolution = [20, 8] }, + { size = "wide", mode = "dark", lang = "*", path = "logo-desktop-dark.svg", resolution = [20, 8] }, + { size = "wide", mode = "dark", lang = "de", path = "logo-desktop-dark-de.svg", resolution = [20, 8] }, + { size = "narrow", path = "logo-mobile.svg", resolution = [1, 1] }, +] +``` + +The order in which these distinguishing fields (`size`, `mode`, `lang`) are added is up to you, and you can can split and merge these entries however you like, as long as for each specific case (i.e. tuple of `(size, mode, lang)`), there is exactly one applicable logo definition. ## Favicon From e0a34d718390fff1d3f8ce892dda4c29bac05a41 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 11 Dec 2024 14:17:36 +0100 Subject: [PATCH 5/9] Stop waiting for timeout in UI tests with faulty config --- frontend/tests/util/isolation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/tests/util/isolation.ts b/frontend/tests/util/isolation.ts index 9dfb11c93..522d5e71b 100644 --- a/frontend/tests/util/isolation.ts +++ b/frontend/tests/util/isolation.ts @@ -61,6 +61,12 @@ export const test = base.extend({ ["serve", "--config", configPath], // { stdio: "inherit" } ); + tobiraProcess.addListener("exit", code => { + if (code != null) { + throw new Error(`Failed to start Tobira process (exit code ${code}). ` + + 'Invalid config? (Hint: try setting stdio: "inherit" in isolation.ts)'); + } + }); await waitPort({ port, interval: 10, output: "silent" }); From 1fb48ca831ef9b1e6f3c0de6c05534d33c658fb8 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Thu, 19 Dec 2024 13:28:31 +0100 Subject: [PATCH 6/9] Refactor `TranslatedString` --- backend/src/config/theme.rs | 4 +- backend/src/config/translated_string.rs | 57 +++++++++++-------------- backend/src/http/assets.rs | 2 +- 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index d1f3c9027..508e37a0b 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -1,7 +1,7 @@ use std::{fmt, path::PathBuf}; use serde::{Deserialize, Serialize}; -use super::color::ColorConfig; +use super::{color::ColorConfig, translated_string::LangKey}; #[derive(Debug, confique::Config)] @@ -42,7 +42,7 @@ pub(crate) struct ThemeConfig { pub(crate) struct LogoDef { pub(crate) size: Option, pub(crate) mode: Option, - pub(crate) lang: Option, + pub(crate) lang: Option, pub(crate) path: PathBuf, pub(crate) resolution: LogoResolution, } diff --git a/backend/src/config/translated_string.rs b/backend/src/config/translated_string.rs index a1306e066..e9c6513f5 100644 --- a/backend/src/config/translated_string.rs +++ b/backend/src/config/translated_string.rs @@ -1,47 +1,25 @@ use std::{collections::HashMap, fmt}; -use serde::Deserialize; - +use serde::{Deserialize, Serialize}; +use anyhow::{anyhow, Error}; /// A configurable string specified in different languages. Language 'en' always /// has to be specified. -#[derive(serde::Serialize, Clone)] -pub(crate) struct TranslatedString(HashMap); +#[derive(Serialize, Deserialize, Clone)] +#[serde(try_from = "HashMap")] +pub(crate) struct TranslatedString(HashMap); impl TranslatedString { - pub(crate) const LANGUAGES: &'static [&'static str] = &["en", "de"]; - pub(crate) fn en(&self) -> &str { - &self.0["en"] + &self.0[&LangKey::En] } } -impl<'de> Deserialize<'de> for TranslatedString { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - - let map = >::deserialize(deserializer).map_err(|e| { - D::Error::custom(format!( - "invalid translated string, expected object with keys 'en', 'de', ... ({})", - e, - )) - })?; - - // Make sure only valid languages are specified - if let Some(invalid) = map.keys().find(|key| !Self::LANGUAGES.contains(&key.as_str())) { - return Err(D::Error::custom(format!( - "'{}' is not a valid language key for translated string (valid keys: {:?})", - invalid, - Self::LANGUAGES, - ))); - } +impl TryFrom> for TranslatedString { + type Error = Error; - if !map.contains_key("en") { - return Err(D::Error::custom( - "translated string not specified for language 'en', but it has to be" - )); + fn try_from(map: HashMap) -> Result { + if !map.contains_key(&LangKey::En) { + return Err(anyhow!("Translated string must include 'en' as a language.")); } Ok(Self(map)) @@ -54,3 +32,16 @@ impl fmt::Debug for TranslatedString { f.debug_map().entries(self.0.iter()).finish() } } + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LangKey { + En, + De, +} + +impl fmt::Display for LangKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.serialize(f) + } +} diff --git a/backend/src/http/assets.rs b/backend/src/http/assets.rs index d63796845..7fc87e48d 100644 --- a/backend/src/http/assets.rs +++ b/backend/src/http/assets.rs @@ -247,7 +247,7 @@ fn frontend_config(config: &Config) -> serde_json::Value { .map(|logo| json!({ "size": logo.size.as_ref().map(ToString::to_string), "mode": logo.mode.as_ref().map(ToString::to_string), - "lang": logo.lang.clone(), + "lang": logo.lang.as_ref().map(ToString::to_string), "path": generate_http_path(logo), "resolution": logo.resolution, })) From 6fe4f02cda586f8b6a44159985e0d37c8e0d3af9 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 19 Dec 2024 15:10:13 +0100 Subject: [PATCH 7/9] Add `LangKey::Default` and use it as default instead of En (BREAKING) This is a bit more explicit and flexible than the previous solution where `en` was always the default. Sadly, this is a breaking change for the configuration. In most cases, migrating is just replacing `en` with `default`. --- .deployment/templates/config.toml | 4 ++-- backend/src/config/mod.rs | 7 ++++--- backend/src/config/theme.rs | 24 ++++++++++++++++++++++++ backend/src/config/translated_string.rs | 10 ++++++---- backend/src/http/assets.rs | 2 +- docs/docs/setup/config.toml | 7 ++++--- frontend/src/config.ts | 2 +- frontend/src/ui/InitialConsent.tsx | 2 +- frontend/src/util/index.ts | 4 ++-- frontend/tests/util/isolation.ts | 2 +- util/dev-config/config.toml | 2 +- 11 files changed, 47 insertions(+), 19 deletions(-) diff --git a/.deployment/templates/config.toml b/.deployment/templates/config.toml index 43c83a895..df167dc3f 100644 --- a/.deployment/templates/config.toml +++ b/.deployment/templates/config.toml @@ -1,5 +1,5 @@ [general] -site_title.en = "Tobira Test Deployment" +site_title.default = "Tobira Test Deployment" tobira_url = "https://{% if id != 'main' %}{{id}}.{% endif %}tobira.opencast.org" users_searchable = true @@ -25,7 +25,7 @@ unix_socket_permissions = 0o777 [auth] source = "tobira-session" session.from_login_credentials = "login-callback:http+unix://[/opt/tobira/{{ id }}/socket/auth.sock]/" -login_page.note.en = 'Dummy users: "jose", "morgan", "björk" and "sabine". Password for all: "tobira".' +login_page.note.default = 'Dummy users: "jose", "morgan", "björk" and "sabine". Password for all: "tobira".' login_page.note.de = 'Testnutzer: "jose", "morgan", "björk" und "sabine". Passwort für alle: "tobira".' trusted_external_key = "tobira" diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 5e4fdf075..086d186e4 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -50,11 +50,12 @@ const TOBIRA_CONFIG_PATH_ENV: &str = "TOBIRA_CONFIG_PATH"; /// units: 'ms', 's', 'min', 'h' and 'd'. /// /// All user-facing texts you can configure here have to be specified per -/// language, with two letter language key. Only English ('en') is required. -/// Take `general.site_title` for example: +/// language, with two letter language key. The special key 'default' is +/// required and used as fallback for languages that are not specified +/// explicitly. Take `general.site_title` for example: /// /// [general] -/// site_title.en = "My university" +/// site_title.default = "My university" /// site_title.de = "Meine Universität" /// #[derive(Debug, confique::Config)] diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index 508e37a0b..5a8b8a242 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -28,6 +28,7 @@ pub(crate) struct ThemeConfig { /// ``` /// /// See the documentation on theming/logos for more info and additional examples! + #[config(validate = validate_logos)] pub(crate) logos: Vec, /// Colors used in the UI. Specified in sRGB. @@ -146,3 +147,26 @@ impl ThemeConfig { out } } + +fn validate_logos(logos: &Vec) -> Result<(), String> { + let mut cases = HashMap::new(); + for logo in logos { + let modes = logo.mode.map(|m| [m]).unwrap_or([LogoMode::Light, LogoMode::Dark]); + let sizes = logo.size.map(|s| [s]).unwrap_or([LogoSize::Wide, LogoSize::Narrow]); + + for mode in modes { + for size in sizes { + let key = (mode, size); + let prev = cases.insert(key, &logo.path); + if let Some(prev) = prev { + return Err(format!( + "ambiguous logo definition: " + )); + } + } + } + } + + + Ok(()) +} diff --git a/backend/src/config/translated_string.rs b/backend/src/config/translated_string.rs index e9c6513f5..6fcc56c5a 100644 --- a/backend/src/config/translated_string.rs +++ b/backend/src/config/translated_string.rs @@ -9,8 +9,8 @@ use anyhow::{anyhow, Error}; pub(crate) struct TranslatedString(HashMap); impl TranslatedString { - pub(crate) fn en(&self) -> &str { - &self.0[&LangKey::En] + pub(crate) fn default(&self) -> &str { + &self.0[&LangKey::Default] } } @@ -18,8 +18,8 @@ impl TryFrom> for TranslatedString { type Error = Error; fn try_from(map: HashMap) -> Result { - if !map.contains_key(&LangKey::En) { - return Err(anyhow!("Translated string must include 'en' as a language.")); + if !map.contains_key(&LangKey::Default) { + return Err(anyhow!("Translated string must include 'default' entry.")); } Ok(Self(map)) @@ -36,6 +36,8 @@ impl fmt::Debug for TranslatedString { #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] #[serde(rename_all = "lowercase")] pub(crate) enum LangKey { + #[serde(alias = "*")] + Default, En, De, } diff --git a/backend/src/http/assets.rs b/backend/src/http/assets.rs index 7fc87e48d..87d845d92 100644 --- a/backend/src/http/assets.rs +++ b/backend/src/http/assets.rs @@ -73,7 +73,7 @@ impl Assets { builder.add_embedded(INDEX_FILE, &EMBEDS[INDEX_FILE]).with_modifier(deps, { let frontend_config = frontend_config(config); - let html_title = config.general.site_title.en().to_owned(); + let html_title = config.general.site_title.default().to_owned(); let global_style = config.theme.to_css(); let matomo_code = config.matomo.js_code().unwrap_or_default(); diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index c4ef247c7..3d9a1892c 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -5,11 +5,12 @@ # units: 'ms', 's', 'min', 'h' and 'd'. # # All user-facing texts you can configure here have to be specified per -# language, with two letter language key. Only English ('en') is required. -# Take `general.site_title` for example: +# language, with two letter language key. The special key 'default' is +# required and used as fallback for languages that are not specified +# explicitly. Take `general.site_title` for example: # # [general] -# site_title.en = "My university" +# site_title.default = "My university" # site_title.de = "Meine Universität" # diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 6e88a757d..45a6106aa 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -101,7 +101,7 @@ type SyncConfig = { type MetadataLabel = "builtin:license" | "builtin:source" | TranslatedString; -export type TranslatedString = { en: string } & Record<"de", string | undefined>; +export type TranslatedString = { default: string } & Record<"en" | "de", string | undefined>; const CONFIG: Config = parseConfig(); export default CONFIG; diff --git a/frontend/src/ui/InitialConsent.tsx b/frontend/src/ui/InitialConsent.tsx index 144c0bd72..0cb65736f 100644 --- a/frontend/src/ui/InitialConsent.tsx +++ b/frontend/src/ui/InitialConsent.tsx @@ -26,7 +26,7 @@ export const InitialConsent: React.FC = ({ consentGiven: initialConsentGi const currentLanguage = i18n.resolvedLanguage ?? "en"; const usedLang = currentLanguage in notNullish(CONFIG.initialConsent).text ? currentLanguage - : "en"; + : "default"; const hash = await calcHash(usedLang); localStorage.setItem(LOCAL_STORAGE_KEY, `${usedLang}:${hash}`); diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index a1b3ea828..579622677 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -85,8 +85,8 @@ export const translatedConfig = (s: TranslatedString, i18n: i18n): string => getTranslatedString(s, i18n.resolvedLanguage); export const getTranslatedString = (s: TranslatedString, lang: string | undefined): string => { - const l = lang ?? "en"; - return (l in s ? s[l as keyof TranslatedString] : undefined) ?? s.en; + const l = lang ?? "default"; + return (l in s ? s[l as keyof TranslatedString] : undefined) ?? s.default; }; export const useOnOutsideClick = ( diff --git a/frontend/tests/util/isolation.ts b/frontend/tests/util/isolation.ts index 522d5e71b..e77d8c893 100644 --- a/frontend/tests/util/isolation.ts +++ b/frontend/tests/util/isolation.ts @@ -126,7 +126,7 @@ const tobiraConfig = ({ index, port, dbName, rootPath }: { rootPath: string; }) => ` [general] - site_title.en = "Tobira Videoportal" + site_title.default = "Tobira Videoportal" tobira_url = "http://localhost:${port}" users_searchable = true diff --git a/util/dev-config/config.toml b/util/dev-config/config.toml index 60432a858..73b979296 100644 --- a/util/dev-config/config.toml +++ b/util/dev-config/config.toml @@ -2,7 +2,7 @@ # developer, you are not interested in this file. [general] -site_title.en = "Tobira Videoportal" +site_title.default = "Tobira Videoportal" tobira_url = "http://localhost:8030" users_searchable = true From 3aa3a60826978f905a3cadb77f6abef8161c18a4 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 19 Dec 2024 16:14:51 +0100 Subject: [PATCH 8/9] Add logo config validation This makes sure for every case/situation there is exactly one logo defined. --- backend/src/config/theme.rs | 77 +++++++++++++++++++++++++++++-------- docs/docs/setup/config.toml | 5 +-- 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index 5a8b8a242..fd62fa01b 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -1,4 +1,4 @@ -use std::{fmt, path::PathBuf}; +use std::{collections::HashMap, fmt, path::PathBuf}; use serde::{Deserialize, Serialize}; use super::{color::ColorConfig, translated_string::LangKey}; @@ -20,9 +20,8 @@ pub(crate) struct ThemeConfig { /// /// ``` /// logos = [ - /// { path = "logo-large.svg", resolution = [425, 182] }, - /// { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, - /// { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, + /// { path = "logo-wide-light.svg", mode = "light", size = "wide", resolution = [425, 182] }, + /// { path = "logo-wide-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, /// { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, /// ] /// ``` @@ -48,7 +47,7 @@ pub(crate) struct LogoDef { pub(crate) resolution: LogoResolution, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] pub(crate) enum LogoSize { Wide, @@ -61,7 +60,7 @@ impl fmt::Display for LogoSize { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] pub(crate) enum LogoMode { Light, @@ -149,24 +148,72 @@ impl ThemeConfig { } fn validate_logos(logos: &Vec) -> Result<(), String> { + #[derive()] + enum LangLogo { + Universal(usize), + LangSpecific(HashMap), + } + + let all_modes = [LogoMode::Light, LogoMode::Dark]; + let all_sizes = [LogoSize::Wide, LogoSize::Narrow]; + let mut cases = HashMap::new(); - for logo in logos { - let modes = logo.mode.map(|m| [m]).unwrap_or([LogoMode::Light, LogoMode::Dark]); - let sizes = logo.size.map(|s| [s]).unwrap_or([LogoSize::Wide, LogoSize::Narrow]); + for (i, logo) in logos.iter().enumerate() { + let modes = logo.mode.map(|m| vec![m]).unwrap_or(all_modes.to_vec()); + let sizes = logo.size.map(|s| vec![s]).unwrap_or(all_sizes.to_vec()); - for mode in modes { - for size in sizes { + for &mode in &modes { + for &size in &sizes { let key = (mode, size); - let prev = cases.insert(key, &logo.path); - if let Some(prev) = prev { + + if let Some(entry) = cases.get_mut(&key) { + let conflicting = match (entry, &logo.lang) { + (LangLogo::LangSpecific(m), Some(lang)) => m.insert(lang.clone(), i), + (LangLogo::LangSpecific(m), None) => m.values().next().copied(), + (LangLogo::Universal(c), _) => Some(*c), + }; + + if let Some(conflicting) = conflicting { + return Err(format!( + "ambiguous logo definition: \ + entry {i} (path: '{curr_path}') conflicts with \ + entry {prev_index} (path: '{prev_path}'). \ + Both define a {mode} {size} logo, which is only allowed \ + if both have different 'lang' keys! Consider adding 'mode' \ + or 'size' fields to make entries more specific.", + i = i + 1, + prev_index = conflicting + 1, + curr_path = logo.path.display(), + prev_path = logos[conflicting].path.display(), + )); + } + } else { + cases.insert(key, match &logo.lang { + Some(lang) => LangLogo::LangSpecific(HashMap::from([(lang.clone(), i)])), + None => LangLogo::Universal(i), + }); + } + } + } + } + + // Check that all cases are defined + for mode in all_modes { + for size in all_sizes { + match cases.get(&(mode, size)) { + None => return Err(format!( + "incomplete logo configuration: no {mode} {size} logo defined", + )), + Some(LangLogo::LangSpecific(m)) if !m.contains_key(&LangKey::Default) => { return Err(format!( - "ambiguous logo definition: " + "incomplete logo configuration: {mode} {size} logo is \ + missing `lang = '*'` entry", )); } + _ => {} } } } - Ok(()) } diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index 3d9a1892c..cd071d75b 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -540,9 +540,8 @@ # # ``` # logos = [ -# { path = "logo-large.svg", resolution = [425, 182] }, -# { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, -# { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, +# { path = "logo-wide-light.svg", mode = "light", size = "wide", resolution = [425, 182] }, +# { path = "logo-wide-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, # { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, # ] # ``` From 709fdd9fbac6fc62c2667f0ca267a0eb8af59beb Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Thu, 19 Dec 2024 16:43:17 +0100 Subject: [PATCH 9/9] Fix deployment config --- .deployment/templates/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.deployment/templates/config.toml b/.deployment/templates/config.toml index df167dc3f..3e9727767 100644 --- a/.deployment/templates/config.toml +++ b/.deployment/templates/config.toml @@ -6,7 +6,7 @@ users_searchable = true [general.metadata] dcterms.source = "builtin:source" dcterms.license = "builtin:license" -dcterms.spatial = { en = "Location", de = "Ort" } +dcterms.spatial = { default = "Location", de = "Ort" } [db] database = "tobira-{{ id }}"