diff --git a/definy-client/src/lib.rs b/definy-client/src/lib.rs index 60ad3b94..75825666 100644 --- a/definy-client/src/lib.rs +++ b/definy-client/src/lib.rs @@ -97,7 +97,7 @@ impl narumincho_vdom_client::App for DefinyApp { .unwrap(); on_keydown.forget(); - let filter_query = { + let query_string = { let initial_url = web_sys::window() .unwrap() .document() @@ -109,7 +109,43 @@ impl narumincho_vdom_client::App for DefinyApp { search.strip_prefix('?').unwrap_or(search.as_str()).to_string() }; - let filter_for_fetch = definy_ui::event_filter_from_query_str(&filter_query); + let query_params = definy_ui::query::parse_query(Some(query_string.as_str())); + let filter_for_fetch = query_params.event_type; + let html_lang = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.document_element()) + .and_then(|element| element.get_attribute("lang")); + let fallback_language = html_lang + .as_deref() + .and_then(definy_ui::language::language_from_tag) + .or_else(definy_ui::language::best_language_from_browser) + .unwrap_or_else(definy_ui::language::default_language); + let language_resolution = if let Some(requested_lang) = query_params.lang { + if let Some(language) = definy_ui::language::language_from_tag(requested_lang.as_str()) { + definy_ui::language::LanguageResolution { + language, + unsupported_query_lang: None, + } + } else { + definy_ui::language::LanguageResolution { + language: fallback_language, + unsupported_query_lang: Some(requested_lang), + } + } + } else { + definy_ui::language::LanguageResolution { + language: fallback_language, + unsupported_query_lang: None, + } + }; + let language_fallback_notice = + language_resolution + .unsupported_query_lang + .as_ref() + .map(|requested| definy_ui::LanguageFallbackNotice { + requested: requested.to_string(), + fallback_to_code: language_resolution.language.code, + }); wasm_bindgen_futures::spawn_local(async move { if let Some(ssr_event_binaries) = ssr_event_binaries { let event_pairs = ssr_event_binaries @@ -248,7 +284,9 @@ impl narumincho_vdom_client::App for DefinyApp { is_loading, has_more, None, - definy_ui::event_filter_from_query_str(&filter_query), + filter_for_fetch, + language_resolution.language, + language_fallback_notice, ) } @@ -259,10 +297,35 @@ impl narumincho_vdom_client::App for DefinyApp { let location = definy_ui::Location::from_url(&pathname); let search = web_url.search(); let query = search.strip_prefix('?').unwrap_or(search.as_str()); - let filter_event_type = definy_ui::event_filter_from_query_str(query); + let query_params = definy_ui::query::parse_query(Some(query)); + let filter_event_type = query_params.event_type; + let requested_lang = query_params.lang.clone(); + let parsed_language = requested_lang + .as_deref() + .and_then(definy_ui::language::language_from_tag); + let (language, language_fallback_notice) = if let Some(requested_lang) = requested_lang + { + if let Some(parsed_language) = parsed_language { + (parsed_language, None) + } else { + let fallback_language = definy_ui::language::best_language_from_browser() + .unwrap_or_else(definy_ui::language::default_language); + ( + fallback_language, + Some(definy_ui::LanguageFallbackNotice { + requested: requested_lang, + fallback_to_code: fallback_language.code, + }), + ) + } + } else { + (state.language, None) + }; let mut next = AppState { location, event_detail_eval_result: None, + language, + language_fallback_notice, ..state }; if matches!(next.location, Some(definy_ui::Location::Home)) { @@ -277,6 +340,24 @@ impl narumincho_vdom_client::App for DefinyApp { }; } } + if query_params.lang.is_none() { + if let Some(location) = &next.location { + let url = AppState::build_url( + location, + next.language.code, + filter_event_type, + ); + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let _ = history.replace_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(url.as_str()), + ); + } + } + } + } return next; } state diff --git a/definy-server/src/main.rs b/definy-server/src/main.rs index 71252af5..11579cf0 100644 --- a/definy-server/src/main.rs +++ b/definy-server/src/main.rs @@ -130,9 +130,36 @@ async fn handler( .is_some_and(|value| value.contains("text/html")); if accepts_html { + if let Some(redirect_url) = lang_redirect_url(&request) { + return Response::builder() + .status(302) + .header("Location", redirect_url) + .body(Full::new(Bytes::from("Redirecting..."))); + } + let accept_language = request + .headers() + .get("accept-language") + .and_then(|value| value.to_str().ok()); + let language_resolution = definy_ui::language::resolve_language(uri.query(), accept_language); + let language_fallback_notice = + language_resolution + .unsupported_query_lang + .as_ref() + .map(|requested| definy_ui::LanguageFallbackNotice { + requested: requested.to_string(), + fallback_to_code: language_resolution.language.code, + }); let pool = state.pool.read().await.clone(); return match pool { - Some(pool) => handle_html(&uri, &pool).await, + Some(pool) => { + handle_html( + &uri, + &pool, + language_resolution.language, + language_fallback_notice, + ) + .await + } None => db_unavailable_response(true), }; } @@ -198,6 +225,8 @@ fn db_unavailable_response(wants_html: bool) -> Result>, hy async fn handle_html( uri: &hyper::Uri, pool: &sqlx::postgres::PgPool, + language: definy_ui::language::Language, + language_fallback_notice: Option, ) -> Result>, hyper::http::Error> { let path = uri.path(); let query = uri.query(); @@ -254,6 +283,8 @@ async fn handle_html( has_more, None, filter_event_type, + language, + language_fallback_notice, ), &Some(definy_ui::ResourceHash { js: JAVASCRIPT_HASH.to_string(), @@ -263,3 +294,29 @@ async fn handle_html( ), )))) } + +fn lang_redirect_url(request: &Request) -> Option { + if definy_ui::query::parse_query(request.uri().query()) + .lang + .is_some() + { + return None; + } + let accept_language = request + .headers() + .get("accept-language") + .and_then(|value| value.to_str().ok()); + let best = definy_ui::language::best_language_from_accept_language(accept_language); + Some(build_url_with_lang(request.uri(), best.code)) +} + +fn build_url_with_lang(uri: &hyper::Uri, lang_code: &str) -> String { + let mut params = definy_ui::query::parse_query(uri.query()); + params.lang = Some(lang_code.to_string()); + let mut url = uri.path().to_string(); + if let Some(query) = definy_ui::query::build_query(params) { + url.push('?'); + url.push_str(query.as_str()); + } + url +} diff --git a/definy-ui/src/account_detail.rs b/definy-ui/src/account_detail.rs index 6d16458a..584bb953 100644 --- a/definy-ui/src/account_detail.rs +++ b/definy-ui/src/account_detail.rs @@ -1,6 +1,7 @@ use narumincho_vdom::*; use crate::{AppState, Location, fetch}; +use crate::i18n; pub fn account_detail_view( state: &AppState, @@ -36,7 +37,12 @@ pub fn account_detail_view( .children([ Div::new() .style(Style::new().set("font-weight", "600")) - .children([text("Change account name")]) + .children([text(i18n::tr( + state, + "Change account name", + "アカウント名を変更", + "Ŝanĝi kontonomon", + ))]) .into_node(), Input::new() .type_("text") @@ -168,7 +174,7 @@ pub fn account_detail_view( state })); })) - .children([text("Change Name")]) + .children([text(i18n::tr(state, "Change Name", "名前を変更", "Ŝanĝi nomon"))]) .into_node(), ]) .into_node(), @@ -183,7 +189,7 @@ pub fn account_detail_view( .children([ A::::new() .class("back-link") - .href(Href::Internal(Location::AccountList)) + .href(state.href_with_lang(Location::AccountList)) .style( Style::new() .set("display", "inline-flex") @@ -192,7 +198,12 @@ pub fn account_detail_view( .set("color", "var(--primary)") .set("font-weight", "500"), ) - .children([text("← Back to Accounts")]) + .children([text(i18n::tr( + state, + "← Back to Accounts", + "← アカウント一覧へ戻る", + "← Reen al kontoj", + ))]) .into_node(), Div::new() .class("event-detail-card") @@ -219,7 +230,11 @@ pub fn account_detail_view( .into_node(), Div::new() .style(Style::new().set("color", "var(--text-secondary)")) - .children([text(format!("{} events", account_events.len()))]) + .children([text(format!( + "{} {}", + account_events.len(), + i18n::tr(state, "events", "イベント", "eventoj") + ))]) .into_node(), ]) .into_node(), @@ -236,7 +251,12 @@ pub fn account_detail_view( .set("padding", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("This account has not posted any events yet.")]) + .children([text(i18n::tr( + state, + "This account has not posted any events yet.", + "このアカウントはまだイベントを投稿していません。", + "Ĉi tiu konto ankoraŭ ne afiŝis eventojn.", + ))]) .into_node() } else { Div::new() @@ -248,7 +268,7 @@ pub fn account_detail_view( .map(|(hash, event)| { A::::new() .class("event-card") - .href(Href::Internal(Location::Event(hash))) + .href(state.href_with_lang(Location::Event(hash))) .style( Style::new() .set("display", "grid") @@ -268,7 +288,7 @@ pub fn account_detail_view( .into_node(), Div::new() .children([text( - crate::event_presenter::event_summary_text(event), + crate::event_presenter::event_summary_text(state, event), )]) .into_node(), ]) diff --git a/definy-ui/src/account_list.rs b/definy-ui/src/account_list.rs index d31189c7..686ff128 100644 --- a/definy-ui/src/account_list.rs +++ b/definy-ui/src/account_list.rs @@ -1,6 +1,7 @@ use narumincho_vdom::*; use crate::{AppState, Location}; +use crate::i18n; struct AccountRow { account_id: definy_event::event::AccountId, @@ -19,13 +20,18 @@ pub fn account_list_view(state: &AppState) -> Node { .children([ H2::new() .style(Style::new().set("font-size", "1.3rem")) - .children([text("Accounts")]) + .children([text(i18n::tr(state, "Accounts", "アカウント", "Kontoj"))]) .into_node(), if rows.is_empty() { Div::new() .class("event-detail-card") .style(Style::new().set("padding", "0.9rem")) - .children([text("No accounts yet.")]) + .children([text(i18n::tr( + state, + "No accounts yet.", + "まだアカウントがありません。", + "Ankoraŭ neniuj kontoj.", + ))]) .into_node() } else { Div::new() @@ -42,7 +48,7 @@ pub fn account_list_view(state: &AppState) -> Node { ); A::::new() .class("event-card") - .href(Href::Internal(Location::Account(row.account_id))) + .href(state.href_with_lang(Location::Account(row.account_id))) .style( Style::new() .set("display", "grid") @@ -60,7 +66,11 @@ pub fn account_list_view(state: &AppState) -> Node { .set("font-size", "0.85rem") .set("color", "var(--text-secondary)"), ) - .children([text(format!("{} events", row.event_count))]) + .children([text(format!( + "{} {}", + row.event_count, + i18n::tr(state, "events", "イベント", "eventoj") + ))]) .into_node(), Div::new() .style( @@ -69,7 +79,8 @@ pub fn account_list_view(state: &AppState) -> Node { .set("color", "var(--text-secondary)"), ) .children([text(format!( - "latest: {}", + "{} {}", + i18n::tr(state, "latest:", "最新:", "lasta:"), row.latest_time.format("%Y-%m-%d %H:%M:%S") ))]) .into_node(), diff --git a/definy-ui/src/app_state.rs b/definy-ui/src/app_state.rs index 5cc24ace..e3a57cc3 100644 --- a/definy-ui/src/app_state.rs +++ b/definy-ui/src/app_state.rs @@ -1,4 +1,5 @@ -use definy_event::event::AccountId; +use definy_event::event::{AccountId, EventType}; +use narumincho_vdom::Route; #[derive(Clone, PartialEq, Eq, Debug)] pub enum PathStep { @@ -105,6 +106,14 @@ pub struct AppState { pub focused_path: Option>, pub active_dropdown_name: Option, pub dropdown_search_query: String, + pub language: crate::language::Language, + pub language_fallback_notice: Option, +} + +#[derive(Clone)] +pub struct LanguageFallbackNotice { + pub requested: String, + pub fallback_to_code: &'static str, } #[derive(Clone)] @@ -230,6 +239,8 @@ pub fn build_initial_state( event_list_has_more: bool, current_key: Option, filter_event_type: Option, + language: crate::language::Language, + language_fallback_notice: Option, ) -> AppState { let mut event_cache = HashMap::new(); let mut event_hashes = Vec::new(); @@ -299,6 +310,43 @@ pub fn build_initial_state( focused_path: None, active_dropdown_name: None, dropdown_search_query: String::new(), + language, + language_fallback_notice, + } +} + +impl AppState { + pub fn build_url(location: &Location, lang_code: &str, event_type: Option) -> String { + let mut url = location.to_url(); + let query = crate::query::build_query(crate::query::QueryParams { + lang: Some(lang_code.to_string()), + event_type: if matches!(location, Location::Home) { + event_type + } else { + None + }, + }); + if let Some(query) = query { + url.push('?'); + url.push_str(query.as_str()); + } + url + } + + pub fn url_with_lang(&self, location: &Location) -> String { + AppState::build_url( + location, + self.language.code, + self.event_list_state.filter_event_type, + ) + } + + pub fn home_url_with_lang(&self, event_type: Option) -> String { + AppState::build_url(&Location::Home, self.language.code, event_type) + } + + pub fn href_with_lang(&self, location: Location) -> narumincho_vdom::Href { + narumincho_vdom::Href::External(self.url_with_lang(&location)) } } diff --git a/definy-ui/src/dropdown.rs b/definy-ui/src/dropdown.rs index ef40977f..eb19ab81 100644 --- a/definy-ui/src/dropdown.rs +++ b/definy-ui/src/dropdown.rs @@ -1,4 +1,5 @@ use crate::AppState; +use crate::i18n; use narumincho_vdom::*; use std::rc::Rc; use wasm_bindgen::JsCast; @@ -16,7 +17,7 @@ pub fn searchable_dropdown( .iter() .find(|(val, _)| val == current_value) .map(|(_, label)| label.clone()) - .unwrap_or_else(|| "選択...".to_string()); + .unwrap_or_else(|| i18n::tr(state, "Select...", "選択...", "Elektu...").to_string()); let container = Div::new().style( Style::new() @@ -148,7 +149,10 @@ pub fn searchable_dropdown( ); search_input .attributes - .push(("placeholder".to_string(), "検索...".to_string())); + .push(( + "placeholder".to_string(), + i18n::tr(state, "Search...", "検索...", "Serĉi...").to_string(), + )); search_input.events.push(( "input".to_string(), EventHandler::new(move |set_state| { diff --git a/definy-ui/src/event_detail.rs b/definy-ui/src/event_detail.rs index 833d9254..ce2403e2 100644 --- a/definy-ui/src/event_detail.rs +++ b/definy-ui/src/event_detail.rs @@ -3,6 +3,7 @@ use narumincho_vdom::*; use crate::Location; use crate::app_state::AppState; +use crate::i18n; use crate::expression_eval::{evaluate_expression, expression_to_source}; fn part_type_text(part_type: &definy_event::event::PartType) -> String { @@ -48,7 +49,12 @@ pub fn event_detail_view(state: &AppState, target_hash: &[u8; 32]) -> Node Node::new() .class("back-link") - .href(Href::Internal(Location::Home)) + .href(state.href_with_lang(Location::Home)) .style( Style::new() .set("display", "inline-flex") @@ -68,7 +74,12 @@ pub fn event_detail_view(state: &AppState, target_hash: &[u8; 32]) -> Node::new() - .href(Href::Internal(Location::Account(event.account_id.clone()))) + .href(state.href_with_lang(Location::Account(event.account_id.clone()))) .style( Style::new() .set("width", "fit-content") @@ -144,7 +155,12 @@ fn render_event_detail( .set("font-weight", "600"), ) .children([ - text("Account created: "), + text(i18n::tr( + state, + "Account created:", + "アカウント作成:", + "Konto kreita:", + )), text(create_account_event.account_name.as_ref()), ]) .into_node(), @@ -156,7 +172,12 @@ fn render_event_detail( .set("font-weight", "600"), ) .children([ - text("Profile changed: "), + text(i18n::tr( + state, + "Profile changed:", + "プロフィール変更:", + "Profilo ŝanĝita:", + )), text(change_profile_event.account_name.as_ref()), ]) .into_node(), @@ -196,7 +217,11 @@ fn render_event_detail( set_state(Box::new(move |state: AppState| { let events_vec: Vec<_> = state.event_cache.iter().map(|(h, e)| (*h, e.clone())).collect(); let eval_result = - evaluate_message_result(&expression, &events_vec); + evaluate_message_result( + state.language.code, + &expression, + &events_vec, + ); AppState { event_detail_eval_result: Some(eval_result), ..state.clone() @@ -205,11 +230,11 @@ fn render_event_detail( } })) .style(Style::new().set("margin-top", "0.65rem")) - .children([text("Evaluate")]) + .children([text(i18n::tr(state, "Evaluate", "評価", "Taksi"))]) .into_node() }, A::::new() - .href(Href::Internal(Location::Part(*hash))) + .href(state.href_with_lang(Location::Part(*hash))) .style( Style::new() .set("margin-top", "0.45rem") @@ -217,7 +242,12 @@ fn render_event_detail( .set("color", "var(--primary)") .set("text-decoration", "none"), ) - .children([text("Open part detail")]) + .children([text(i18n::tr( + state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), match &state.event_detail_eval_result { Some(result) => Div::new() @@ -245,7 +275,8 @@ fn render_event_detail( Div::new() .style(Style::new().set("font-size", "1.08rem")) .children([text(format!( - "Part updated: {}", + "{} {}", + i18n::tr(state, "Part updated:", "パーツ更新:", "Parto ĝisdatigita:"), part_update_event.part_name ))]) .into_node(), @@ -270,7 +301,8 @@ fn render_event_detail( .set("opacity", "0.85"), ) .children([text(format!( - "expression: {}", + "{} {}", + i18n::tr(state, "expression:", "式:", "esprimo:"), expression_to_source(&part_update_event.expression) ))]) .into_node(), @@ -282,23 +314,39 @@ fn render_event_detail( .set("opacity", "0.85"), ) .children([text(format!( - "partDefinitionEventHash: {}", + "{} {}", + i18n::tr( + state, + "partDefinitionEventHash:", + "partDefinitionEventHash:", + "partDefinitionEventHash:" + ), crate::hash_format::encode_hash32( &part_update_event.part_definition_event_hash, ) ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( part_update_event.part_definition_event_hash, ))) - .children([text("Open definition event")]) + .children([text(i18n::tr( + state, + "Open definition event", + "定義イベントを開く", + "Malfermi difinan eventon", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Part( + .href(state.href_with_lang(Location::Part( part_update_event.part_definition_event_hash, ))) - .children([text("Open part detail")]) + .children([text(i18n::tr( + state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), ]) .into_node(), @@ -310,7 +358,8 @@ fn render_event_detail( ) .children([ text(format!( - "Module created: {}", + "{} {}", + i18n::tr(state, "Module created:", "モジュール作成:", "Modulo kreita:"), module_definition_event.module_name )), if module_definition_event.description.is_empty() { @@ -327,8 +376,13 @@ fn render_event_detail( .into_node() }, A::::new() - .href(Href::Internal(Location::Module(*hash))) - .children([text("Open module detail")]) + .href(state.href_with_lang(Location::Module(*hash))) + .children([text(i18n::tr( + state, + "Open module detail", + "モジュール詳細を開く", + "Malfermi modulajn detalojn", + ))]) .into_node(), ]) .into_node(), @@ -343,7 +397,8 @@ fn render_event_detail( Div::new() .style(Style::new().set("font-size", "1.08rem")) .children([text(format!( - "Module updated: {}", + "{} {}", + i18n::tr(state, "Module updated:", "モジュール更新:", "Modulo ĝisdatigita:"), module_update_event.module_name ))]) .into_node(), @@ -368,23 +423,39 @@ fn render_event_detail( .set("opacity", "0.85"), ) .children([text(format!( - "moduleDefinitionEventHash: {}", + "{} {}", + i18n::tr( + state, + "moduleDefinitionEventHash:", + "moduleDefinitionEventHash:", + "moduleDefinitionEventHash:" + ), crate::hash_format::encode_hash32( &module_update_event.module_definition_event_hash, ) ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( module_update_event.module_definition_event_hash, ))) - .children([text("Open definition event")]) + .children([text(i18n::tr( + state, + "Open definition event", + "定義イベントを開く", + "Malfermi difinan eventon", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Module( + .href(state.href_with_lang(Location::Module( module_update_event.module_definition_event_hash, ))) - .children([text("Open module detail")]) + .children([text(i18n::tr( + state, + "Open module detail", + "モジュール詳細を開く", + "Malfermi modulajn detalojn", + ))]) .into_node(), ]) .into_node(), @@ -405,7 +476,12 @@ fn render_event_detail( .set("opacity", "0.6"), ) .children([ - text("Event Hash: "), + text(i18n::tr( + state, + "Event Hash: ", + "イベントハッシュ: ", + "Evento-hako: ", + )), text(&crate::hash_format::encode_hash32(hash)), ]) .into_node(), @@ -431,7 +507,12 @@ fn related_part_events_section( .children([ Div::new() .style(Style::new().set("font-weight", "600")) - .children([text("Events linked by partDefinitionEventHash")]) + .children([text(i18n::tr( + state, + "Events linked by partDefinitionEventHash", + "partDefinitionEventHash に紐づくイベント", + "Eventoj ligitaj per partDefinitionEventHash", + ))]) .into_node(), Div::new() .class("mono") @@ -449,9 +530,9 @@ fn related_part_events_section( related_events .into_iter() .map(|(event_hash, event)| { - let label = crate::event_presenter::event_kind_label(&event); + let label = crate::event_presenter::event_kind_label(state, &event); A::::new() - .href(Href::Internal(Location::Event(event_hash))) + .href(state.href_with_lang(Location::Event(event_hash))) .style( Style::new() .set("display", "grid") @@ -518,6 +599,7 @@ fn root_part_definition_hash(current_hash: &[u8; 32], content: &EventContent) -> } fn evaluate_message_result( + lang_code: &str, expression: &definy_event::event::Expression, events: &[( [u8; 32], @@ -528,8 +610,16 @@ fn evaluate_message_result( )], ) -> String { match evaluate_expression(expression, events) { - Ok(value) => format!("Result: {}", value), - Err(error) => format!("Error: {}", error), + Ok(value) => format!( + "{} {}", + i18n::tr_lang(lang_code, "Result:", "結果:", "Rezulto:"), + value + ), + Err(error) => format!( + "{} {}", + i18n::tr_lang(lang_code, "Error:", "エラー:", "Eraro:"), + error + ), } } @@ -547,6 +637,9 @@ mod tests { definy_event::event::NumberExpression { value: 32 }, )), }); - assert_eq!(evaluate_message_result(&expression, &[]), "Result: 42"); + assert_eq!( + evaluate_message_result("en", &expression, &[]), + "Result: 42" + ); } } diff --git a/definy-ui/src/event_filter.rs b/definy-ui/src/event_filter.rs index bd50f17d..1feb2b35 100644 --- a/definy-ui/src/event_filter.rs +++ b/definy-ui/src/event_filter.rs @@ -1,28 +1,16 @@ use definy_event::event::EventType; -#[derive(serde::Serialize, serde::Deserialize)] -struct EventFilterQuery { - event_type: Option, -} - pub fn event_filter_from_query(query: Option<&str>) -> Option { event_filter_from_query_str(query.unwrap_or("")) } pub fn event_filter_from_query_str(query: &str) -> Option { - if query.is_empty() { - return None; - } - serde_urlencoded::from_str::(query) - .ok() - .and_then(|parsed| parsed.event_type) + crate::query::parse_query(Some(query)).event_type } pub fn event_filter_query_string(event_type: Option) -> Option { - event_type.and_then(|event_type| { - serde_urlencoded::to_string(EventFilterQuery { - event_type: Some(event_type), - }) - .ok() + crate::query::build_query(crate::query::QueryParams { + lang: None, + event_type, }) } diff --git a/definy-ui/src/event_list.rs b/definy-ui/src/event_list.rs index 3a86369d..13a2de0a 100644 --- a/definy-ui/src/event_list.rs +++ b/definy-ui/src/event_list.rs @@ -6,13 +6,18 @@ use crate::expression_editor::{EditorTarget, render_root_expression_editor}; use crate::expression_eval::{evaluate_expression, expression_to_source}; use crate::module_projection::collect_module_snapshots; use crate::part_projection::collect_part_snapshots; +use crate::i18n; -fn update_event_filter_url(event_type: Option) { - let query = crate::event_filter_query_string(event_type); - let new_url = match query { - Some(query) => format!("/?{}", query), - None => "/".to_string(), - }; +fn update_event_filter_url(event_type: Option, lang_code: &str) { + let query = crate::query::build_query(crate::query::QueryParams { + lang: Some(lang_code.to_string()), + event_type, + }); + let mut new_url = "/".to_string(); + if let Some(query) = query { + new_url.push('?'); + new_url.push_str(query.as_str()); + } if let Some(window) = web_sys::window() { if let Ok(history) = window.history() { let _ = history.push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url)); @@ -49,13 +54,31 @@ pub fn event_list_view(state: &AppState) -> Node { let _page_size = state.event_list_state.page_size; let filter_options = vec![ - ("".to_string(), "All Events".to_string()), - ("create_account".to_string(), "Create Account".to_string()), - ("change_profile".to_string(), "Change Profile".to_string()), - ("part_definition".to_string(), "Part Definition".to_string()), - ("part_update".to_string(), "Part Update".to_string()), - ("module_definition".to_string(), "Module Definition".to_string()), - ("module_update".to_string(), "Module Update".to_string()), + ("".to_string(), i18n::tr(&state, "All Events", "すべてのイベント", "Ĉiuj eventoj").to_string()), + ( + "create_account".to_string(), + i18n::tr(&state, "Create Account", "アカウント作成", "Krei konton").to_string(), + ), + ( + "change_profile".to_string(), + i18n::tr(&state, "Change Profile", "プロフィール変更", "Ŝanĝi profilon").to_string(), + ), + ( + "part_definition".to_string(), + i18n::tr(&state, "Part Definition", "パーツ定義", "Parto-difino").to_string(), + ), + ( + "part_update".to_string(), + i18n::tr(&state, "Part Update", "パーツ更新", "Parto-ĝisdatigo").to_string(), + ), + ( + "module_definition".to_string(), + i18n::tr(&state, "Module Definition", "モジュール定義", "Modulo-difino").to_string(), + ), + ( + "module_update".to_string(), + i18n::tr(&state, "Module Update", "モジュール更新", "Modulo-ĝisdatigo").to_string(), + ), ]; let current_filter = state.event_list_state.filter_event_type @@ -79,7 +102,7 @@ pub fn event_list_view(state: &AppState) -> Node { "module_update" => Some(EventType::ModuleUpdate), _ => None, }; - update_event_filter_url(event_type); + update_event_filter_url(event_type, state.language.code); // Reset list and load first page with new filter let mut next = state.clone(); next.event_list_state = crate::EventListState { @@ -118,7 +141,12 @@ pub fn event_list_view(state: &AppState) -> Node { part_description_input(&state), Div::new() .style(Style::new().set("color", "var(--text-secondary)").set("font-size", "0.84rem")) - .children([text("Expression Builder")]) + .children([text(i18n::tr( + &state, + "Expression Builder", + "式ビルダー", + "Esprimo-konstruilo", + ))]) .into_node(), render_root_expression_editor( &state, @@ -134,7 +162,8 @@ pub fn event_list_view(state: &AppState) -> Node { .set("opacity", "0.85"), ) .children([text(format!( - "Current: {}", + "{} {}", + i18n::tr(&state, "Current:", "現在:", "Nuna:"), expression_to_source(&state.part_definition_form.composing_expression) ))]) .into_node(), @@ -151,15 +180,23 @@ pub fn event_list_view(state: &AppState) -> Node { &events_vec, ) { - Ok(value) => format!("Result: {}", value), - Err(error) => format!("Error: {}", error), + Ok(value) => format!( + "{} {}", + i18n::tr(&state, "Result:", "結果:", "Rezulto:"), + value + ), + Err(error) => format!( + "{} {}", + i18n::tr(&state, "Error:", "エラー:", "Eraro:"), + error + ), }; let mut next = state.clone(); next.part_definition_form.eval_result = Some(result); next })); })) - .children([text("Evaluate")]) + .children([text(i18n::tr(&state, "Evaluate", "評価", "Taksi"))]) .into_node(), Button::new() .on_click(EventHandler::new(async |set_state| { @@ -185,7 +222,12 @@ pub fn event_list_view(state: &AppState) -> Node { if part_name.is_empty() { let mut next = state.clone(); next.part_definition_form.eval_result = - Some("Error: part name is required".to_string()); + Some(i18n::tr( + &state, + "Error: part name is required", + "エラー: パーツ名は必須です", + "Eraro: parto-nomo estas bezonata", + ).to_string()); return next; } let expression = @@ -273,13 +315,31 @@ pub fn event_list_view(state: &AppState) -> Node { next.part_definition_form.eval_result = Some(match status { crate::local_event::LocalEventStatus::Queued => { - "PartDefinition queued (offline)".to_string() + i18n::tr( + &state, + "PartDefinition queued (offline)", + "PartDefinition をキューに追加しました (オフライン)", + "PartDefinition envicigita (senkonekte)", + ) + .to_string() } crate::local_event::LocalEventStatus::Failed => { - "PartDefinition failed to send".to_string() + i18n::tr( + &state, + "PartDefinition failed to send", + "PartDefinition の送信に失敗しました", + "PartDefinition sendado malsukcesis", + ) + .to_string() } crate::local_event::LocalEventStatus::Sent => { - "PartDefinition posted".to_string() + i18n::tr( + &state, + "PartDefinition posted", + "PartDefinition を投稿しました", + "PartDefinition sendita", + ) + .to_string() } }); next @@ -307,7 +367,7 @@ pub fn event_list_view(state: &AppState) -> Node { next })); })) - .children([text("Send")]) + .children([text(i18n::tr(&state, "Send", "送信", "Sendi"))]) .into_node(), ]) .into_node(), @@ -354,7 +414,7 @@ pub fn event_list_view(state: &AppState) -> Node { .event_hashes .iter() .filter_map(|hash| state.event_cache.get(hash).map(|event| (hash, event))) - .map(|(hash, event)| event_view(hash, event, &account_name_map)) + .map(|(hash, event)| event_view(&state, hash, event, &account_name_map)) .collect::>>() }) .into_node(), @@ -363,14 +423,19 @@ pub fn event_list_view(state: &AppState) -> Node { children.push( Div::new() .style(Style::new().set("text-align", "center").set("padding", "1rem")) - .children([text("Loading events...")]) + .children([text(i18n::tr( + &state, + "Loading events...", + "イベントを読み込み中...", + "Ŝargado de eventoj...", + ))]) .into_node(), ); } else if state.event_list_state.has_more { let button_text = if state.event_list_state.event_hashes.is_empty() { - "Load Events" + i18n::tr(&state, "Load Events", "イベントを読み込む", "Ŝargi eventojn") } else { - "Load More Events" + i18n::tr(&state, "Load More Events", "さらに読み込む", "Ŝargi pliajn eventojn") }; children.push( Button::new() @@ -442,7 +507,12 @@ pub fn event_list_view(state: &AppState) -> Node { children.push( Div::new() .style(Style::new().set("text-align", "center").set("padding", "1rem").set("color", "var(--text-secondary)")) - .children([text("No events found. Click 'Load Events' to fetch.")]) + .children([text(i18n::tr( + &state, + "No events found. Click 'Load Events' to fetch.", + "イベントが見つかりません。'Load Events' をクリックして取得します。", + "Neniuj eventoj trovitaj. Klaku 'Load Events' por ŝargi.", + ))]) .into_node(), ); } @@ -452,6 +522,7 @@ pub fn event_list_view(state: &AppState) -> Node { } fn event_view( + state: &AppState, hash: &[u8; 32], event_result: &Result< (ed25519_dalek::Signature, definy_event::event::Event), @@ -475,9 +546,7 @@ fn event_view( .set("display", "grid") .set("gap", "0.75rem"), ) - .href(narumincho_vdom::Href::Internal(crate::Location::Event( - *hash, - ))) + .href(state.href_with_lang(crate::Location::Event(*hash))) .children([ Div::new() .style( @@ -505,14 +574,24 @@ fn event_view( EventContent::CreateAccount(create_account_event) => Div::new() .style(Style::new().set("color", "var(--primary)")) .children([ - text("Account created: "), + text(i18n::tr( + &state, + "Account created:", + "アカウント作成:", + "Konto kreita:", + )), text(create_account_event.account_name.as_ref()), ]) .into_node(), EventContent::ChangeProfile(change_profile_event) => Div::new() .style(Style::new().set("color", "var(--primary)")) .children([ - text("Profile changed: "), + text(i18n::tr( + &state, + "Profile changed:", + "プロフィール変更:", + "Profilo ŝanĝita:", + )), text(change_profile_event.account_name.as_ref()), ]) .into_node(), @@ -520,7 +599,7 @@ fn event_view( .style(Style::new().set("font-size", "0.98rem")) .children([ A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Account( + .href(state.href_with_lang(crate::Location::Account( event.account_id.clone(), ))) .style( @@ -558,14 +637,19 @@ fn event_view( .into_node() }, A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Part(*hash))) + .href(state.href_with_lang(crate::Location::Part(*hash))) .style( Style::new() .set("font-size", "0.82rem") .set("color", "var(--primary)") .set("text-decoration", "none"), ) - .children([text("Open part detail")]) + .children([text(i18n::tr( + &state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), ]) .into_node(), @@ -573,7 +657,7 @@ fn event_view( .style(Style::new().set("font-size", "1.05rem")) .children([ A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Account( + .href(state.href_with_lang(crate::Location::Account( event.account_id.clone(), ))) .style( @@ -591,7 +675,11 @@ fn event_view( ), )]) .into_node(), - text(format!("Part updated: {}", part_update_event.part_name)), + text(format!( + "{} {}", + i18n::tr(&state, "Part updated:", "パーツ更新:", "Parto ĝisdatigita:"), + part_update_event.part_name + )), Div::new() .class("mono") .style( @@ -600,7 +688,8 @@ fn event_view( .set("opacity", "0.85"), ) .children([text(format!( - "expression: {}", + "{} {}", + i18n::tr(&state, "expression:", "式:", "esprimo:"), expression_to_source(&part_update_event.expression) ))]) .into_node(), @@ -618,7 +707,7 @@ fn event_view( ))]) .into_node(), A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Part( + .href(state.href_with_lang(crate::Location::Part( part_update_event.part_definition_event_hash, ))) .style( @@ -627,7 +716,12 @@ fn event_view( .set("color", "var(--primary)") .set("text-decoration", "none"), ) - .children([text("Open part detail")]) + .children([text(i18n::tr( + &state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), ]) .into_node(), @@ -635,7 +729,7 @@ fn event_view( .style(Style::new().set("font-size", "1rem")) .children([ A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Account( + .href(state.href_with_lang(crate::Location::Account( event.account_id.clone(), ))) .style( @@ -654,7 +748,8 @@ fn event_view( )]) .into_node(), text(format!( - "Module created: {}", + "{} {}", + i18n::tr(&state, "Module created:", "モジュール作成:", "Modulo kreita:"), module_definition_event.module_name )), if module_definition_event.description.is_empty() { @@ -676,7 +771,7 @@ fn event_view( .style(Style::new().set("font-size", "1rem")) .children([ A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Account( + .href(state.href_with_lang(crate::Location::Account( event.account_id.clone(), ))) .style( @@ -694,7 +789,11 @@ fn event_view( ), )]) .into_node(), - text(format!("Module updated: {}", module_update_event.module_name)), + text(format!( + "{} {}", + i18n::tr(&state, "Module updated:", "モジュール更新:", "Modulo ĝisdatigita:"), + module_update_event.module_name + )), if module_update_event.module_description.is_empty() { Div::new().children([]).into_node() } else { @@ -724,7 +823,7 @@ fn event_view( ))]) .into_node(), A::::new() - .href(narumincho_vdom::Href::Internal(crate::Location::Event( + .href(state.href_with_lang(crate::Location::Event( module_update_event.module_definition_event_hash, ))) .style( @@ -733,7 +832,12 @@ fn event_view( .set("color", "var(--primary)") .set("text-decoration", "none"), ) - .children([text("Open module definition")]) + .children([text(i18n::tr( + &state, + "Open module definition", + "モジュール定義を開く", + "Malfermi modulo-difinon", + ))]) .into_node(), ]) .into_node(), @@ -750,7 +854,16 @@ fn event_view( .set("padding", "1rem") .set("color", "var(--error)"), ) - .children([text(&format!("イベントの読み込みに失敗しました: {:?}", e))]) + .children([text(&format!( + "{}: {:?}", + i18n::tr( + &state, + "Failed to load events", + "イベントの読み込みに失敗しました", + "Malsukcesis ŝargi eventojn", + ), + e + ))]) .into_node(), } } @@ -830,7 +943,7 @@ fn part_type_input(state: &AppState) -> Node { .set("font-size", "0.85rem") .set("color", "var(--text-secondary)"), ) - .children([text("Part Type")]) + .children([text(i18n::tr(&state, "Part Type", "パーツ型", "Parto-tipo"))]) .into_node(), render_part_type_editor(state, &state.part_definition_form.part_type_input, 0), ]) @@ -838,7 +951,10 @@ fn part_type_input(state: &AppState) -> Node { } fn module_selection_input(state: &AppState) -> Node { - let mut options = vec![("".to_string(), "No module".to_string())]; + let mut options = vec![( + "".to_string(), + i18n::tr(&state, "No module", "モジュールなし", "Neniu modulo").to_string(), + )]; options.extend(collect_module_snapshots(state).into_iter().map(|module| { ( crate::hash_format::encode_hash32(&module.definition_event_hash), @@ -876,7 +992,7 @@ fn module_selection_input(state: &AppState) -> Node { .set("font-size", "0.85rem") .set("color", "var(--text-secondary)"), ) - .children([text("Module")]) + .children([text(i18n::tr(&state, "Module", "モジュール", "Modulo"))]) .into_node(), dropdown, ]) @@ -893,15 +1009,33 @@ fn render_part_type_editor( let mut options = Vec::new(); if depth == 0 { - options.push(("none".to_string(), "None".to_string())); + options.push(( + "none".to_string(), + i18n::tr(&state, "None", "なし", "Neniu").to_string(), + )); } options.extend([ - ("number".to_string(), "Number".to_string()), - ("string".to_string(), "String".to_string()), - ("boolean".to_string(), "Boolean".to_string()), - ("type".to_string(), "Type".to_string()), - ("list".to_string(), "List<...>".to_string()), + ( + "number".to_string(), + i18n::tr(&state, "Number", "数値", "Nombro").to_string(), + ), + ( + "string".to_string(), + i18n::tr(&state, "String", "文字列", "Teksto").to_string(), + ), + ( + "boolean".to_string(), + i18n::tr(&state, "Boolean", "真偽値", "Bulea").to_string(), + ), + ( + "type".to_string(), + i18n::tr(&state, "Type", "型", "Tipo").to_string(), + ), + ( + "list".to_string(), + i18n::tr(&state, "List<...>", "リスト<...>", "Listo<...>").to_string(), + ), ]); options.extend( @@ -913,7 +1047,14 @@ fn render_part_type_editor( "type_part:{}", crate::hash_format::encode_hash32(&snapshot.definition_event_hash) ); - (value, format!("Type Part: {}", snapshot.part_name)) + ( + value, + format!( + "{} {}", + i18n::tr(&state, "Type Part:", "型パーツ:", "Tipo-parto:"), + snapshot.part_name + ), + ) }), ); @@ -955,7 +1096,7 @@ fn render_part_type_editor( .set("color", "var(--text-secondary)") .set("margin-bottom", "0.25rem"), ) - .children([text("Item Type")]) + .children([text(i18n::tr(&state, "Item Type", "要素型", "Ero-tipo"))]) .into_node(), render_part_type_editor(state, &Some(item_type.as_ref().clone()), depth + 1), ]) diff --git a/definy-ui/src/event_presenter.rs b/definy-ui/src/event_presenter.rs index 38c8f8b9..0be470fd 100644 --- a/definy-ui/src/event_presenter.rs +++ b/definy-ui/src/event_presenter.rs @@ -1,14 +1,24 @@ use definy_event::event::{Event, EventContent}; +use crate::app_state::AppState; +use crate::i18n; use crate::expression_eval::expression_to_source; -pub fn event_summary_text(event: &Event) -> String { +pub fn event_summary_text(state: &AppState, event: &Event) -> String { match &event.content { EventContent::CreateAccount(create_account_event) => { - format!("Account created: {}", create_account_event.account_name) + format!( + "{} {}", + i18n::tr(state, "Account created:", "アカウント作成:", "Konto kreita:"), + create_account_event.account_name + ) } EventContent::ChangeProfile(change_profile_event) => { - format!("Profile changed: {}", change_profile_event.account_name) + format!( + "{} {}", + i18n::tr(state, "Profile changed:", "プロフィール変更:", "Profilo ŝanĝita:"), + change_profile_event.account_name + ) } EventContent::PartDefinition(part_definition_event) => format!( "{} = {}{}", @@ -21,7 +31,8 @@ pub fn event_summary_text(event: &Event) -> String { } ), EventContent::PartUpdate(part_update_event) => format!( - "Part updated: {}{} | {}", + "{} {}{} | {}", + i18n::tr(state, "Part updated:", "パーツ更新:", "Parto ĝisdatigita:"), part_update_event.part_name, if part_update_event.part_description.is_empty() { String::new() @@ -32,40 +43,80 @@ pub fn event_summary_text(event: &Event) -> String { ), EventContent::ModuleDefinition(module_definition_event) => { if module_definition_event.description.is_empty() { - format!("Module created: {}", module_definition_event.module_name) + format!( + "{} {}", + i18n::tr(state, "Module created:", "モジュール作成:", "Modulo kreita:"), + module_definition_event.module_name + ) } else { format!( - "Module created: {} - {}", - module_definition_event.module_name, module_definition_event.description + "{} {} - {}", + i18n::tr(state, "Module created:", "モジュール作成:", "Modulo kreita:"), + module_definition_event.module_name, + module_definition_event.description ) } } EventContent::ModuleUpdate(module_update_event) => { if module_update_event.module_description.is_empty() { - format!("Module updated: {}", module_update_event.module_name) + format!( + "{} {}", + i18n::tr(state, "Module updated:", "モジュール更新:", "Modulo ĝisdatigita:"), + module_update_event.module_name + ) } else { format!( - "Module updated: {} - {}", - module_update_event.module_name, module_update_event.module_description + "{} {} - {}", + i18n::tr(state, "Module updated:", "モジュール更新:", "Modulo ĝisdatigita:"), + module_update_event.module_name, + module_update_event.module_description ) } } } } -pub fn event_kind_label(event: &Event) -> String { +pub fn event_kind_label(state: &AppState, event: &Event) -> String { match &event.content { - EventContent::CreateAccount(_) => "CreateAccount".to_string(), - EventContent::ChangeProfile(_) => "ChangeProfile".to_string(), + EventContent::CreateAccount(_) => i18n::tr( + state, + "CreateAccount", + "アカウント作成", + "Konto-kreo", + ) + .to_string(), + EventContent::ChangeProfile(_) => i18n::tr( + state, + "ChangeProfile", + "プロフィール変更", + "Profil-ŝanĝo", + ) + .to_string(), EventContent::PartDefinition(part_definition) => { - format!("PartDefinition: {}", part_definition.part_name) + format!( + "{} {}", + i18n::tr(state, "PartDefinition:", "パーツ定義:", "Parto-difino:"), + part_definition.part_name + ) } - EventContent::PartUpdate(part_update) => format!("PartUpdate: {}", part_update.part_name), + EventContent::PartUpdate(part_update) => format!( + "{} {}", + i18n::tr(state, "PartUpdate:", "パーツ更新:", "Parto-ĝisdatigo:"), + part_update.part_name + ), EventContent::ModuleDefinition(module_definition) => { - format!("ModuleDefinition: {}", module_definition.module_name) + format!( + "{} {}", + i18n::tr(state, "ModuleDefinition:", "モジュール定義:", "Modulo-difino:"), + module_definition.module_name + ) } EventContent::ModuleUpdate(module_update) => { - format!("ModuleUpdate: {}", module_update.module_name) + format!( + "{} {}", + i18n::tr(state, "ModuleUpdate:", "モジュール更新:", "Modulo-ĝisdatigo:"), + module_update.module_name + ) } } } diff --git a/definy-ui/src/expression_editor.rs b/definy-ui/src/expression_editor.rs index 4ad867a8..c7129d9b 100644 --- a/definy-ui/src/expression_editor.rs +++ b/definy-ui/src/expression_editor.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::Location; use crate::app_state::AppState; +use crate::i18n; use crate::part_projection::{PartSnapshot, collect_part_snapshots, find_part_snapshot}; #[derive(Clone, Copy)] @@ -158,7 +159,12 @@ fn render_expression_editor( .set("font-size", "0.8rem") .set("color", "var(--text-secondary)"), ) - .children([text("組み込み型")]) + .children([text(i18n::tr( + state, + "Built-in types", + "組み込み型", + "Enkonstruitaj tipoj", + ))]) .into_node(), ); } @@ -169,7 +175,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Item Type"), + text(i18n::tr(state, "Item Type", "要素型", "Ero-tipo")), render_expression_editor( state, type_list_expression.item_type.as_ref(), @@ -190,7 +196,7 @@ fn render_expression_editor( let mut grid_children = Vec::new(); grid_children.push( Div::new() - .children([text("Item")]) + .children([text(i18n::tr(state, "Item", "項目", "Ero"))]) .style( Style::new() .set("font-weight", "bold") @@ -277,7 +283,7 @@ fn render_expression_editor( .children(grid_children) .into_node(), ); - children.push(add_list_item_button(path.clone(), target)); + children.push(add_list_item_button(state, path.clone(), target)); } else { let mut list_children = list_expression .items @@ -309,7 +315,11 @@ fn render_expression_editor( .set("color", "var(--text-secondary)") .set("flex", "1"), ) - .children([text(format!("Item {}", index + 1))]) + .children([text(format!( + "{} {}", + i18n::tr(state, "Item", "項目", "Ero"), + index + 1 + ))]) .into_node(), remove_list_item_button(path.clone(), index, target), ]) @@ -328,7 +338,7 @@ fn render_expression_editor( .into_node() }) .collect::>>(); - list_children.push(add_list_item_button(path.clone(), target)); + list_children.push(add_list_item_button(state, path.clone(), target)); children.push( Div::new() .style( @@ -360,7 +370,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Left"), + text(i18n::tr(state, "Left", "左", "Maldekstre")), render_expression_editor( state, add_expression.left.as_ref(), @@ -376,7 +386,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Right"), + text(i18n::tr(state, "Right", "右", "Dekstre")), render_expression_editor( state, add_expression.right.as_ref(), @@ -394,7 +404,7 @@ fn render_expression_editor( ); } definy_event::event::Expression::Boolean(boolean_expression) => { - children.push(boolean_input(path, target, boolean_expression.value)); + children.push(boolean_input(state, path, target, boolean_expression.value)); } definy_event::event::Expression::If(if_expression) => { let mut cond_path = path.clone(); @@ -416,7 +426,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Condition"), + text(i18n::tr(state, "Condition", "条件", "Kondiĉo")), render_expression_editor( state, if_expression.condition.as_ref(), @@ -432,7 +442,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Then"), + text(i18n::tr(state, "Then", "なら", "Tiam")), render_expression_editor( state, if_expression.then_expr.as_ref(), @@ -448,7 +458,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Else"), + text(i18n::tr(state, "Else", "それ以外", "Alie")), render_expression_editor( state, if_expression.else_expr.as_ref(), @@ -483,7 +493,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Left"), + text(i18n::tr(state, "Left", "左", "Maldekstre")), render_expression_editor( state, equal_expression.left.as_ref(), @@ -499,7 +509,7 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Right"), + text(i18n::tr(state, "Right", "右", "Dekstre")), render_expression_editor( state, equal_expression.right.as_ref(), @@ -534,14 +544,14 @@ fn render_expression_editor( Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Let Name"), + text(i18n::tr(state, "Let Name", "変数名", "Nomo")), let_name_input(path.clone(), target, &let_expression.variable_name), ]) .into_node(), Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Value"), + text(i18n::tr(state, "Value", "値", "Valoro")), render_expression_editor( state, let_expression.value.as_ref(), @@ -556,7 +566,7 @@ fn render_expression_editor( .into_node(), Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) - .children([text("Body"), { + .children([text(i18n::tr(state, "Body", "本体", "Kerno")), { let mut body_scope = scope_variables.clone(); body_scope.push(ScopeVariable { id: let_expression.variable_id, @@ -613,7 +623,7 @@ fn render_expression_editor( .set("font-size", "0.8rem") .set("color", "var(--text-secondary)"), ) - .children([text("Key")]) + .children([text(i18n::tr(state, "Key", "キー", "Ŝlosilo"))]) .into_node(), if structure_locked { Div::new() @@ -631,14 +641,14 @@ fn render_expression_editor( if structure_locked { Div::new().children([]).into_node() } else { - remove_record_item_button(path.clone(), index, target) + remove_record_item_button(state, path.clone(), index, target) }, ]) .into_node(), Div::new() .style(Style::new().set("display", "grid").set("gap", "0.3rem")) .children([ - text("Value"), + text(i18n::tr(state, "Value", "値", "Valoro")), render_expression_editor( state, item.value.as_ref(), @@ -656,7 +666,7 @@ fn render_expression_editor( }) .collect::>>(); if !structure_locked { - record_children.push(add_record_item_button(path, target)); + record_children.push(add_record_item_button(state, path, target)); } children.push( Div::new() @@ -691,7 +701,11 @@ fn render_expression_editor( .set("font-size", "0.82rem") .set("color", "var(--text-secondary)"), ) - .children([text(format!("Type: {}", type_part_name))]) + .children([text(format!( + "{} {}", + i18n::tr(state, "Type:", "型:", "Tipo:"), + type_part_name + ))]) .into_node(), render_expression_editor( state, @@ -716,7 +730,12 @@ fn render_expression_editor( .set("font-size", "0.8rem") .set("color", "var(--text-secondary)"), ) - .children([text("ドロップダウンから Global/Local 参照を選んでください")]) + .children([text(i18n::tr( + state, + "Select a Global/Local reference from the dropdown.", + "ドロップダウンから Global/Local 参照を選んでください", + "Elektu Globalan/Lokan referencon el la falmenuo.", + ))]) .into_node(), ); } @@ -1675,7 +1694,12 @@ fn string_input(path: Vec, target: EditorTarget, value: &str) -> Node< input.into_node() } -fn boolean_input(path: Vec, target: EditorTarget, value: bool) -> Node { +fn boolean_input( + state: &AppState, + path: Vec, + target: EditorTarget, + value: bool, +) -> Node { Div::new() .style(Style::new().set("display", "flex").set("gap", "0.5rem")) .children([ @@ -1702,7 +1726,7 @@ fn boolean_input(path: Vec, target: EditorTarget, value: bool) -> Node } } })) - .children([text("True")]) + .children([text(i18n::tr(state, "True", "真", "Vera"))]) .into_node(), Button::new() .type_("button") @@ -1727,7 +1751,7 @@ fn boolean_input(path: Vec, target: EditorTarget, value: bool) -> Node } } })) - .children([text("False")]) + .children([text(i18n::tr(state, "False", "偽", "Falsa"))]) .into_node(), ]) .into_node() @@ -1817,7 +1841,11 @@ fn record_item_key_input( input.into_node() } -fn add_record_item_button(path: Vec, target: EditorTarget) -> Node { +fn add_record_item_button( + state: &AppState, + path: Vec, + target: EditorTarget, +) -> Node { Button::new() .type_("button") .on_click(EventHandler::new(move |set_state| { @@ -1831,11 +1859,12 @@ fn add_record_item_button(path: Vec, target: EditorTarget) -> Node, item_index: usize, target: EditorTarget, @@ -1853,11 +1882,15 @@ fn remove_record_item_button( })); } })) - .children([text("Remove")]) + .children([text(i18n::tr(state, "Remove", "削除", "Forigi"))]) .into_node() } -fn add_list_item_button(path: Vec, target: EditorTarget) -> Node { +fn add_list_item_button( + state: &AppState, + path: Vec, + target: EditorTarget, +) -> Node { Button::new() .type_("button") .on_click(EventHandler::new(move |set_state| { @@ -1871,7 +1904,7 @@ fn add_list_item_button(path: Vec, target: EditorTarget) -> Node Node { let mut children = vec![header_main(state)]; @@ -40,7 +43,7 @@ fn header_main(state: &AppState) -> Node { ) .children([ A::::new() - .href(Href::Internal(Location::Home)) + .href(state.href_with_lang(Location::Home)) .style( Style::new() .set("text-decoration", "none") @@ -58,40 +61,40 @@ fn header_main(state: &AppState) -> Node { .into_node()]) .into_node(), A::::new() - .href(Href::Internal(Location::PartList)) + .href(state.href_with_lang(Location::PartList)) .style( Style::new() .set("font-size", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Parts")]) + .children([text(i18n::tr(state, "Parts", "パーツ", "Partoj"))]) .into_node(), A::::new() - .href(Href::Internal(Location::ModuleList)) + .href(state.href_with_lang(Location::ModuleList)) .style( Style::new() .set("font-size", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Modules")]) + .children([text(i18n::tr(state, "Modules", "モジュール", "Moduloj"))]) .into_node(), A::::new() - .href(Href::Internal(Location::LocalEventQueue)) + .href(state.href_with_lang(Location::LocalEventQueue)) .style( Style::new() .set("font-size", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Local Events")]) + .children([text(i18n::tr(state, "Local Events", "ローカルイベント", "Lokaj eventoj"))]) .into_node(), A::::new() - .href(Href::Internal(Location::AccountList)) + .href(state.href_with_lang(Location::AccountList)) .style( Style::new() .set("font-size", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Accounts")]) + .children([text(i18n::tr(state, "Accounts", "アカウント", "Kontoj"))]) .into_node(), ]) .into_node(), @@ -115,59 +118,150 @@ fn header_main(state: &AppState) -> Node { .children([text(crate::page_title::page_title_text(state))]) .into_node()]) .into_node(), - match &state.current_key { - Some(secret_key) => { - let account_id = definy_event::event::AccountId(Box::new( - secret_key.verifying_key().to_bytes(), - )); - let account_name = state.account_name_map().get(&account_id).cloned(); + { + let account_button = match &state.current_key { + Some(secret_key) => { + let account_id = definy_event::event::AccountId(Box::new( + secret_key.verifying_key().to_bytes(), + )); + let account_name = state.account_name_map().get(&account_id).cloned(); - Button::new() - .on_click(EventHandler::new(async |set_state| { - set_state(Box::new(|state: AppState| AppState { - is_header_popover_open: !state.is_header_popover_open, - ..state.clone() - })); - })) - .style( - Style::new() - .set("font-family", "'JetBrains Mono', monospace") - .set("font-size", "0.74rem") - .set("background", "rgba(255, 255, 255, 0.05)") - .set("color", "var(--text)") - .set("border", "1px solid var(--border)") - .set("padding", "0.38rem 0.8rem") - .set("max-width", "min(46vw, 420px)") - .set("overflow", "hidden") - .set("text-overflow", "ellipsis") - .set("white-space", "nowrap") - .set("anchor-name", "--header-popover-button"), - ) - .children([text(&match account_name { - Some(name) => name.to_string(), - None => base64::Engine::encode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - secret_key.verifying_key().to_bytes(), - ), - })]) - .into_node() - } - None => Button::new() - .command_for("login-or-create-account-dialog") - .command(CommandValue::ShowModal) - .children([text("Log In / Sign Up")]) - .into_node(), + Button::new() + .on_click(EventHandler::new(async |set_state| { + set_state(Box::new(|state: AppState| AppState { + is_header_popover_open: !state.is_header_popover_open, + ..state.clone() + })); + })) + .style( + Style::new() + .set("font-family", "'JetBrains Mono', monospace") + .set("font-size", "0.74rem") + .set("background", "rgba(255, 255, 255, 0.05)") + .set("color", "var(--text)") + .set("border", "1px solid var(--border)") + .set("padding", "0.38rem 0.8rem") + .set("max-width", "min(46vw, 420px)") + .set("overflow", "hidden") + .set("text-overflow", "ellipsis") + .set("white-space", "nowrap") + .set("anchor-name", "--header-popover-button"), + ) + .children([text(&match account_name { + Some(name) => name.to_string(), + None => base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + secret_key.verifying_key().to_bytes(), + ), + })]) + .into_node() + } + None => Button::new() + .command_for("login-or-create-account-dialog") + .command(CommandValue::ShowModal) + .children([text(i18n::tr( + state, + "Log In / Sign Up", + "ログイン / サインアップ", + "Ensaluti / Registriĝi", + ))]) + .into_node(), + }; + + Div::new() + .style( + Style::new() + .set("display", "flex") + .set("align-items", "center") + .set("gap", "0.6rem"), + ) + .children([language_dropdown(state), account_button]) + .into_node() }, ]) .into_node() } +fn language_dropdown(state: &AppState) -> Node { + let languages = crate::language::preferred_languages(); + let mut options = Vec::with_capacity(languages.len()); + for language in languages { + options.push(( + language.code.to_string(), + crate::language::language_label(&language), + )); + } + let current_code = state.language.code; + let dropdown = crate::dropdown::searchable_dropdown( + state, + "language", + current_code, + options.as_slice(), + Rc::new(|value| { + Box::new(move |state: AppState| { + let selected = crate::language::language_from_tag(value.as_str()) + .unwrap_or(state.language); + if selected.code == state.language.code { + return state; + } + let location = state.location.clone().unwrap_or(Location::Home); + let url = AppState::build_url( + &location, + selected.code, + state.event_list_state.filter_event_type, + ); + if let Some(window) = web_sys::window() { + if let Ok(history) = window.history() { + let _ = history.push_state_with_url( + &wasm_bindgen::JsValue::NULL, + "", + Some(url.as_str()), + ); + } + } + AppState { + language: selected, + language_fallback_notice: None, + ..state + } + }) + }), + ); + if let Some(notice) = &state.language_fallback_notice { + Div::new() + .style( + Style::new() + .set("display", "grid") + .set("gap", "0.25rem") + .set("justify-items", "start"), + ) + .children([ + dropdown, + Div::new() + .style( + Style::new() + .set("font-size", "0.75rem") + .set("color", "var(--text-secondary)") + .set("max-width", "22rem"), + ) + .children([text(format!( + "言語「{}」はサポートされていないため「{}」にフォールバックしました", + notice.requested, notice.fallback_to_code + ))]) + .into_node(), + ]) + .into_node() + } else { + dropdown + } +} + fn popover(state: &AppState) -> Node { let account_link = state.current_key.as_ref().map(|key| { let account_id = definy_event::event::AccountId(Box::new(key.verifying_key().to_bytes())); let account_name = crate::app_state::account_display_name(&state.account_name_map(), &account_id); A::::new() - .href(Href::Internal(Location::Account(account_id))) + .href(state.href_with_lang(Location::Account(account_id))) .style( Style::new() .set("padding", "0.4rem 0.5rem") @@ -218,9 +312,9 @@ fn popover(state: &AppState) -> Node { })); })) .children([text(if state.force_offline { - "Offline: On" + i18n::tr(state, "Offline: On", "オフライン: オン", "Senkonekte: En") } else { - "Offline: Off" + i18n::tr(state, "Offline: Off", "オフライン: オフ", "Senkonekte: Malŝaltita") })]) .style( Style::new() @@ -242,7 +336,7 @@ fn popover(state: &AppState) -> Node { } })); })) - .children([text("Log Out")]) + .children([text(i18n::tr(state, "Log Out", "ログアウト", "Elsaluti"))]) .style( Style::new() .set("width", "100%") diff --git a/definy-ui/src/i18n.rs b/definy-ui/src/i18n.rs new file mode 100644 index 00000000..c3197ffc --- /dev/null +++ b/definy-ui/src/i18n.rs @@ -0,0 +1,32 @@ +use crate::AppState; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Lang { + En, + Ja, + Eo, +} + +fn lang_from_code(code: &str) -> Lang { + match code { + "ja" => Lang::Ja, + "eo" => Lang::Eo, + _ => Lang::En, + } +} + +pub fn tr<'a>(state: &AppState, en: &'a str, ja: &'a str, eo: &'a str) -> &'a str { + match lang_from_code(state.language.code) { + Lang::En => en, + Lang::Ja => ja, + Lang::Eo => eo, + } +} + +pub fn tr_lang<'a>(lang_code: &str, en: &'a str, ja: &'a str, eo: &'a str) -> &'a str { + match lang_from_code(lang_code) { + Lang::En => en, + Lang::Ja => ja, + Lang::Eo => eo, + } +} diff --git a/definy-ui/src/language.rs b/definy-ui/src/language.rs new file mode 100644 index 00000000..0f71d3d9 --- /dev/null +++ b/definy-ui/src/language.rs @@ -0,0 +1,304 @@ +use std::collections::HashSet; + +use crate::query::parse_query; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Language { + pub code: &'static str, + pub english_name: &'static str, + pub native_name: &'static str, +} + +#[derive(Clone)] +pub struct LanguageResolution { + pub language: Language, + pub unsupported_query_lang: Option, +} + +pub const SUPPORTED_LANGUAGES: &[Language] = &[ + Language { + code: "en", + english_name: "English", + native_name: "English", + }, + Language { + code: "zh", + english_name: "Chinese", + native_name: "中文", + }, + Language { + code: "hi", + english_name: "Hindi", + native_name: "हिन्दी", + }, + Language { + code: "es", + english_name: "Spanish", + native_name: "Español", + }, + Language { + code: "fr", + english_name: "French", + native_name: "Français", + }, + Language { + code: "ar", + english_name: "Arabic", + native_name: "العربية", + }, + Language { + code: "bn", + english_name: "Bengali", + native_name: "বাংলা", + }, + Language { + code: "pt", + english_name: "Portuguese", + native_name: "Português", + }, + Language { + code: "ru", + english_name: "Russian", + native_name: "Русский", + }, + Language { + code: "ur", + english_name: "Urdu", + native_name: "اردو", + }, + Language { + code: "id", + english_name: "Indonesian", + native_name: "Bahasa Indonesia", + }, + Language { + code: "de", + english_name: "German", + native_name: "Deutsch", + }, + Language { + code: "ja", + english_name: "Japanese", + native_name: "日本語", + }, + Language { + code: "sw", + english_name: "Swahili", + native_name: "Kiswahili", + }, + Language { + code: "mr", + english_name: "Marathi", + native_name: "मराठी", + }, + Language { + code: "te", + english_name: "Telugu", + native_name: "తెలుగు", + }, + Language { + code: "tr", + english_name: "Turkish", + native_name: "Türkçe", + }, + Language { + code: "ta", + english_name: "Tamil", + native_name: "தமிழ்", + }, + Language { + code: "vi", + english_name: "Vietnamese", + native_name: "Tiếng Việt", + }, + Language { + code: "ko", + english_name: "Korean", + native_name: "한국어", + }, + Language { + code: "it", + english_name: "Italian", + native_name: "Italiano", + }, + Language { + code: "th", + english_name: "Thai", + native_name: "ไทย", + }, + Language { + code: "nl", + english_name: "Dutch", + native_name: "Nederlands", + }, + Language { + code: "pl", + english_name: "Polish", + native_name: "Polski", + }, + Language { + code: "fa", + english_name: "Persian", + native_name: "فارسی", + }, + Language { + code: "eo", + english_name: "Esperanto", + native_name: "Esperanto", + }, +]; + +pub fn supported_languages() -> &'static [Language] { + SUPPORTED_LANGUAGES +} + +pub fn default_language() -> Language { + SUPPORTED_LANGUAGES[0] +} + +pub fn language_from_tag(tag: &str) -> Option { + let tag = tag.trim(); + if tag.is_empty() { + return None; + } + let primary = tag + .split(|c| c == '-' || c == '_') + .next() + .unwrap_or(tag) + .to_ascii_lowercase(); + SUPPORTED_LANGUAGES + .iter() + .find(|lang| lang.code == primary) + .copied() +} + +pub fn language_from_query(query: Option<&str>) -> Option { + let params = parse_query(query); + params.lang.as_deref().and_then(language_from_tag) +} + +pub fn language_label(language: &Language) -> String { + format!("{} ({})", language.native_name, language.english_name) +} + +pub fn preferred_languages() -> Vec { + let mut ordered = Vec::new(); + let mut seen = HashSet::new(); + for tag in browser_language_tags() { + if let Some(lang) = language_from_tag(tag.as_str()) { + if seen.insert(lang.code) { + ordered.push(lang); + } + } + } + for lang in SUPPORTED_LANGUAGES.iter().copied() { + if seen.insert(lang.code) { + ordered.push(lang); + } + } + ordered +} + +pub fn best_language_from_browser() -> Option { + for tag in browser_language_tags() { + if let Some(lang) = language_from_tag(tag.as_str()) { + return Some(lang); + } + } + None +} + +pub fn resolve_language(query: Option<&str>, accept_language: Option<&str>) -> LanguageResolution { + let params = parse_query(query); + if let Some(requested_lang) = params.lang { + if let Some(language) = language_from_tag(requested_lang.as_str()) { + return LanguageResolution { + language, + unsupported_query_lang: None, + }; + } + let fallback = language_from_accept_language(accept_language).unwrap_or_else(default_language); + return LanguageResolution { + language: fallback, + unsupported_query_lang: Some(requested_lang), + }; + } + LanguageResolution { + language: language_from_accept_language(accept_language).unwrap_or_else(default_language), + unsupported_query_lang: None, + } +} + +pub fn best_language_from_accept_language(header: Option<&str>) -> Language { + language_from_accept_language(header).unwrap_or_else(default_language) +} + +pub fn language_from_accept_language(header: Option<&str>) -> Option { + let header = match header { + Some(header) => header, + None => return None, + }; + let mut candidates = Vec::new(); + for part in header.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let mut pieces = part.split(';'); + let tag = pieces.next().unwrap_or("").trim(); + if tag.is_empty() { + continue; + } + let mut q = 1.0_f32; + for param in pieces { + let param = param.trim(); + let mut kv = param.splitn(2, '='); + let key = kv.next().unwrap_or("").trim().to_ascii_lowercase(); + if key == "q" { + if let Some(val) = kv.next() { + q = val.trim().parse::().unwrap_or(1.0); + } + } + } + candidates.push((tag.to_string(), q)); + } + candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + for (tag, _) in candidates { + if tag == "*" { + return Some(default_language()); + } + if let Some(lang) = language_from_tag(tag.as_str()) { + return Some(lang); + } + } + None +} + +#[cfg(target_arch = "wasm32")] +fn browser_language_tags() -> Vec { + let mut tags = Vec::new(); + if let Some(window) = web_sys::window() { + let navigator = window.navigator(); + let languages = navigator.languages(); + for value in languages.iter() { + if let Some(value) = value.as_string() { + if !value.trim().is_empty() { + tags.push(value); + } + } + } + if tags.is_empty() { + if let Some(lang) = navigator.language() { + if !lang.trim().is_empty() { + tags.push(lang); + } + } + } + } + tags +} + +#[cfg(not(target_arch = "wasm32"))] +fn browser_language_tags() -> Vec { + Vec::new() +} diff --git a/definy-ui/src/lib.rs b/definy-ui/src/lib.rs index a3dd0358..8fd01c0d 100644 --- a/definy-ui/src/lib.rs +++ b/definy-ui/src/lib.rs @@ -11,7 +11,9 @@ mod expression_eval; pub mod fetch; mod hash_format; mod header; +pub mod i18n; pub mod indexed_db; +pub mod language; mod layout; mod local_event; mod local_event_queue; @@ -20,6 +22,7 @@ mod message; mod module_detail; mod module_list; mod module_projection; +pub mod query; pub mod navigator_credential; mod not_found; mod page_title; @@ -133,6 +136,7 @@ init({{ module_or_path: \"/{}\" }});", _ => {} } Html::new() + .attribute("lang", state.language.code) .children([ Head::new().children(head_children).into_node(), Body::new() diff --git a/definy-ui/src/local_event_queue.rs b/definy-ui/src/local_event_queue.rs index 4ac87f88..85ecffaa 100644 --- a/definy-ui/src/local_event_queue.rs +++ b/definy-ui/src/local_event_queue.rs @@ -1,13 +1,14 @@ use narumincho_vdom::*; use crate::app_state::{replace_local_event_records, AppState}; +use crate::i18n; use crate::local_event::LocalEventStatus; -fn status_label(status: &LocalEventStatus) -> &'static str { +fn status_label(state: &AppState, status: &LocalEventStatus) -> &'static str { match status { - LocalEventStatus::Queued => "送信待ち", - LocalEventStatus::Sent => "送信済み", - LocalEventStatus::Failed => "送信失敗", + LocalEventStatus::Queued => i18n::tr(&state, "Queued", "送信待ち", "Atendanta"), + LocalEventStatus::Sent => i18n::tr(&state, "Sent", "送信済み", "Sendita"), + LocalEventStatus::Failed => i18n::tr(&state, "Failed", "送信失敗", "Malsukcesis"), } } @@ -19,10 +20,10 @@ fn status_color(status: &LocalEventStatus) -> &'static str { } } -fn format_time_ms(time_ms: i64) -> String { +fn format_time_ms(state: &AppState, time_ms: i64) -> String { chrono::DateTime::::from_timestamp_millis(time_ms) .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| "unknown".to_string()) + .unwrap_or_else(|| i18n::tr(&state, "unknown", "不明", "nekonata").to_string()) } pub fn local_event_queue_view(state: &AppState) -> Node { @@ -47,7 +48,10 @@ pub fn local_event_queue_view(state: &AppState) -> Node { Err(error) => { next.local_event_queue.is_loading = false; next.local_event_queue.last_error = - Some(format!("Failed to load local events: {error:?}")); + Some(format!( + "{}: {error:?}", + i18n::tr(&state, "Failed to load local events", "ローカルイベントの読み込みに失敗しました", "Malsukcesis ŝargi lokajn eventojn") + )); } } next @@ -61,7 +65,7 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("padding", "0.4rem 0.8rem") .set("border-radius", "0.5rem"), ) - .children([text("Refresh")]) + .children([text(i18n::tr(&state, "Refresh", "更新", "Refreŝigi"))]) .into_node(); let offline_toggle = Button::new() @@ -81,9 +85,9 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("border-radius", "0.5rem"), ) .children([text(if state.force_offline { - "Offline: On" + i18n::tr(&state, "Offline: On", "オフライン: オン", "Senkonekte: En") } else { - "Offline: Off" + i18n::tr(&state, "Offline: Off", "オフライン: オフ", "Senkonekte: Malŝaltita") })]) .into_node(); @@ -92,7 +96,12 @@ pub fn local_event_queue_view(state: &AppState) -> Node { list_items.push( Div::new() .style(Style::new().set("color", "var(--text-secondary)")) - .children([text("No local events")]) + .children([text(i18n::tr( + &state, + "No local events", + "ローカルイベントはありません", + "Neniuj lokaj eventoj", + ))]) .into_node(), ); } else { @@ -110,12 +119,13 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("font-weight", "600") .set("display", "inline-flex"), ) - .children([text(status_label(&status))]) + .children([text(status_label(state, &status))]) .into_node(); let summary = match definy_event::verify_and_deserialize(&record.event_binary) { - Ok((_, event)) => crate::event_presenter::event_summary_text(&event), - Err(_) => "Invalid event".to_string(), + Ok((_, event)) => crate::event_presenter::event_summary_text(state, &event), + Err(_) => i18n::tr(&state, "Invalid event", "無効なイベント", "Nevalida evento") + .to_string(), }; let mut actions = Vec::new(); @@ -137,7 +147,13 @@ pub fn local_event_queue_view(state: &AppState) -> Node { } Err(error) => { next.local_event_queue.last_error = Some(format!( - "Failed to cancel queued event: {error:?}" + "{}: {error:?}", + i18n::tr( + &state, + "Failed to cancel queued event", + "キュー済みイベントのキャンセルに失敗しました", + "Malsukcesis nuligi envicigitan eventon", + ) )); } } @@ -153,7 +169,7 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("padding", "0.3rem 0.6rem") .set("border-radius", "0.45rem"), ) - .children([text("Cancel")]) + .children([text(i18n::tr(&state, "Cancel", "キャンセル", "Nuligi"))]) .into_node(), ); } @@ -219,7 +235,7 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("color", "var(--text-secondary)") .set("font-size", "0.78rem"), ) - .children([text(format_time_ms(record.updated_at_ms))]) + .children([text(format_time_ms(state, record.updated_at_ms))]) .into_node(), error_note, if actions.is_empty() { @@ -252,7 +268,14 @@ pub fn local_event_queue_view(state: &AppState) -> Node { Div::new() .style(Style::new().set("display", "grid").set("gap", "0.2rem")) .children([ - H2::new().children([text("Local Events")]).into_node(), + H2::new() + .children([text(i18n::tr( + &state, + "Local Events", + "ローカルイベント", + "Lokaj eventoj", + ))]) + .into_node(), Div::new() .style( Style::new() @@ -279,7 +302,7 @@ pub fn local_event_queue_view(state: &AppState) -> Node { .set("color", "var(--text-secondary)") .set("font-size", "0.82rem"), ) - .children([text("Loading...")]) + .children([text(i18n::tr(&state, "Loading...", "読み込み中...", "Ŝargado..."))]) .into_node() } else if let Some(error) = &state.local_event_queue.last_error { Div::new() diff --git a/definy-ui/src/login_or_create_account_dialog.rs b/definy-ui/src/login_or_create_account_dialog.rs index 25e2053c..294a3f94 100644 --- a/definy-ui/src/login_or_create_account_dialog.rs +++ b/definy-ui/src/login_or_create_account_dialog.rs @@ -4,6 +4,7 @@ use crate::{ LoginOrCreateAccountDialogState, app_state::{AppState, CreatingAccountState}, fetch, + i18n, }; /// ログインまたはアカウント作成ダイアログ @@ -25,9 +26,13 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { .style(Style::new().set("font-size", "1.25rem")) .children([text( match state.login_or_create_account_dialog_state.state { - CreatingAccountState::LogIn => "Log In", - CreatingAccountState::CreateAccount => "Sign Up", - _ => "Account", + CreatingAccountState::LogIn => { + i18n::tr(state, "Log In", "ログイン", "Ensaluti") + } + CreatingAccountState::CreateAccount => { + i18n::tr(state, "Sign Up", "サインアップ", "Registriĝi") + } + _ => i18n::tr(state, "Account", "アカウント", "Konto"), }, )]) .into_node(), @@ -100,7 +105,7 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { ), ) .on_click(create_login_event_handler()) - .children([text("Log In")]) + .children([text(i18n::tr(state, "Log In", "ログイン", "Ensaluti"))]) .into_node(), Button::new() .type_("button") @@ -153,17 +158,14 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { } })); })) - .children([text("Sign Up")]) + .children([text(i18n::tr(state, "Sign Up", "サインアップ", "Registriĝi"))]) .into_node(), ]) .into_node(), match state.login_or_create_account_dialog_state.state { - CreatingAccountState::LogIn => login_view(), + CreatingAccountState::LogIn => login_view(state), CreatingAccountState::CreateAccount => { - create_account_view( - &state.login_or_create_account_dialog_state, - state.force_offline, - ) + create_account_view(state, state.force_offline) } _ => Div::new().children([]).into_node(), }, @@ -171,7 +173,7 @@ pub fn login_or_create_account_dialog(state: &AppState) -> Node { .into_node() } -fn login_view() -> Node { +fn login_view(state: &AppState) -> Node { Form::new() .on_submit(EventHandler::new(async |set_state| { let password = wasm_bindgen::JsCast::dyn_into::( @@ -205,7 +207,14 @@ fn login_view() -> Node { Div::new() .class("form-group") .children([ - Label::new().children([text("Secret Key")]).into_node(), + Label::new() + .children([text(i18n::tr( + state, + "Secret Key", + "秘密鍵", + "Sekreta ŝlosilo", + ))]) + .into_node(), Input::new() .type_("password") .name("password") @@ -217,7 +226,7 @@ fn login_view() -> Node { Button::new() .type_("submit") .style(Style::new().set("width", "100%")) - .children([text("Log In")]) + .children([text(i18n::tr(state, "Log In", "ログイン", "Ensaluti"))]) .into_node(), ]) .into_node() @@ -228,10 +237,9 @@ fn generate_key() -> ed25519_dalek::SigningKey { ed25519_dalek::SigningKey::generate(&mut csprng) } -fn create_account_view( - state: &LoginOrCreateAccountDialogState, - force_offline: bool, -) -> Node { +fn create_account_view(state: &AppState, force_offline: bool) -> Node { + let dialog_state = &state.login_or_create_account_dialog_state; + let lang_code = state.language.code; let mut password_input = Input::new() .type_("password") .name("password") @@ -239,18 +247,18 @@ fn create_account_view( .required() .readonly(); - if let Some(key) = &state.generated_key { + if let Some(key) = &dialog_state.generated_key { password_input = password_input.value(&base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, key.to_bytes(), )); } - let requesting = state.state == CreatingAccountState::CreateAccountRequesting - || state.state == CreatingAccountState::Success; + let requesting = dialog_state.state == CreatingAccountState::CreateAccountRequesting + || dialog_state.state == CreatingAccountState::Success; - let generated_key_for_submit = state.generated_key.clone(); - let generated_key_for_copy = state.generated_key.clone(); + let generated_key_for_submit = dialog_state.generated_key.clone(); + let generated_key_for_copy = dialog_state.generated_key.clone(); Form::new() .on_submit(EventHandler::new(move |set_state| { @@ -297,16 +305,36 @@ fn create_account_view( let status_for_state = status.clone(); let message = match status { crate::local_event::LocalEventStatus::Sent => { - "Account created".to_string() + i18n::tr_lang( + lang_code, + "Account created", + "アカウントを作成しました", + "Konto kreita", + ) + .to_string() } crate::local_event::LocalEventStatus::Queued => { - "Queued: network unavailable".to_string() + i18n::tr_lang( + lang_code, + "Queued: network unavailable", + "キュー済み: ネットワーク未接続", + "En vico: reto nedisponebla", + ) + .to_string() } crate::local_event::LocalEventStatus::Failed => { record .last_error .clone() - .unwrap_or_else(|| "Failed to send".to_string()) + .unwrap_or_else(|| { + i18n::tr_lang( + lang_code, + "Failed to send", + "送信に失敗しました", + "Sendado malsukcesis", + ) + .to_string() + }) } }; set_state_for_async(Box::new(move |state: AppState| { @@ -352,7 +380,14 @@ fn create_account_view( Div::new() .class("form-group") .children([ - Label::new().children([text("Username")]).into_node(), + Label::new() + .children([text(i18n::tr( + state, + "Username", + "ユーザー名", + "Uzantnomo", + ))]) + .into_node(), Input::new() .type_("text") .name("username") @@ -366,7 +401,12 @@ fn create_account_view( .class("form-group") .children([ Label::new() - .children([text("User ID (Public Key)")]) + .children([text(i18n::tr( + state, + "User ID (Public Key)", + "ユーザーID (公開鍵)", + "Uzanto-ID (publika ŝlosilo)", + ))]) .into_node(), Div::new() .style( @@ -379,7 +419,7 @@ fn create_account_view( .set("border", "1px solid var(--border)") .set("word-break", "break-all"), ) - .children(match &state.generated_key { + .children(match &dialog_state.generated_key { Some(key) => vec![text(&base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, key.verifying_key().to_bytes(), @@ -392,12 +432,24 @@ fn create_account_view( Div::new() .class("form-group") .children([ - Label::new().children([text("Secret Key")]).into_node(), + Label::new() + .children([text(i18n::tr( + state, + "Secret Key", + "秘密鍵", + "Sekreta ŝlosilo", + ))]) + .into_node(), Div::new() .class("hint") .style(Style::new().set("margin-bottom", "0.5rem")) .children([text( - "If you lose your secret key, you will not be able to log in again.", + i18n::tr( + state, + "If you lose your secret key, you will not be able to log in again.", + "秘密鍵を失うと再ログインできません。", + "Se vi perdas la sekretan ŝlosilon, vi ne povos denove ensaluti.", + ), )]) .into_node(), Div::new() @@ -424,7 +476,7 @@ fn create_account_view( } })) .type_("button") - .children([text("Copy")]) + .children([text(i18n::tr(state, "Copy", "コピー", "Kopii"))]) .into_node(), Button::new() .on_click(EventHandler::new(async |set_state| { @@ -443,7 +495,7 @@ fn create_account_view( })) .type_("button") .disabled(requesting) - .children([text("Regen")]) + .children([text(i18n::tr(state, "Regen", "再生成", "Regeneri"))]) .into_node(), ]) .into_node(), @@ -457,22 +509,32 @@ fn create_account_view( .command(CommandValue::Close) .type_("button") .on_click(EventHandler::new(async |_set_state| {})) - .children([text("Cancel")]) + .children([text(i18n::tr(state, "Cancel", "キャンセル", "Nuligi"))]) .into_node(), Button::new() .type_("submit") .disabled(requesting) - .children([text(match state.state { - CreatingAccountState::LogIn => "Log In", - CreatingAccountState::CreateAccount => "Sign Up", - CreatingAccountState::CreateAccountRequesting => "Signing Up...", - CreatingAccountState::Success => "Success", - CreatingAccountState::Error => "Error", + .children([text(match dialog_state.state { + CreatingAccountState::LogIn => { + i18n::tr(state, "Log In", "ログイン", "Ensaluti") + } + CreatingAccountState::CreateAccount => { + i18n::tr(state, "Sign Up", "サインアップ", "Registriĝi") + } + CreatingAccountState::CreateAccountRequesting => { + i18n::tr(state, "Signing Up...", "サインアップ中...", "Registriĝante...") + } + CreatingAccountState::Success => { + i18n::tr(state, "Success", "成功", "Sukceso") + } + CreatingAccountState::Error => { + i18n::tr(state, "Error", "エラー", "Eraro") + } })]) .into_node(), ]) .into_node(), - match &state.create_account_result_message { + match &dialog_state.create_account_result_message { Some(message) => Div::new() .style( Style::new() diff --git a/definy-ui/src/module_detail.rs b/definy-ui/src/module_detail.rs index bf1fe67a..7b819207 100644 --- a/definy-ui/src/module_detail.rs +++ b/definy-ui/src/module_detail.rs @@ -4,6 +4,7 @@ use crate::app_state::AppState; use crate::module_projection::find_module_snapshot; use crate::part_projection::collect_part_snapshots; use crate::Location; +use crate::i18n; pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> Node { let Some(module_snapshot) = find_module_snapshot(state, definition_event_hash) else { @@ -13,7 +14,12 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> .children([ H2::new() .style(Style::new().set("font-size", "1.3rem")) - .children([text("Module not found")]) + .children([text(i18n::tr( + &state, + "Module not found", + "モジュールが見つかりません", + "Modulo ne trovita", + ))]) .into_node(), ]) .into_node(); @@ -61,22 +67,36 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> .set("font-size", "0.85rem") .set("color", "var(--primary)"), ) - .children([text(format!("latest author: {}", author_name))]) + .children([text(format!( + "{} {}", + i18n::tr(&state, "latest author:", "最新の投稿者:", "lasta aŭtoro:"), + author_name + ))]) .into_node(), Div::new() .style(Style::new().set("display", "flex").set("gap", "0.45rem")) .children([ A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( module_snapshot.latest_event_hash, ))) - .children([text("Latest event")]) + .children([text(i18n::tr( + &state, + "Latest event", + "最新イベント", + "Lasta evento", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( module_snapshot.definition_event_hash, ))) - .children([text("Definition event")]) + .children([text(i18n::tr( + &state, + "Definition event", + "定義イベント", + "Difina evento", + ))]) .into_node(), ]) .into_node(), @@ -92,12 +112,22 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> .set("padding", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Login required to update modules.")]) + .children([text(i18n::tr( + &state, + "Login required to update modules.", + "モジュール更新にはログインが必要です。", + "Ensaluto necesas por ĝisdatigi modulojn.", + ))]) .into_node() }, Div::new() .style(Style::new().set("margin-top", "1rem")) - .children([text("Parts in this module")]) + .children([text(i18n::tr( + &state, + "Parts in this module", + "このモジュールのパーツ", + "Partoj en ĉi tiu modulo", + ))]) .into_node(), if parts_in_module.is_empty() { Div::new() @@ -107,7 +137,12 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> .set("padding", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("No parts in this module yet.")]) + .children([text(i18n::tr( + &state, + "No parts in this module yet.", + "このモジュールにはまだパーツがありません。", + "Ankoraŭ neniuj partoj en ĉi tiu modulo.", + ))]) .into_node() } else { Div::new() @@ -165,7 +200,8 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> .set("color", "var(--primary)"), ) .children([text(format!( - "latest author: {}", + "{} {}", + i18n::tr(&state, "latest author:", "最新の投稿者:", "lasta aŭtoro:"), part_author ))]) .into_node(), @@ -177,16 +213,26 @@ pub fn module_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> ) .children([ A::::new() - .href(Href::Internal(Location::Part( + .href(state.href_with_lang(Location::Part( part.definition_event_hash, ))) - .children([text("Open part detail")]) + .children([text(i18n::tr( + &state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( part.latest_event_hash, ))) - .children([text("Latest event")]) + .children([text(i18n::tr( + &state, + "Latest event", + "最新イベント", + "Lasta evento", + ))]) .into_node(), ]) .into_node(), @@ -220,7 +266,12 @@ fn module_update_form( .children([ Div::new() .style(Style::new().set("font-weight", "600")) - .children([text("Update module")]) + .children([text(i18n::tr( + &state, + "Update module", + "モジュールを更新", + "Ĝisdatigi modulon", + ))]) .into_node(), Input::new() .type_("text") @@ -260,7 +311,13 @@ fn module_update_form( .style(Style::new().set("min-height", "5rem")); description.attributes.push(( "placeholder".to_string(), - "module description (supports multiple lines)".to_string(), + i18n::tr( + &state, + "module description (supports multiple lines)", + "モジュール説明 (複数行対応)", + "modula priskribo (subtenas plurajn liniojn)", + ) + .to_string(), )); description.events.push(( "input".to_string(), @@ -306,7 +363,12 @@ fn module_update_form( } else { let mut next = state.clone(); next.module_update_form.result_message = - Some("Error: login required".to_string()); + Some(i18n::tr( + &state, + "Error: login required", + "エラー: ログインが必要です", + "Eraro: ensaluto necesas", + ).to_string()); return next; }; let (module_name, module_description) = @@ -315,7 +377,12 @@ fn module_update_form( if module_name.is_empty() { let mut next = state.clone(); next.module_update_form.result_message = - Some("Error: module name is required".to_string()); + Some(i18n::tr( + &state, + "Error: module name is required", + "エラー: モジュール名は必須です", + "Eraro: modulo-nomo estas bezonata", + ).to_string()); return next; } let module_description = module_description; @@ -343,7 +410,13 @@ fn module_update_form( set_state_for_async(Box::new(move |state| { let mut next = state.clone(); next.module_update_form.result_message = Some(format!( - "Error: failed to serialize ModuleUpdate: {:?}", + "{}: {:?}", + i18n::tr( + &state, + "Error: failed to serialize ModuleUpdate", + "エラー: ModuleUpdate のシリアライズに失敗しました", + "Eraro: malsukcesis seriigi ModuleUpdate", + ), error )); next @@ -408,7 +481,12 @@ fn module_update_form( String::new(); } next.module_update_form.result_message = - Some("ModuleUpdate event posted".to_string()); + Some(i18n::tr( + &state, + "ModuleUpdate event posted", + "ModuleUpdate を投稿しました", + "ModuleUpdate sendita", + ).to_string()); next })); } @@ -422,13 +500,31 @@ fn module_update_form( next.module_update_form.result_message = Some( match status { crate::local_event::LocalEventStatus::Queued => { - "ModuleUpdate queued (offline)".to_string() + i18n::tr( + &state, + "ModuleUpdate queued (offline)", + "ModuleUpdate をキューに追加しました (オフライン)", + "ModuleUpdate envicigita (senkonekte)", + ) + .to_string() } crate::local_event::LocalEventStatus::Failed => { - "ModuleUpdate failed to send".to_string() + i18n::tr( + &state, + "ModuleUpdate failed to send", + "ModuleUpdate の送信に失敗しました", + "ModuleUpdate sendado malsukcesis", + ) + .to_string() } crate::local_event::LocalEventStatus::Sent => { - "ModuleUpdate event posted".to_string() + i18n::tr( + &state, + "ModuleUpdate event posted", + "ModuleUpdate を投稿しました", + "ModuleUpdate sendita", + ) + .to_string() } }, ); @@ -440,7 +536,13 @@ fn module_update_form( set_state_for_async(Box::new(move |state| { let mut next = state.clone(); next.module_update_form.result_message = Some(format!( - "Error: failed to post ModuleUpdate: {:?}", + "{}: {:?}", + i18n::tr( + &state, + "Error: failed to post ModuleUpdate", + "エラー: ModuleUpdate の送信に失敗しました", + "Eraro: malsukcesis sendi ModuleUpdate", + ), error )); next @@ -451,7 +553,12 @@ fn module_update_form( state })); })) - .children([text("Send ModuleUpdate")]) + .children([text(i18n::tr( + &state, + "Send ModuleUpdate", + "ModuleUpdate を送信", + "Sendi ModuleUpdate", + ))]) .into_node(), match &state.module_update_form.result_message { Some(result) => Div::new() diff --git a/definy-ui/src/module_list.rs b/definy-ui/src/module_list.rs index bec8d911..90d48303 100644 --- a/definy-ui/src/module_list.rs +++ b/definy-ui/src/module_list.rs @@ -2,6 +2,7 @@ use narumincho_vdom::*; use sha2::Digest; use crate::app_state::AppState; +use crate::i18n; use crate::module_projection::collect_module_snapshots; pub fn module_list_view(state: &AppState) -> Node { @@ -19,7 +20,12 @@ pub fn module_list_view(state: &AppState) -> Node { .set("padding", "0.9rem") .set("color", "var(--text-secondary)"), ) - .children([text("Login required to create modules.")]) + .children([text(i18n::tr( + &state, + "Login required to create modules.", + "モジュール作成にはログインが必要です。", + "Ensaluto necesas por krei modulojn.", + ))]) .into_node(), ) }; @@ -32,7 +38,7 @@ pub fn module_list_view(state: &AppState) -> Node { children.push( H2::new() .style(Style::new().set("font-size", "1.3rem")) - .children([text("Modules")]) + .children([text(i18n::tr(&state, "Modules", "モジュール", "Moduloj"))]) .into_node(), ); if let Some(form) = create_form { @@ -62,7 +68,12 @@ pub fn module_list_view(state: &AppState) -> Node { .set("padding", "0.95rem") .set("color", "var(--text-secondary)"), ) - .children([text("No modules yet.")]) + .children([text(i18n::tr( + &state, + "No modules yet.", + "まだモジュールがありません。", + "Ankoraŭ neniuj moduloj.", + ))]) .into_node(), ); } else { @@ -113,7 +124,12 @@ pub fn module_list_view(state: &AppState) -> Node { .set("font-size", "0.82rem") .set("color", "var(--text-secondary)"), ) - .children([text("definition event missing")]) + .children([text(i18n::tr( + &state, + "definition event missing", + "定義イベントが見つかりません", + "difina evento mankas", + ))]) .into_node() }, if module.module_description.is_empty() { @@ -147,28 +163,43 @@ pub fn module_list_view(state: &AppState) -> Node { ) .children([ A::::new() - .href(Href::Internal( + .href(state.href_with_lang( crate::Location::Module( module.definition_event_hash, ), )) - .children([text("Open module detail")]) + .children([text(i18n::tr( + &state, + "Open module detail", + "モジュール詳細を開く", + "Malfermi modulajn detalojn", + ))]) .into_node(), A::::new() - .href(Href::Internal( + .href(state.href_with_lang( crate::Location::Event( module.latest_event_hash, ), )) - .children([text("Latest event")]) + .children([text(i18n::tr( + &state, + "Latest event", + "最新イベント", + "Lasta evento", + ))]) .into_node(), A::::new() - .href(Href::Internal( + .href(state.href_with_lang( crate::Location::Event( module.definition_event_hash, ), )) - .children([text("Definition event")]) + .children([text(i18n::tr( + &state, + "Definition event", + "定義イベント", + "Difina evento", + ))]) .into_node(), ]) .into_node(), @@ -192,7 +223,12 @@ fn module_create_form(state: &AppState) -> Node { .children([ Div::new() .style(Style::new().set("font-size", "0.9rem")) - .children([text("Create module")]) + .children([text(i18n::tr( + &state, + "Create module", + "モジュールを作成", + "Krei modulon", + ))]) .into_node(), module_name_input(state), module_description_input(state), @@ -216,8 +252,12 @@ fn module_create_form(state: &AppState) -> Node { state.module_definition_form.module_description_input.clone(); if module_name.is_empty() { let mut next = state.clone(); - next.module_definition_form.result_message = - Some("Error: module name is required".to_string()); + next.module_definition_form.result_message = Some(i18n::tr( + &state, + "Error: module name is required", + "エラー: モジュール名は必須です", + "Eraro: modulo-nomo estas bezonata", + ).to_string()); return next; } let key_for_async = key.clone(); @@ -268,15 +308,31 @@ fn module_create_form(state: &AppState) -> Node { next.module_definition_form.result_message = Some( match status { crate::local_event::LocalEventStatus::Queued => { - "ModuleDefinition queued (offline)" - .to_string() + i18n::tr( + &state, + "ModuleDefinition queued (offline)", + "ModuleDefinition をキューに追加しました (オフライン)", + "ModuleDefinition envicigita (senkonekte)", + ) + .to_string() } crate::local_event::LocalEventStatus::Failed => { - "ModuleDefinition failed to send" - .to_string() + i18n::tr( + &state, + "ModuleDefinition failed to send", + "ModuleDefinition の送信に失敗しました", + "ModuleDefinition sendado malsukcesis", + ) + .to_string() } crate::local_event::LocalEventStatus::Sent => { - "ModuleDefinition posted".to_string() + i18n::tr( + &state, + "ModuleDefinition posted", + "ModuleDefinition を投稿しました", + "ModuleDefinition sendita", + ) + .to_string() } }, ); @@ -288,7 +344,13 @@ fn module_create_form(state: &AppState) -> Node { set_state_for_async(Box::new(move |state| { let mut next = state.clone(); next.module_definition_form.result_message = Some(format!( - "Error: failed to create module ({:?})", + "{} ({:?})", + i18n::tr( + &state, + "Error: failed to create module", + "エラー: モジュール作成に失敗しました", + "Eraro: malsukcesis krei modulon", + ), e )); next @@ -303,7 +365,7 @@ fn module_create_form(state: &AppState) -> Node { next })); })) - .children([text("Create")]) + .children([text(i18n::tr(&state, "Create", "作成", "Krei"))]) .into_node(), ]) .into_node() @@ -316,7 +378,10 @@ fn module_name_input(state: &AppState) -> Node { .value(&state.module_definition_form.module_name_input); input .attributes - .push(("placeholder".to_string(), "module name".to_string())); + .push(( + "placeholder".to_string(), + i18n::tr(&state, "module name", "モジュール名", "modula nomo").to_string(), + )); input.events.push(( "input".to_string(), EventHandler::new(move |set_state| async move { @@ -346,7 +411,13 @@ fn module_description_input(state: &AppState) -> Node { .style(Style::new().set("min-height", "5rem")); textarea.attributes.push(( "placeholder".to_string(), - "description (optional)".to_string(), + i18n::tr( + &state, + "description (optional)", + "説明 (任意)", + "priskribo (nedeviga)", + ) + .to_string(), )); textarea.events.push(( "input".to_string(), diff --git a/definy-ui/src/not_found.rs b/definy-ui/src/not_found.rs index 6a4b9b0d..6fef1efc 100644 --- a/definy-ui/src/not_found.rs +++ b/definy-ui/src/not_found.rs @@ -1,8 +1,9 @@ use crate::Location; use crate::app_state::AppState; +use crate::i18n; use narumincho_vdom::*; -pub fn not_found_view(_state: &AppState) -> Node { +pub fn not_found_view(state: &AppState) -> Node { Div::new() .class("page-shell not-found") .style( @@ -40,11 +41,16 @@ pub fn not_found_view(_state: &AppState) -> Node { .set("color", "var(--text-primary)") .set("margin-bottom", "2rem"), ) - .children([text("Page Not Found")]) + .children([text(i18n::tr( + state, + "Page Not Found", + "ページが見つかりません", + "Paĝo ne trovita", + ))]) .into_node(), A::::new() .class("cta-link") - .href(Href::Internal(Location::Home)) + .href(state.href_with_lang(Location::Home)) .style( Style::new() .set("display", "inline-flex") @@ -60,7 +66,12 @@ pub fn not_found_view(_state: &AppState) -> Node { .set("transition", "all 0.3s ease") .set("box-shadow", "0 4px 10px rgba(139, 92, 246, 0.25)"), ) - .children([text("Return to Home")]) + .children([text(i18n::tr( + state, + "Return to Home", + "ホームに戻る", + "Reen al hejmo", + ))]) .into_node(), ]) .into_node() diff --git a/definy-ui/src/page_title.rs b/definy-ui/src/page_title.rs index a369ef5e..bbf86ec7 100644 --- a/definy-ui/src/page_title.rs +++ b/definy-ui/src/page_title.rs @@ -1,4 +1,5 @@ use crate::{AppState, Location}; +use crate::i18n; #[derive(Clone, Copy)] enum RouteId { @@ -30,18 +31,21 @@ impl RouteId { } } - fn title_prefix(self) -> &'static str { + fn title_prefix(self, state: &AppState) -> &'static str { match self { - Self::Home => "home", - Self::AccountList => "accounts", - Self::PartList => "parts", - Self::ModuleList => "modules", - Self::LocalEventQueue => "local-events", - Self::AccountDetail => "accounts", - Self::PartDetail => "parts", - Self::ModuleDetail => "modules", - Self::EventDetail => "events", - Self::NotFound => "not-found", + Self::Home => i18n::tr(state, "home", "ホーム", "hejmo"), + Self::AccountList | Self::AccountDetail => { + i18n::tr(state, "accounts", "アカウント", "kontoj") + } + Self::PartList | Self::PartDetail => i18n::tr(state, "parts", "パーツ", "partoj"), + Self::ModuleList | Self::ModuleDetail => { + i18n::tr(state, "modules", "モジュール", "moduloj") + } + Self::LocalEventQueue => { + i18n::tr(state, "local-events", "ローカルイベント", "lokaj-eventoj") + } + Self::EventDetail => i18n::tr(state, "events", "イベント", "eventoj"), + Self::NotFound => i18n::tr(state, "not-found", "未検出", "ne-trovita"), } } } @@ -55,17 +59,17 @@ pub fn page_title_text(state: &AppState) -> String { | Some(Location::ModuleList) | Some(Location::LocalEventQueue) | None => { - route_id.title_prefix().to_string() + route_id.title_prefix(state).to_string() } Some(Location::Account(account_id)) => { let account_name = crate::app_state::account_display_name(&state.account_name_map(), account_id); - format!("{}/{}", route_id.title_prefix(), account_name) + format!("{}/{}", route_id.title_prefix(state), account_name) } Some(Location::Part(definition_event_hash)) => { let part_name = resolve_part_name(state, definition_event_hash) .unwrap_or_else(|| short_hash(definition_event_hash)); - format!("{}/{}", route_id.title_prefix(), part_name) + format!("{}/{}", route_id.title_prefix(state), part_name) } Some(Location::Module(definition_event_hash)) => { let module_name = crate::module_projection::resolve_module_name( @@ -73,7 +77,7 @@ pub fn page_title_text(state: &AppState) -> String { definition_event_hash, ) .unwrap_or_else(|| short_hash(definition_event_hash)); - format!("{}/{}", route_id.title_prefix(), module_name) + format!("{}/{}", route_id.title_prefix(state), module_name) } Some(Location::Event(event_hash)) => { let event_label = state @@ -86,28 +90,56 @@ pub fn page_title_text(state: &AppState) -> String { let (_, event) = event_result.as_ref().ok()?; let label = match &event.content { definy_event::event::EventContent::CreateAccount(_) => { - "create-account".to_string() + i18n::tr(state, "create-account", "アカウント作成", "konto-kreo") + .to_string() } definy_event::event::EventContent::ChangeProfile(_) => { - "change-profile".to_string() + i18n::tr(state, "change-profile", "プロフィール変更", "profil-ŝanĝo") + .to_string() } definy_event::event::EventContent::PartDefinition(part_definition) => { - format!("part-definition/{}", part_definition.part_name) + format!( + "{}/{}", + i18n::tr(state, "part-definition", "パーツ定義", "parto-difino"), + part_definition.part_name + ) } definy_event::event::EventContent::PartUpdate(part_update) => { - format!("part-update/{}", part_update.part_name) + format!( + "{}/{}", + i18n::tr(state, "part-update", "パーツ更新", "parto-ĝisdatigo"), + part_update.part_name + ) } definy_event::event::EventContent::ModuleDefinition(module_definition) => { - format!("module-definition/{}", module_definition.module_name) + format!( + "{}/{}", + i18n::tr( + state, + "module-definition", + "モジュール定義", + "modulo-difino" + ), + module_definition.module_name + ) } definy_event::event::EventContent::ModuleUpdate(module_update) => { - format!("module-update/{}", module_update.module_name) + format!( + "{}/{}", + i18n::tr( + state, + "module-update", + "モジュール更新", + "modulo-ĝisdatigo" + ), + module_update.module_name + ) } }; Some(label) }) .unwrap_or_else(|| short_hash(event_hash)); - format!("{}/{}", route_id.title_prefix(), event_label) + format!("{}/{}", route_id.title_prefix(state), event_label) } } } diff --git a/definy-ui/src/part_detail.rs b/definy-ui/src/part_detail.rs index 0de1ea12..54d2cb06 100644 --- a/definy-ui/src/part_detail.rs +++ b/definy-ui/src/part_detail.rs @@ -4,6 +4,7 @@ use crate::Location; use crate::app_state::AppState; use crate::expression_editor::{EditorTarget, render_root_expression_editor}; use crate::expression_eval::expression_to_source; +use crate::i18n; use crate::module_projection::collect_module_snapshots; use crate::part_projection::{collect_related_part_events, find_part_snapshot}; @@ -17,8 +18,13 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .children(match snapshot { Some(snapshot) => vec![ A::::new() - .href(Href::Internal(Location::PartList)) - .children([text("← Back to Parts")]) + .href(state.href_with_lang(Location::PartList)) + .children([text(i18n::tr( + &state, + "← Back to Parts", + "← パーツ一覧へ戻る", + "← Reen al partoj", + ))]) .into_node(), H2::new() .style(Style::new().set("font-size", "1.3rem")) @@ -40,14 +46,20 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .set("color", "var(--text-secondary)"), ) .children([text(format!( - "Updated at: {}", + "{} {}", + i18n::tr(&state, "Updated at:", "更新日時:", "Ĝisdatigita je:"), snapshot.updated_at.format("%Y-%m-%d %H:%M:%S") ))]) .into_node(), if snapshot.part_description.is_empty() { Div::new() .style(Style::new().set("color", "var(--text-secondary)")) - .children([text("(no description)")]) + .children([text(i18n::tr( + &state, + "(no description)", + "(説明なし)", + "(sen priskribo)", + ))]) .into_node() } else { Div::new() @@ -63,7 +75,8 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .set("opacity", "0.9"), ) .children([text(format!( - "expression: {}", + "{} {}", + i18n::tr(&state, "expression:", "式:", "esprimo:"), expression_to_source(&snapshot.expression) ))]) .into_node(), @@ -71,14 +84,28 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .style(Style::new().set("display", "flex").set("gap", "0.6rem")) .children([ A::::new() - .href(Href::Internal(Location::Event(*definition_event_hash))) - .children([text("Definition event")]) + .href( + state.href_with_lang(Location::Event( + *definition_event_hash, + )), + ) + .children([text(i18n::tr( + &state, + "Definition event", + "定義イベント", + "Difina evento", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( snapshot.latest_event_hash, ))) - .children([text("Latest event")]) + .children([text(i18n::tr( + &state, + "Latest event", + "最新イベント", + "Lasta evento", + ))]) .into_node(), ]) .into_node(), @@ -96,7 +123,7 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .children([ Div::new() .style(Style::new().set("font-weight", "600")) - .children([text("History")]) + .children([text(i18n::tr(&state, "History", "履歴", "Historio"))]) .into_node(), Div::new() .style(Style::new().set("display", "grid").set("gap", "0.4rem")) @@ -105,9 +132,9 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N .into_iter() .map(|(event_hash, event)| { let label = - crate::event_presenter::event_kind_label(&event); + crate::event_presenter::event_kind_label(state, &event); A::::new() - .href(Href::Internal(Location::Event(event_hash))) + .href(state.href_with_lang(Location::Event(event_hash))) .style( Style::new() .set("display", "grid") @@ -142,12 +169,22 @@ pub fn part_detail_view(state: &AppState, definition_event_hash: &[u8; 32]) -> N ], None => vec![ A::::new() - .href(Href::Internal(Location::PartList)) - .children([text("← Back to Parts")]) + .href(state.href_with_lang(Location::PartList)) + .children([text(i18n::tr( + &state, + "← Back to Parts", + "← パーツ一覧へ戻る", + "← Reen al partoj", + ))]) .into_node(), Div::new() .style(Style::new().set("color", "var(--text-secondary)")) - .children([text("Part not found")]) + .children([text(i18n::tr( + &state, + "Part not found", + "パーツが見つかりません", + "Parto ne trovita", + ))]) .into_node(), ], }) @@ -160,7 +197,10 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< let (initial_name, initial_description, initial_expression, initial_module_hash) = effective_part_update_form(state, definition_event_hash); let dropdown_name = format!("part-update-module-{}", hash_as_base64); - let mut module_options = vec![("".to_string(), "No module".to_string())]; + let mut module_options = vec![( + "".to_string(), + i18n::tr(&state, "No module", "モジュールなし", "Neniu modulo").to_string(), + )]; module_options.extend(collect_module_snapshots(state).into_iter().map(|module| { ( crate::hash_format::encode_hash32(&module.definition_event_hash), @@ -182,7 +222,12 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .children([ Div::new() .style(Style::new().set("font-weight", "600")) - .children([text("Create PartUpdate event")]) + .children([text(i18n::tr( + &state, + "Create PartUpdate event", + "PartUpdate イベントを作成", + "Krei PartUpdate eventon", + ))]) .into_node(), Div::new() .class("mono") @@ -192,7 +237,16 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .set("opacity", "0.8") .set("word-break", "break-all"), ) - .children([text(format!("partDefinitionEventHash: {}", hash_as_base64))]) + .children([text(format!( + "{} {}", + i18n::tr( + &state, + "partDefinitionEventHash:", + "partDefinitionEventHash:", + "partDefinitionEventHash:" + ), + hash_as_base64 + ))]) .into_node(), Input::new() .type_("text") @@ -234,7 +288,7 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .set("font-size", "0.85rem") .set("color", "var(--text-secondary)"), ) - .children([text("Module")]) + .children([text(i18n::tr(&state, "Module", "モジュール", "Modulo"))]) .into_node(), crate::dropdown::searchable_dropdown( state, @@ -303,7 +357,12 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .set("color", "var(--text-secondary)") .set("font-size", "0.9rem"), ) - .children([text("Expression Builder")]) + .children([text(i18n::tr( + &state, + "Expression Builder", + "式ビルダー", + "Esprimo-konstruilo", + ))]) .into_node(), render_root_expression_editor(state, &initial_expression, EditorTarget::PartUpdate), Div::new() @@ -315,7 +374,8 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .set("opacity", "0.85"), ) .children([text(format!( - "Current: {}", + "{} {}", + i18n::tr(&state, "Current:", "現在:", "Nuna:"), expression_to_source(&initial_expression) ))]) .into_node(), @@ -331,7 +391,13 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< } else { return AppState { event_detail_eval_result: Some( - "Error: login required".to_string(), + i18n::tr( + &state, + "Error: login required", + "エラー: ログインが必要です", + "Eraro: ensaluto necesas", + ) + .to_string(), ), ..state.clone() }; @@ -347,7 +413,13 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< if part_name.is_empty() { return AppState { event_detail_eval_result: Some( - "Error: part name is required".to_string(), + i18n::tr_lang( + &state.language.code, + "Error: part name is required", + "エラー: パーツ名は必須です", + "Eraro: parto-nomo estas bezonata", + ) + .to_string(), ), ..state.clone() }; @@ -380,7 +452,13 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< Err(error) => { set_state_for_async(Box::new(move |state| AppState { event_detail_eval_result: Some(format!( - "Error: failed to serialize PartUpdate: {:?}", + "{}: {:?}", + i18n::tr( + &state, + "Error: failed to serialize PartUpdate", + "エラー: PartUpdate のシリアライズに失敗しました", + "Eraro: malsukcesis seriigi PartUpdate", + ), error )), ..state.clone() @@ -458,7 +536,12 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< .module_definition_event_hash = None; } next.event_detail_eval_result = - Some("PartUpdate event posted".to_string()); + Some(i18n::tr( + &state, + "PartUpdate event posted", + "PartUpdate を投稿しました", + "PartUpdate sendita", + ).to_string()); next })); } @@ -471,13 +554,31 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< ); next.event_detail_eval_result = Some(match status { crate::local_event::LocalEventStatus::Queued => { - "PartUpdate queued (offline)".to_string() + i18n::tr( + &state, + "PartUpdate queued (offline)", + "PartUpdate をキューに追加しました (オフライン)", + "PartUpdate envicigita (senkonekte)", + ) + .to_string() } crate::local_event::LocalEventStatus::Failed => { - "PartUpdate failed to send".to_string() + i18n::tr( + &state, + "PartUpdate failed to send", + "PartUpdate の送信に失敗しました", + "PartUpdate sendado malsukcesis", + ) + .to_string() } crate::local_event::LocalEventStatus::Sent => { - "PartUpdate event posted".to_string() + i18n::tr( + &state, + "PartUpdate event posted", + "PartUpdate を投稿しました", + "PartUpdate sendita", + ) + .to_string() } }); next @@ -487,7 +588,13 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< Err(error) => { set_state_for_async(Box::new(move |state| AppState { event_detail_eval_result: Some(format!( - "Error: failed to post PartUpdate: {:?}", + "{}: {:?}", + i18n::tr( + &state, + "Error: failed to post PartUpdate", + "エラー: PartUpdate の送信に失敗しました", + "Eraro: malsukcesis sendi PartUpdate", + ), error )), ..state.clone() @@ -498,7 +605,12 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< state })); })) - .children([text("Send PartUpdate")]) + .children([text(i18n::tr( + &state, + "Send PartUpdate", + "PartUpdate を送信", + "Sendi PartUpdate", + ))]) .into_node() }, match &state.event_detail_eval_result { @@ -520,7 +632,12 @@ fn part_update_form(state: &AppState, definition_event_hash: &[u8; 32]) -> Node< fn effective_part_update_form( state: &AppState, definition_event_hash: &[u8; 32], -) -> (String, String, definy_event::event::Expression, Option<[u8; 32]>) { +) -> ( + String, + String, + definy_event::event::Expression, + Option<[u8; 32]>, +) { if state.part_update_form.part_definition_event_hash == Some(*definition_event_hash) { return ( state.part_update_form.part_name_input.clone(), diff --git a/definy-ui/src/part_list.rs b/definy-ui/src/part_list.rs index f298aa8c..7f5d1ec3 100644 --- a/definy-ui/src/part_list.rs +++ b/definy-ui/src/part_list.rs @@ -1,6 +1,7 @@ use narumincho_vdom::*; use crate::Location; +use crate::i18n; use crate::app_state::AppState; use crate::expression_eval::expression_to_source; use crate::part_projection::collect_part_snapshots; @@ -37,7 +38,7 @@ pub fn part_list_view(state: &AppState) -> Node { .children([ H2::new() .style(Style::new().set("font-size", "1.3rem")) - .children([text("Parts")]) + .children([text(i18n::tr(state, "Parts", "パーツ", "Partoj"))]) .into_node(), if snapshots.is_empty() { Div::new() @@ -47,7 +48,12 @@ pub fn part_list_view(state: &AppState) -> Node { .set("padding", "0.95rem") .set("color", "var(--text-secondary)"), ) - .children([text("No parts yet.")]) + .children([text(i18n::tr( + state, + "No parts yet.", + "まだパーツがありません。", + "Ankoraŭ neniuj partoj.", + ))]) .into_node() } else { Div::new() @@ -93,7 +99,8 @@ pub fn part_list_view(state: &AppState) -> Node { .set("color", "var(--text-secondary)"), ) .children([text(format!( - "type: {}", + "{} {}", + i18n::tr(state, "type:", "型:", "tipo:"), optional_part_type_text(&part.part_type) ))]) .into_node(), @@ -106,14 +113,24 @@ pub fn part_list_view(state: &AppState) -> Node { .set("font-size", "0.82rem") .set("color", "var(--text-secondary)"), ) - .children([text("definition event missing")]) + .children([text(i18n::tr( + state, + "definition event missing", + "定義イベントが見つかりません", + "difina evento mankas", + ))]) .into_node() }, A::::new() - .href(Href::Internal(Location::Part( + .href(state.href_with_lang(Location::Part( part.definition_event_hash, ))) - .children([text("Open part detail")]) + .children([text(i18n::tr( + state, + "Open part detail", + "パーツ詳細を開く", + "Malfermi partajn detalojn", + ))]) .into_node(), if part.part_description.is_empty() { Div::new().children([]).into_node() @@ -135,7 +152,8 @@ pub fn part_list_view(state: &AppState) -> Node { .set("opacity", "0.8"), ) .children([text(format!( - "expression: {}", + "{} {}", + i18n::tr(state, "expression:", "式:", "esprimo:"), expression_to_source(&part.expression) ))]) .into_node(), @@ -146,7 +164,8 @@ pub fn part_list_view(state: &AppState) -> Node { .set("color", "var(--primary)"), ) .children([text(format!( - "latest author: {}", + "{} {}", + i18n::tr(state, "latest author:", "最新の投稿者:", "lasta aŭtoro:"), account_name ))]) .into_node(), @@ -158,16 +177,26 @@ pub fn part_list_view(state: &AppState) -> Node { ) .children([ A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( part.latest_event_hash, ))) - .children([text("Latest event")]) + .children([text(i18n::tr( + state, + "Latest event", + "最新イベント", + "Lasta evento", + ))]) .into_node(), A::::new() - .href(Href::Internal(Location::Event( + .href(state.href_with_lang(Location::Event( part.definition_event_hash, ))) - .children([text("Definition event")]) + .children([text(i18n::tr( + state, + "Definition event", + "定義イベント", + "Difina evento", + ))]) .into_node(), ]) .into_node(), diff --git a/definy-ui/src/query.rs b/definy-ui/src/query.rs new file mode 100644 index 00000000..58533231 --- /dev/null +++ b/definy-ui/src/query.rs @@ -0,0 +1,76 @@ +use definy_event::event::EventType; + +#[derive(serde::Serialize, serde::Deserialize, Default, Clone)] +pub struct QueryParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub lang: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_type: Option, +} + +pub fn parse_query(query: Option<&str>) -> QueryParams { + let query = query.unwrap_or(""); + if query.is_empty() { + return QueryParams::default(); + } + let mut params = QueryParams::default(); + let pairs = serde_urlencoded::from_str::>(query).unwrap_or_default(); + for (key, value) in pairs { + match key.as_str() { + "lang" => { + if value.trim().is_empty() { + params.lang = None; + } else { + params.lang = Some(value); + } + } + "event_type" => { + params.event_type = parse_event_type_value(value.as_str()); + } + _ => {} + } + } + params +} + +fn parse_event_type_value(value: &str) -> Option { + let encoded = serde_urlencoded::to_string([("event_type", value)]).ok()?; + serde_urlencoded::from_str::(encoded.as_str()) + .ok()? + .event_type +} + +pub fn build_query(mut params: QueryParams) -> Option { + if params + .lang + .as_ref() + .is_some_and(|lang| lang.trim().is_empty()) + { + params.lang = None; + } + let encoded = serde_urlencoded::to_string(params).ok()?; + if encoded.is_empty() { + None + } else { + Some(encoded) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_query_preserves_lang_when_event_type_is_invalid() { + let params = parse_query(Some("lang=ja&event_type=invalid")); + assert_eq!(params.lang.as_deref(), Some("ja")); + assert_eq!(params.event_type, None); + } + + #[test] + fn parse_query_parses_both_lang_and_event_type() { + let params = parse_query(Some("lang=ja&event_type=part_definition")); + assert_eq!(params.lang.as_deref(), Some("ja")); + assert_eq!(params.event_type, Some(EventType::PartDefinition)); + } +} diff --git a/definy-ui/tests/browser_e2e.rs b/definy-ui/tests/browser_e2e.rs index 70bc72dd..f3c98b3f 100644 --- a/definy-ui/tests/browser_e2e.rs +++ b/definy-ui/tests/browser_e2e.rs @@ -269,7 +269,8 @@ impl WebDriverClient { async fn wait_for_url(&self, expected: &str) -> Result<(), Box> { for _ in 0..40 { - if self.current_url().await? == expected { + let current = self.current_url().await?; + if url_matches_expected(expected, current.as_str()) { return Ok(()); } sleep(Duration::from_millis(100)).await; @@ -367,6 +368,13 @@ fn snippet(text: &str) -> String { out } +fn url_matches_expected(expected: &str, current: &str) -> bool { + current == expected + || current + .strip_prefix(expected) + .is_some_and(|suffix| suffix.starts_with('?') || suffix.starts_with('#')) +} + async fn webdriver_request( client: &Client>, base_url: &str, @@ -530,6 +538,8 @@ fn render_html_response(path: &str) -> Response> { last_error: None, }, location, + language: definy_ui::language::default_language(), + language_fallback_notice: None, }, &Some(definy_ui::ResourceHash { js: TEST_JAVASCRIPT_HASH.to_string(), @@ -578,3 +588,23 @@ fn handle_request(request: Request) -> Response> { _ => render_html_response(path), } } + +#[test] +fn wait_url_match_allows_query_or_fragment() { + assert!(url_matches_expected( + "http://127.0.0.1:1234/", + "http://127.0.0.1:1234/" + )); + assert!(url_matches_expected( + "http://127.0.0.1:1234/", + "http://127.0.0.1:1234/?lang=en" + )); + assert!(url_matches_expected( + "http://127.0.0.1:1234/", + "http://127.0.0.1:1234/#top" + )); + assert!(!url_matches_expected( + "http://127.0.0.1:1234/", + "http://127.0.0.1:1234/unknown-page" + )); +}