Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring logos for multiple languages #1292

Merged
merged 10 commits into from
Jan 14, 2025
19 changes: 8 additions & 11 deletions .deployment/templates/config.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[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

[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 }}"
Expand All @@ -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"
Expand Down Expand Up @@ -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] },
]
9 changes: 2 additions & 7 deletions backend/src/cmd/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,8 @@ fn print_outcome<T>(any_errors: &mut bool, label: &str, result: &Result<T>) {
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());
Expand Down
16 changes: 5 additions & 11 deletions backend/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -161,14 +162,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);
Expand Down
151 changes: 120 additions & 31 deletions backend/src/config/theme.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{path::PathBuf, fmt};
use std::{collections::HashMap, fmt, path::PathBuf};
use serde::{Deserialize, Serialize};

use super::color::ColorConfig;
use super::{color::ColorConfig, translated_string::LangKey};


#[derive(Debug, confique::Config)]
Expand All @@ -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-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] },
/// ]
/// ```
///
/// See the documentation on theming/logos for more info and additional examples!
#[config(validate = validate_logos)]
pub(crate) logos: Vec<LogoDef>,

/// Colors used in the UI. Specified in sRGB.
#[config(nested)]
pub(crate) color: ColorConfig,
Expand All @@ -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<LogoDef>,

/// Large logo for dark mode usage.
pub(crate) large_dark: Option<LogoDef>,

/// Small logo for dark mode usage.
pub(crate) small_dark: Option<LogoDef>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub(crate) struct LogoDef {
pub(crate) size: Option<LogoSize>,
pub(crate) mode: Option<LogoMode>,
pub(crate) lang: Option<LangKey>,
pub(crate) path: PathBuf,
pub(crate) resolution: LogoResolution,
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[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, Hash)]
#[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 {
Expand Down Expand Up @@ -128,3 +146,74 @@ impl ThemeConfig {
out
}
}

fn validate_logos(logos: &Vec<LogoDef>) -> Result<(), String> {
#[derive()]
enum LangLogo {
Universal(usize),
LangSpecific(HashMap<LangKey, usize>),
}

let all_modes = [LogoMode::Light, LogoMode::Dark];
let all_sizes = [LogoSize::Wide, LogoSize::Narrow];

let mut cases = HashMap::new();
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 {
let key = (mode, size);

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!(
"incomplete logo configuration: {mode} {size} logo is \
missing `lang = '*'` entry",
));
}
_ => {}
}
}
}

Ok(())
}
61 changes: 27 additions & 34 deletions backend/src/config/translated_string.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>);
#[derive(Serialize, Deserialize, Clone)]
#[serde(try_from = "HashMap<LangKey, String>")]
pub(crate) struct TranslatedString(HashMap<LangKey, String>);

impl TranslatedString {
pub(crate) const LANGUAGES: &'static [&'static str] = &["en", "de"];

pub(crate) fn en(&self) -> &str {
&self.0["en"]
pub(crate) fn default(&self) -> &str {
&self.0[&LangKey::Default]
}
}

impl<'de> Deserialize<'de> for TranslatedString {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;

let map = <HashMap<String, String>>::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<HashMap<LangKey, String>> 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<LangKey, String>) -> Result<Self, Self::Error> {
if !map.contains_key(&LangKey::Default) {
return Err(anyhow!("Translated string must include 'default' entry."));
}

Ok(Self(map))
Expand All @@ -54,3 +32,18 @@ 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 {
#[serde(alias = "*")]
Default,
En,
De,
}

impl fmt::Display for LangKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.serialize(f)
}
}
Loading
Loading