diff --git a/Cargo.toml b/Cargo.toml index b265199..9c44af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ "rc_singleplayer", "rc_singleplayer_room", "rc_lobby", "rc_lobby_room", "rc_core", "rc_database", "rc_factory", - "rc_factory_api" ,"rc_multiplayer", + "rc_factory_web" ,"rc_multiplayer", "rc_plugins", ] diff --git a/assets/templates/rc_factory_web/app.js.html.hbs b/assets/templates/rc_factory_web/app.js.html.hbs new file mode 100644 index 0000000..0c2973d --- /dev/null +++ b/assets/templates/rc_factory_web/app.js.html.hbs @@ -0,0 +1,220 @@ +(function () { + const $ = (id) => document.getElementById(id); + + const TIER_MAX = { + "1": 1000, + "2": 6434, + "3": 79999, + "4": 1299999, + "5": 20000000, + "M": 100000000, + }; + + const state = { + page: 1, + pageSize: 100, + lastItemCount: 0, + }; + + const basePayload = { + page: 1, + pageSize: 100, + order: 0, + playerFilter: false, + movementFilter: "100000,200000,300000,400000,500000,600000,700000,800000,900000,1000000,1100000,1200000", + movementCategoryFilter: "100000,200000,300000,400000,500000,600000,700000,800000,900000,1000000,1100000,1200000", + weaponFilter: "10000000,20000000,25000000,30000000,40000000,50000000,60000000,65000000,70100000,75000000", + weaponCategoryFilter: "10000000,20000000,25000000,30000000,40000000,50000000,60000000,65000000,70100000,75000000", + minimumCpu: -1, + maximumCpu: -1, + textFilter: "", + textSearchField: 0, // ALL=0, PLAYER=1, NAME=2 + buyable: true, + prependFeaturedRobot: false, + featuredOnly: false, + defaultPage: false + }; + + function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + async function fetchJson(url, opts) { + const r = await fetch(url, opts); + const t = await r.text(); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}: ${t}`); + try { return JSON.parse(t); } catch { return t; } + } + + function buildPayload() { + const q = ($("q").value || "").trim(); + const textSearchField = Number($("searchField").value || "0"); // ALL=0, PLAYER=1, NAME=2 + const order = Number($("order").value || "0"); + const tier = $("tier").value || ""; // "" or "1".."5" or "M" + + const p = { ...basePayload }; + p.page = state.page; + p.pageSize = state.pageSize; + p.order = order; + p.textFilter = q; + p.textSearchField = textSearchField; + + p.playerFilter = (textSearchField === 1); + + if (tier !== "") { + const mx = TIER_MAX[tier] ?? 0; + if (mx > 0) { + p.minimumRobotRanking = 0; + p.maximumRobotRanking = mx; + } + } + + return p; + } + + function setStatus(msg) { + $("status").textContent = msg || ""; + } + + function setPagerEnabled() { + const prevDisabled = state.page <= 1; + $("btnPrevTop").disabled = prevDisabled; + $("btnPrevBottom").disabled = prevDisabled; + + const nextDisabled = state.lastItemCount < state.pageSize; + $("btnNextTop").disabled = nextDisabled; + $("btnNextBottom").disabled = nextDisabled; + } + + function renderList(data) { + const grid = $("grid"); + grid.innerHTML = ""; + + const items = data?.response?.roboShopItems ?? []; + state.lastItemCount = items.length; + + setStatus(`page: ${state.page} / items: ${items.length}`); + + for (const it of items) { + const id = it.itemId; + const name = it.itemName; + const thumb = it.thumbnail; + const author = it.addedByDisplayName; + + const card = document.createElement("div"); + card.className = "card"; + + const img = document.createElement("img"); + img.className = "thumb"; + img.loading = "lazy"; + if (thumb) img.src = thumb; + card.appendChild(img); + + const h = document.createElement("div"); + h.style.marginTop = "8px"; + h.innerHTML = `${escapeHtml(name)}
id: ${escapeHtml(id ?? "?")}
`; + card.appendChild(h); + + const p = document.createElement("div"); + p.className = "muted"; + p.textContent = author ? `by: ${author}` : ""; + p.style.marginTop = "6px"; + card.appendChild(p); + + const btn = document.createElement("button"); + btn.className = "btn"; + btn.textContent = "About"; + btn.addEventListener("click", async () => { + if (id == null) return; + try { await doGet(id); } catch (e) { showDialog(`id: ${id}`, String(e)); } + }); + card.appendChild(btn); + + grid.appendChild(card); + } + + setPagerEnabled(); + } + + function showDialog(title, body) { + $("dlgTitle").textContent = title || ""; + $("detail").textContent = body || ""; + const dlg = $("dlg"); + if (!dlg.open) dlg.showModal(); + } + + async function doSearch(resetPage) { + if (resetPage) state.page = 1; + + setStatus("loading..."); + + const payload = buildPayload(); + const data = await fetchJson("/api/roboShopItems/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + renderList(data); + } + + async function doGet(id) { + const data = await fetchJson(`/api/roboShopItems/get/${encodeURIComponent(id)}`, { method: "GET" }); + + if (typeof data === "string") { + showDialog(`id: ${id}`, data); + return; + } + + const resp = data?.response ?? data; + + const title = + resp?.name + ? `${resp.name} (id: ${resp.id ?? id})` + : `id: ${id}`; + + showDialog(title, JSON.stringify(resp, null, 2)); + } + + $("btnSearch").addEventListener("click", async () => { + try { await doSearch(true); } catch (e) { setStatus(String(e)); } + }); + + $("btnPrevTop").addEventListener("click", async () => { + if (state.page <= 1) return; + state.page--; + try { await doSearch(false); } catch (e) { setStatus(String(e)); } + }); + $("btnPrevBottom").addEventListener("click", async () => { + if (state.page <= 1) return; + state.page--; + try { await doSearch(false); } catch (e) { setStatus(String(e)); } + }); + + $("btnNextTop").addEventListener("click", async () => { + state.page++; + try { await doSearch(false); } catch (e) { setStatus(String(e)); } + }); + $("btnNextBottom").addEventListener("click", async () => { + state.page++; + try { await doSearch(false); } catch (e) { setStatus(String(e)); } + }); + + $("q").addEventListener("keydown", async (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + try { await doSearch(true); } catch (e) { setStatus(String(e)); } + } + }); + + $("dlgClose").addEventListener("click", () => { + $("dlg").close(); + }); + + doSearch(true).catch((e) => setStatus(String(e))); +})(); diff --git a/assets/templates/rc_factory_web/index.html.hbs b/assets/templates/rc_factory_web/index.html.hbs new file mode 100644 index 0000000..d34a834 --- /dev/null +++ b/assets/templates/rc_factory_web/index.html.hbs @@ -0,0 +1,282 @@ + + + + + + Factory + + + + +
+
+
+
+ + + +
+ +
+ + + +
+
+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ + +
+
+ +
+
+

+    
+
+ + + + + + diff --git a/rc_factory_api/Cargo.toml b/rc_factory_web/Cargo.toml similarity index 78% rename from rc_factory_api/Cargo.toml rename to rc_factory_web/Cargo.toml index e98c4db..4db98c0 100644 --- a/rc_factory_api/Cargo.toml +++ b/rc_factory_web/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "oj_factory_api" +name = "oj_factory_web" version.workspace = true edition.workspace = true repository.workspace = true @@ -9,12 +9,15 @@ readme.workspace = true [dependencies] actix-web.workspace = true +actix-files.workspace = true clap.workspace = true log.workspace = true env_logger.workspace = true git-version.workspace = true base64.workspace = true +serde.workspace = true serde_json.workspace = true +handlebars = { version = "6", features = ["dir_source"] } libfj.workspace = true oj_rc_core = { version = "*", path = "../rc_core" } diff --git a/rc_factory_api/run_debug.sh b/rc_factory_web/run_debug.sh similarity index 100% rename from rc_factory_api/run_debug.sh rename to rc_factory_web/run_debug.sh diff --git a/rc_factory_api/src/cli.rs b/rc_factory_web/src/cli.rs similarity index 100% rename from rc_factory_api/src/cli.rs rename to rc_factory_web/src/cli.rs diff --git a/rc_factory_api/src/main.rs b/rc_factory_web/src/main.rs similarity index 59% rename from rc_factory_api/src/main.rs rename to rc_factory_web/src/main.rs index 1cddbeb..8f82b71 100644 --- a/rc_factory_api/src/main.rs +++ b/rc_factory_web/src/main.rs @@ -4,7 +4,7 @@ mod robocraft; use actix_web::{App, HttpServer, Responder}; use oj_rc_core::persist::config::{ConfigImpl, ConfigProvider}; -#[actix_web::get("/")] +#[actix_web::get("/version")] async fn index() -> impl Responder { let name = env!("CARGO_PKG_NAME"); let version = env!("CARGO_PKG_VERSION"); @@ -26,13 +26,33 @@ async fn main() -> std::io::Result<()> { let conf = ConfigImpl::load(&cli_args.assets).map_err(io_error)?; let factory_enum = >::factory(&conf).await.map_err(io_error)?; - let factory_data = actix_web::web::Data::new(factory_enum); + let mut handlebars_conf = handlebars::Handlebars::new(); + let mut dir_conf = handlebars::DirectorySourceOptions::default(); + dir_conf.tpl_extension = ".html.hbs".to_owned(); + dir_conf.hidden = false; + dir_conf.temporary = false; + handlebars_conf + .register_templates_directory( + std::path::PathBuf::from(&cli_args.assets).parent().expect("Bad asset path").join("templates"), + dir_conf, + ) + .unwrap(); + let handlebars_ref = actix_web::web::Data::new(handlebars_conf); + + let assets_root = actix_web::web::Data::new(std::path::PathBuf::from(&cli_args.assets)); + HttpServer::new(move || { App::new() .app_data(factory_data.clone()) + .app_data(assets_root.clone()) + .app_data(handlebars_ref.clone()) .service(index) + .service(robocraft::web_ui::index) + .service(robocraft::web_ui::app_js) + .service(robocraft::web_ui::favicon) + .service(robocraft::web_ui::favicon_standard) .service(robocraft::factory::crf_api::list) .service(robocraft::factory::crf_api::list_default) .service(robocraft::factory::crf_api::get) diff --git a/rc_factory_api/src/robocraft/factory/crf_api.rs b/rc_factory_web/src/robocraft/factory/crf_api.rs similarity index 100% rename from rc_factory_api/src/robocraft/factory/crf_api.rs rename to rc_factory_web/src/robocraft/factory/crf_api.rs diff --git a/rc_factory_api/src/robocraft/factory/mod.rs b/rc_factory_web/src/robocraft/factory/mod.rs similarity index 100% rename from rc_factory_api/src/robocraft/factory/mod.rs rename to rc_factory_web/src/robocraft/factory/mod.rs diff --git a/rc_factory_api/src/robocraft/mod.rs b/rc_factory_web/src/robocraft/mod.rs similarity index 53% rename from rc_factory_api/src/robocraft/mod.rs rename to rc_factory_web/src/robocraft/mod.rs index a106d20..56e8be3 100644 --- a/rc_factory_api/src/robocraft/mod.rs +++ b/rc_factory_web/src/robocraft/mod.rs @@ -1 +1,2 @@ pub mod factory; +pub mod web_ui; \ No newline at end of file diff --git a/rc_factory_web/src/robocraft/web_ui.rs b/rc_factory_web/src/robocraft/web_ui.rs new file mode 100644 index 0000000..b8f3b60 --- /dev/null +++ b/rc_factory_web/src/robocraft/web_ui.rs @@ -0,0 +1,59 @@ +use actix_web::{get, web::Data, HttpResponse}; +use handlebars::Handlebars; + +const TEMPLATE_INDEX: &str = "rc_factory_web/index"; +const TEMPLATE_APP_JS: &str = "rc_factory_web/app.js"; + +#[derive(serde::Serialize)] +struct Context { + version: String, + source_url: String, +} + +fn version_string() -> String { + let name = env!("CARGO_PKG_NAME"); + let version = env!("CARGO_PKG_VERSION"); + //let license = env!("CARGO_PKG_LICENSE"); + //let repo = env!("CARGO_PKG_REPOSITORY"); + format!("OpenJam {} {}", name, version) +} + +#[get("/")] +pub async fn index(hb: Data>) -> HttpResponse { + let ctx = Context { + version: version_string(), + source_url: env!("CARGO_PKG_REPOSITORY").to_string(), + }; + + match hb.render(TEMPLATE_INDEX, &ctx) { + Ok(body) => HttpResponse::Ok() + .insert_header(("Content-Type", "text/html; charset=utf-8")) + .body(body), + Err(e) => HttpResponse::InternalServerError().body(format!("template render error: {e}")), + } +} + +#[get("/app.js")] +pub async fn app_js(hb: Data>) -> HttpResponse { + match hb.render(TEMPLATE_APP_JS, &()) { + Ok(body) => HttpResponse::Ok() + .insert_header(("Content-Type", "application/javascript; charset=utf-8")) + .body(body), + Err(e) => HttpResponse::InternalServerError().body(format!("template render error: {e}")), + } +} + +async fn favicon_impl(assets_root: Data) -> impl actix_web::Responder { + let path = assets_root.join("favicon.jpg"); + actix_files::NamedFile::open_async(path).await +} + +#[get("/robocraft/favicon")] +pub async fn favicon(assets_root: Data) -> impl actix_web::Responder { + favicon_impl(assets_root).await +} + +#[get("/favicon.ico")] +pub async fn favicon_standard(assets_root: Data) -> impl actix_web::Responder { + favicon_impl(assets_root).await +} \ No newline at end of file