pib-viewer 0.8.0

A viewer for public governmental data served over OParl
// SPDX-FileCopyrightText: Politik im Blick developers
// SPDX-FileCopyrightText: Wolfgang Silbermayr <wolfgang@silbermayr.at>
//
// SPDX-License-Identifier: AGPL-3.0-or-later OR EUPL-1.2

mod client;
mod components;
mod route;
mod views;

use dioxus::{
    core::Element,
    document,
    prelude::{component, dioxus_elements, rsx},
};

use dioxus_i18n::{
    prelude::{I18n, I18nConfig, Locale, use_init_i18n},
    t,
    unic_langid::langid,
};
use dioxus_router::Router;
use dioxus_signals::{GlobalSignal, ReadableExt as _, Signal};
use fluent_langneg::{NegotiationStrategy, convert_vec_str_to_langids, negotiate_languages};
use manganis::{Asset, asset};
use tracing::{info, warn};
use url::Url;

use crate::{
    client::Config,
    components::{Banner, Footer},
    route::Route,
};

const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/styling/main.css");

static CONFIG: GlobalSignal<ConfigState> = Signal::global(|| ConfigState::Initial);

fn main() {
    dioxus::launch(App);
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ConfigState {
    Initial,
    Loading,
    Failed { message: String },
    NoConfigPresent,
    ConfigLoaded { config: Config },
}

#[component]
fn App() -> Element {
    let mut i18n = use_init_i18n(|| {
        I18nConfig::new(langid!("en-US"))
            .with_fallback(langid!("en-US"))
            .with_locale(Locale::new_static(
                langid!("en-US"),
                include_str!("../i18n/en-US.ftl"),
            ))
            .with_locale(Locale::new_static(
                langid!("de-DE"),
                include_str!("../i18n/de-DE.ftl"),
            ))
    });

    let initialize = move |_| async move {
        initialize_i18n(&mut i18n).await;
        initialize_config(&CONFIG).await;
    };

    rsx! {
        document::Link { rel: "icon", href: FAVICON }
        document::Link { rel: "stylesheet", href: MAIN_CSS }

        div {
            onmounted: initialize,

            match &*CONFIG.read_unchecked() {
                ConfigState::ConfigLoaded { config } => rsx! {
                    for banner in config.banner.iter().flatten() {
                        Banner { message: banner.message.to_string() }
                    }
                    Router::<Route> {}
                    if let Some(footer) = &config.footer {
                        Footer {
                            links: footer.links.iter().flatten().cloned().collect()
                        }
                    }
                },
                ConfigState::NoConfigPresent => rsx! {
                    { t!("config-not-present") }
                },
                ConfigState::Failed{message} => rsx! {
                    { t!("config-loading-failed", message: message.to_string()) }
                },
                ConfigState::Loading | ConfigState::Initial=>  rsx! {
                    { t!("config-loading") }
                }
            }
        },
    }
}

async fn initialize_i18n(i18n: &mut I18n) {
    let Ok(languages) = document::eval("return navigator.languages;").await else {
        warn!("couldn't load user language.");
        return;
    };

    let serde_json::Value::Array(languages) = languages else {
        warn!("not an array value: {languages}");
        return;
    };

    let requested = match convert_vec_str_to_langids(
        languages
            .into_iter()
            .filter_map(|s| s.as_str().map(str::to_string)),
    ) {
        Ok(l) => l,
        Err(e) => {
            warn!("Error reading requested languages: {e}");
            return;
        }
    };
    let available = match convert_vec_str_to_langids(["en-US", "de-DE"]) {
        Ok(l) => l,
        Err(e) => {
            warn!("Error reading available languages: {e}");
            return;
        }
    };

    let languages = negotiate_languages(&requested, &available, None, NegotiationStrategy::Lookup);

    if let Some(language) = languages.into_iter().next() {
        let language = match language.to_string().parse() {
            Ok(l) => l,
            Err(e) => {
                warn!("Error converting language identifier: {e}");
                return;
            }
        };
        i18n.set_language(language);
    }
}

async fn initialize_config(state: &GlobalSignal<ConfigState>) {
    if !matches!(&*state.read(), ConfigState::Initial) {
        return;
    }
    *state.write() = ConfigState::Loading;

    let Ok(base_url) = document::eval("return document.URL;").await else {
        warn!("couldn't load base url.");
        *state.write() = ConfigState::Failed {
            message: "couldn't load base url.".to_string(),
        };
        return;
    };
    let serde_json::Value::String(base_url) = base_url else {
        warn!("not a string value: {base_url}");
        *state.write() = ConfigState::Failed {
            message: format!("not a string value: {base_url}"),
        };
        return;
    };
    info!("base url: {base_url:?}");
    let base_url = match base_url.parse::<Url>() {
        Ok(url) => url,
        Err(e) => {
            warn!("Couldn't parse base url {base_url}, reason: {e}");
            *state.write() = ConfigState::Failed {
                message: format!("Couldn't parse base url {base_url}, reason: {e}"),
            };
            return;
        }
    };
    match crate::client::get_page_config(base_url).await {
        Ok(Some(config)) => {
            *state.write() = ConfigState::ConfigLoaded { config };
        }
        Ok(None) => {
            *state.write() = ConfigState::NoConfigPresent;
        }
        Err(e) => {
            *state.write() = ConfigState::Failed {
                message: format!("Failed to load config: {e}"),
            };
        }
    }
}