Skip to content

Commit

Permalink
Allow configuring logos for multiple languages (#1292)
Browse files Browse the repository at this point in the history
This will make it possible to configure logos for any number of
languages (though for now, Tobira only knows what to do with english,
german and non-language-specific logos).

This is a breaking change, as it changes the way logo files are
configured. Previous configurations won't work anymore.
Specifics and examples for the new configuration will be available in
our docs.

Closes #1271
  • Loading branch information
LukasKalbertodt authored Jan 14, 2025
2 parents 6aae278 + 709fdd9 commit 3b99648
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 222 deletions.
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

0 comments on commit 3b99648

Please sign in to comment.