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