@@ -50,6 +50,7 @@ add_executable(lidar_manager_web
|
|||||||
src/io/io_zone_runtime.cpp
|
src/io/io_zone_runtime.cpp
|
||||||
src/io/io_module_usage.cpp
|
src/io/io_module_usage.cpp
|
||||||
src/storage/path_store.cpp
|
src/storage/path_store.cpp
|
||||||
|
src/storage/path_guide_store.cpp
|
||||||
src/path/path_planner.cpp
|
src/path/path_planner.cpp
|
||||||
src/path/path_service.cpp
|
src/path/path_service.cpp
|
||||||
src/storage/dashboard_store.cpp
|
src/storage/dashboard_store.cpp
|
||||||
@@ -70,6 +71,7 @@ add_executable(lidar_manager_web
|
|||||||
src/server/api_transition_routes.cpp
|
src/server/api_transition_routes.cpp
|
||||||
src/server/api_io_module_routes.cpp
|
src/server/api_io_module_routes.cpp
|
||||||
src/server/api_path_routes.cpp
|
src/server/api_path_routes.cpp
|
||||||
|
src/server/api_path_guide_routes.cpp
|
||||||
src/server/api_dashboard_routes.cpp
|
src/server/api_dashboard_routes.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "io/io_zone_runtime.hpp"
|
#include "io/io_zone_runtime.hpp"
|
||||||
#include "path/path_service.hpp"
|
#include "path/path_service.hpp"
|
||||||
#include "storage/path_store.hpp"
|
#include "storage/path_store.hpp"
|
||||||
|
#include "storage/path_guide_store.hpp"
|
||||||
#include "mission/mission_enqueue.hpp"
|
#include "mission/mission_enqueue.hpp"
|
||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
#include "mission/mission_scheduler.hpp"
|
#include "mission/mission_scheduler.hpp"
|
||||||
@@ -73,6 +74,7 @@ int LidarManagerApp::run()
|
|||||||
IoModuleService io_module_service(io_module_store);
|
IoModuleService io_module_service(io_module_store);
|
||||||
IoZoneRuntime io_zone_runtime;
|
IoZoneRuntime io_zone_runtime;
|
||||||
PathStore path_store(database);
|
PathStore path_store(database);
|
||||||
|
PathGuideStore path_guide_store(database);
|
||||||
PathService path_service(path_store, map_store);
|
PathService path_service(path_store, map_store);
|
||||||
MissionStore mission_store(database);
|
MissionStore mission_store(database);
|
||||||
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
|
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
|
||||||
@@ -118,6 +120,7 @@ int LidarManagerApp::run()
|
|||||||
io_module_store,
|
io_module_store,
|
||||||
io_module_service,
|
io_module_service,
|
||||||
path_store,
|
path_store,
|
||||||
|
path_guide_store,
|
||||||
path_service,
|
path_service,
|
||||||
dashboard_store);
|
dashboard_store);
|
||||||
api.registerRoutes(svr);
|
api.registerRoutes(svr);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
{"transitions", transition_store_.list(site_id)},
|
{"transitions", transition_store_.list(site_id)},
|
||||||
{"io_modules", io_module_store_.list(site_id)},
|
{"io_modules", io_module_store_.list(site_id)},
|
||||||
{"paths", path_store_.list(site_id)},
|
{"paths", path_store_.list(site_id)},
|
||||||
|
{"path_guides", path_guide_store_.list(site_id)},
|
||||||
{"sounds", sound_store_.list()}};
|
{"sounds", sound_store_.list()}};
|
||||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
res.body = bundle.dump(2);
|
res.body = bundle.dump(2);
|
||||||
@@ -114,6 +115,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
nlohmann::json imported = {{"maps", nlohmann::json::array()},
|
nlohmann::json imported = {{"maps", nlohmann::json::array()},
|
||||||
{"io_modules", nlohmann::json::array()},
|
{"io_modules", nlohmann::json::array()},
|
||||||
{"paths", nlohmann::json::array()},
|
{"paths", nlohmann::json::array()},
|
||||||
|
{"path_guides", nlohmann::json::array()},
|
||||||
{"transitions", nlohmann::json::array()}};
|
{"transitions", nlohmann::json::array()}};
|
||||||
std::unordered_map<std::string, std::string> map_id_remap;
|
std::unordered_map<std::string, std::string> map_id_remap;
|
||||||
|
|
||||||
@@ -181,6 +183,33 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bundle.contains("path_guides") && bundle["path_guides"].is_array())
|
||||||
|
{
|
||||||
|
for (const auto& guide : bundle["path_guides"])
|
||||||
|
{
|
||||||
|
if (!guide.is_object())
|
||||||
|
continue;
|
||||||
|
nlohmann::json payload = guide;
|
||||||
|
payload.erase("id");
|
||||||
|
payload["site_id"] = site_id;
|
||||||
|
const std::string old_map = payload.value("map_id", "");
|
||||||
|
if (map_id_remap.count(old_map))
|
||||||
|
payload["map_id"] = map_id_remap[old_map];
|
||||||
|
if (const AuthSession* session = AuthService::activeSession())
|
||||||
|
{
|
||||||
|
if (!payload.contains("created_by") || payload["created_by"].get<std::string>().empty())
|
||||||
|
payload["created_by"] = session->group_name.empty() ? session->username : session->group_name;
|
||||||
|
if (!payload.contains("created_by_group") || payload["created_by_group"].get<std::string>().empty())
|
||||||
|
payload["created_by_group"] = session->group_id;
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto created = path_guide_store_.create(payload, err);
|
||||||
|
if (!created)
|
||||||
|
return HttpUtil::jsonError(res, 400, "path guide import failed: " + err);
|
||||||
|
imported["path_guides"].push_back(*created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bundle.contains("transitions") && bundle["transitions"].is_array())
|
if (bundle.contains("transitions") && bundle["transitions"].is_array())
|
||||||
{
|
{
|
||||||
for (const auto& tr : bundle["transitions"])
|
for (const auto& tr : bundle["transitions"])
|
||||||
|
|||||||
96
src/server/api_path_guide_routes.cpp
Normal file
96
src/server/api_path_guide_routes.cpp
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#include "server/api_server.hpp"
|
||||||
|
|
||||||
|
#include "auth/auth_service.hpp"
|
||||||
|
#include "util/http_util.hpp"
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
void ApiServer::registerPathGuideRoutes(httplib::Server& svr)
|
||||||
|
{
|
||||||
|
svr.Get("/api/path_guides", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : "";
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"path_guides", path_guide_store_.list(site_id)}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Get(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto guide = path_guide_store_.find(id);
|
||||||
|
if (!guide)
|
||||||
|
return HttpUtil::jsonError(res, 404, "path guide not found");
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = guide->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/path_guides", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (const AuthSession* session = AuthService::activeSession())
|
||||||
|
{
|
||||||
|
if (!body.contains("created_by") || !body["created_by"].is_string() ||
|
||||||
|
body["created_by"].get<std::string>().empty())
|
||||||
|
{
|
||||||
|
body["created_by"] = session->group_name.empty() ? session->username : session->group_name;
|
||||||
|
}
|
||||||
|
if (!body.contains("created_by_group") || !body["created_by_group"].is_string() ||
|
||||||
|
body["created_by_group"].get<std::string>().empty())
|
||||||
|
body["created_by_group"] = session->group_id;
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto created = path_guide_store_.create(body, err);
|
||||||
|
if (!created)
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = created->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
nlohmann::json body;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!path_guide_store_.update(id, body, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
const auto updated = path_guide_store_.find(id);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1];
|
||||||
|
const auto existing = path_guide_store_.find(id);
|
||||||
|
if (!existing)
|
||||||
|
return HttpUtil::jsonError(res, 404, "path guide not found");
|
||||||
|
if (const AuthSession* session = AuthService::activeSession())
|
||||||
|
{
|
||||||
|
if (!AuthService::canDeleteMap(*existing, *session))
|
||||||
|
return HttpUtil::jsonError(res, 403, "cannot delete path guide from another user group");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
if (!path_guide_store_.remove(id, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -23,6 +23,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
IoModuleStore& io_module_store,
|
IoModuleStore& io_module_store,
|
||||||
IoModuleService& io_module_service,
|
IoModuleService& io_module_service,
|
||||||
PathStore& path_store,
|
PathStore& path_store,
|
||||||
|
PathGuideStore& path_guide_store,
|
||||||
PathService& path_service,
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store)
|
DashboardStore& dashboard_store)
|
||||||
: repo_(repo),
|
: repo_(repo),
|
||||||
@@ -38,6 +39,7 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
io_module_store_(io_module_store),
|
io_module_store_(io_module_store),
|
||||||
io_module_service_(io_module_service),
|
io_module_service_(io_module_service),
|
||||||
path_store_(path_store),
|
path_store_(path_store),
|
||||||
|
path_guide_store_(path_guide_store),
|
||||||
path_service_(path_service),
|
path_service_(path_service),
|
||||||
dashboard_store_(dashboard_store)
|
dashboard_store_(dashboard_store)
|
||||||
{
|
{
|
||||||
@@ -563,6 +565,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
registerTransitionRoutes(svr);
|
registerTransitionRoutes(svr);
|
||||||
registerIoModuleRoutes(svr);
|
registerIoModuleRoutes(svr);
|
||||||
registerPathRoutes(svr);
|
registerPathRoutes(svr);
|
||||||
|
registerPathGuideRoutes(svr);
|
||||||
registerDashboardRoutes(svr);
|
registerDashboardRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "storage/io_module_store.hpp"
|
#include "storage/io_module_store.hpp"
|
||||||
#include "io/io_module_service.hpp"
|
#include "io/io_module_service.hpp"
|
||||||
#include "storage/path_store.hpp"
|
#include "storage/path_store.hpp"
|
||||||
|
#include "storage/path_guide_store.hpp"
|
||||||
#include "path/path_service.hpp"
|
#include "path/path_service.hpp"
|
||||||
#include "storage/transition_store.hpp"
|
#include "storage/transition_store.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
@@ -36,6 +37,7 @@ public:
|
|||||||
IoModuleStore& io_module_store,
|
IoModuleStore& io_module_store,
|
||||||
IoModuleService& io_module_service,
|
IoModuleService& io_module_service,
|
||||||
PathStore& path_store,
|
PathStore& path_store,
|
||||||
|
PathGuideStore& path_guide_store,
|
||||||
PathService& path_service,
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store);
|
DashboardStore& dashboard_store);
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ private:
|
|||||||
IoModuleStore& io_module_store_;
|
IoModuleStore& io_module_store_;
|
||||||
IoModuleService& io_module_service_;
|
IoModuleService& io_module_service_;
|
||||||
PathStore& path_store_;
|
PathStore& path_store_;
|
||||||
|
PathGuideStore& path_guide_store_;
|
||||||
PathService& path_service_;
|
PathService& path_service_;
|
||||||
DashboardStore& dashboard_store_;
|
DashboardStore& dashboard_store_;
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ private:
|
|||||||
void registerTransitionRoutes(httplib::Server& svr);
|
void registerTransitionRoutes(httplib::Server& svr);
|
||||||
void registerIoModuleRoutes(httplib::Server& svr);
|
void registerIoModuleRoutes(httplib::Server& svr);
|
||||||
void registerPathRoutes(httplib::Server& svr);
|
void registerPathRoutes(httplib::Server& svr);
|
||||||
|
void registerPathGuideRoutes(httplib::Server& svr);
|
||||||
void registerDashboardRoutes(httplib::Server& svr);
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,20 @@ CREATE TABLE IF NOT EXISTS paths (
|
|||||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE
|
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS path_guides (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
map_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
positions_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_by TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by_group TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dashboards (
|
CREATE TABLE IF NOT EXISTS dashboards (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -418,9 +432,12 @@ bool Database::applySchemaMigrations(std::string& err)
|
|||||||
|
|
||||||
ver = getMeta("schema_version").value_or("1");
|
ver = getMeta("schema_version").value_or("1");
|
||||||
if (ver == "5")
|
if (ver == "5")
|
||||||
|
{
|
||||||
|
if (!tableHasColumn(db_, "transitions", "created_by"))
|
||||||
{
|
{
|
||||||
if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
|
if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err))
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
setMeta("schema_version", "6");
|
setMeta("schema_version", "6");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +486,28 @@ bool Database::applySchemaMigrations(std::string& err)
|
|||||||
setMeta("schema_version", "8");
|
setMeta("schema_version", "8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ver = getMeta("schema_version").value_or("1");
|
||||||
|
if (ver == "8")
|
||||||
|
{
|
||||||
|
if (!execSql(db_,
|
||||||
|
"CREATE TABLE IF NOT EXISTS path_guides ("
|
||||||
|
"id TEXT PRIMARY KEY, "
|
||||||
|
"site_id TEXT NOT NULL, "
|
||||||
|
"map_id TEXT NOT NULL, "
|
||||||
|
"name TEXT NOT NULL, "
|
||||||
|
"positions_json TEXT NOT NULL DEFAULT '[]', "
|
||||||
|
"created_by TEXT NOT NULL DEFAULT '', "
|
||||||
|
"created_by_group TEXT NOT NULL DEFAULT '', "
|
||||||
|
"created_at TEXT NOT NULL, "
|
||||||
|
"updated_at TEXT NOT NULL, "
|
||||||
|
"FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, "
|
||||||
|
"FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE"
|
||||||
|
")",
|
||||||
|
err))
|
||||||
|
return false;
|
||||||
|
setMeta("schema_version", "9");
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
391
src/storage/path_guide_store.cpp
Normal file
391
src/storage/path_guide_store.cpp
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
#include "storage/path_guide_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
#include "util/string_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
nlohmann::json parsePositionsJson(const std::string& raw)
|
||||||
|
{
|
||||||
|
if (raw.empty())
|
||||||
|
return nlohmann::json::array();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto parsed = nlohmann::json::parse(raw);
|
||||||
|
return parsed.is_array() ? parsed : nlohmann::json::array();
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return nlohmann::json::array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||||
|
{
|
||||||
|
auto text = [&](int col) -> std::string {
|
||||||
|
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||||
|
return "";
|
||||||
|
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
|
||||||
|
return v ? std::string(v) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const nlohmann::json positions = parsePositionsJson(text(4));
|
||||||
|
int starts_count = 0;
|
||||||
|
int vias_count = 0;
|
||||||
|
int goals_count = 0;
|
||||||
|
for (const auto& pos : positions)
|
||||||
|
{
|
||||||
|
if (!pos.is_object())
|
||||||
|
continue;
|
||||||
|
const std::string role = StringUtil::toLower(pos.value("role", ""));
|
||||||
|
if (role == "start")
|
||||||
|
++starts_count;
|
||||||
|
else if (role == "via")
|
||||||
|
++vias_count;
|
||||||
|
else if (role == "goal")
|
||||||
|
++goals_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {{"id", text(0)},
|
||||||
|
{"site_id", text(1)},
|
||||||
|
{"map_id", text(2)},
|
||||||
|
{"name", text(3)},
|
||||||
|
{"positions", positions},
|
||||||
|
{"starts_count", starts_count},
|
||||||
|
{"vias_count", vias_count},
|
||||||
|
{"goals_count", goals_count},
|
||||||
|
{"created_by", text(5)},
|
||||||
|
{"created_by_group", text(6)},
|
||||||
|
{"created_at", text(7)},
|
||||||
|
{"updated_at", text(8)}};
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr const char* kSelect =
|
||||||
|
"SELECT id, site_id, map_id, name, positions_json, created_by, created_by_group, created_at, updated_at "
|
||||||
|
"FROM path_guides";
|
||||||
|
|
||||||
|
bool validRole(const std::string& role)
|
||||||
|
{
|
||||||
|
return role == "start" || role == "via" || role == "goal";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> normalizePositions(const nlohmann::json& raw, std::string& err)
|
||||||
|
{
|
||||||
|
if (!raw.is_array())
|
||||||
|
{
|
||||||
|
err = "positions must be an array";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
int starts = 0;
|
||||||
|
int goals = 0;
|
||||||
|
int via_priority = 0;
|
||||||
|
|
||||||
|
for (const auto& item : raw)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
const std::string position_id = StringUtil::trimCopy(item.value("position_id", ""));
|
||||||
|
const std::string role = StringUtil::toLower(StringUtil::trimCopy(item.value("role", "")));
|
||||||
|
if (position_id.empty() || !validRole(role))
|
||||||
|
{
|
||||||
|
err = "invalid position entry";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json entry = {{"position_id", position_id}, {"role", role}};
|
||||||
|
if (role == "via")
|
||||||
|
{
|
||||||
|
int priority = via_priority + 1;
|
||||||
|
if (item.contains("priority") && item["priority"].is_number_integer())
|
||||||
|
priority = item["priority"].get<int>();
|
||||||
|
if (priority <= 0)
|
||||||
|
priority = via_priority + 1;
|
||||||
|
via_priority = priority;
|
||||||
|
entry["priority"] = priority;
|
||||||
|
}
|
||||||
|
else if (role == "start")
|
||||||
|
{
|
||||||
|
++starts;
|
||||||
|
}
|
||||||
|
else if (role == "goal")
|
||||||
|
{
|
||||||
|
++goals;
|
||||||
|
}
|
||||||
|
out.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (starts < 1 || goals < 1)
|
||||||
|
{
|
||||||
|
err = "path guide requires at least one start and one goal";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) {
|
||||||
|
const std::string ra = a.value("role", "");
|
||||||
|
const std::string rb = b.value("role", "");
|
||||||
|
if (ra != rb)
|
||||||
|
{
|
||||||
|
if (ra == "start")
|
||||||
|
return true;
|
||||||
|
if (rb == "start")
|
||||||
|
return false;
|
||||||
|
if (ra == "via")
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ra == "via")
|
||||||
|
return a.value("priority", 0) < b.value("priority", 0);
|
||||||
|
return a.value("position_id", "") < b.value("position_id", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PathGuideStore::PathGuideStore(Database& db) : db_(db) {}
|
||||||
|
|
||||||
|
bool PathGuideStore::findNameConflictUnlocked(const std::string& site_id,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string& except_id) const
|
||||||
|
{
|
||||||
|
const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name));
|
||||||
|
if (needle.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql = except_id.empty()
|
||||||
|
? "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1"
|
||||||
|
: "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1";
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return false;
|
||||||
|
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (!except_id.empty())
|
||||||
|
sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool conflict = sqlite3_step(stmt) == SQLITE_ROW;
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json PathGuideStore::list(const std::string& site_id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json out = nlohmann::json::array();
|
||||||
|
std::string sql = kSelect;
|
||||||
|
if (!site_id.empty())
|
||||||
|
sql += " WHERE site_id = ?1";
|
||||||
|
sql += " ORDER BY site_id, map_id, name";
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return out;
|
||||||
|
if (!site_id.empty())
|
||||||
|
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out.push_back(rowToJson(stmt));
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> PathGuideStore::find(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
return std::nullopt;
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
std::optional<nlohmann::json> out;
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||||
|
out = rowToJson(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> PathGuideStore::create(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
|
||||||
|
const std::string map_id = StringUtil::trimCopy(payload.value("map_id", ""));
|
||||||
|
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||||
|
const std::string created_by = StringUtil::trimCopy(payload.value("created_by", ""));
|
||||||
|
const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", ""));
|
||||||
|
const auto positions = normalizePositions(payload.value("positions", nlohmann::json::array()), err);
|
||||||
|
if (!positions)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
if (site_id.empty() || map_id.empty() || name.empty())
|
||||||
|
{
|
||||||
|
err = "missing required fields";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (findNameConflictUnlocked(site_id, name, ""))
|
||||||
|
{
|
||||||
|
err = "path guide name already exists for this site";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string id = payload.value("id", IdUtil::newId());
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
const std::string positions_json = positions->dump();
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"INSERT INTO path_guides(id, site_id, map_id, name, positions_json, created_by, "
|
||||||
|
"created_by_group, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
nlohmann::json created = {{"id", id},
|
||||||
|
{"site_id", site_id},
|
||||||
|
{"map_id", map_id},
|
||||||
|
{"name", name},
|
||||||
|
{"positions", *positions},
|
||||||
|
{"created_by", created_by},
|
||||||
|
{"created_by_group", created_by_group},
|
||||||
|
{"created_at", now},
|
||||||
|
{"updated_at", now}};
|
||||||
|
int starts_count = 0;
|
||||||
|
int vias_count = 0;
|
||||||
|
int goals_count = 0;
|
||||||
|
for (const auto& pos : *positions)
|
||||||
|
{
|
||||||
|
const std::string role = pos.value("role", "");
|
||||||
|
if (role == "start")
|
||||||
|
++starts_count;
|
||||||
|
else if (role == "via")
|
||||||
|
++vias_count;
|
||||||
|
else if (role == "goal")
|
||||||
|
++goals_count;
|
||||||
|
}
|
||||||
|
created["starts_count"] = starts_count;
|
||||||
|
created["vias_count"] = vias_count;
|
||||||
|
created["goals_count"] = goals_count;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathGuideStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
auto existing = find(id);
|
||||||
|
if (!existing)
|
||||||
|
{
|
||||||
|
err = "path guide not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json merged = *existing;
|
||||||
|
for (const char* key : {"site_id", "map_id", "name"})
|
||||||
|
{
|
||||||
|
if (payload.contains(key))
|
||||||
|
merged[key] = payload[key];
|
||||||
|
}
|
||||||
|
if (payload.contains("positions"))
|
||||||
|
merged["positions"] = payload["positions"];
|
||||||
|
|
||||||
|
const std::string site_id = StringUtil::trimCopy(merged.value("site_id", ""));
|
||||||
|
const std::string map_id = StringUtil::trimCopy(merged.value("map_id", ""));
|
||||||
|
const std::string name = StringUtil::trimCopy(merged.value("name", ""));
|
||||||
|
const auto positions = normalizePositions(merged.value("positions", nlohmann::json::array()), err);
|
||||||
|
if (!positions)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (site_id.empty() || map_id.empty() || name.empty())
|
||||||
|
{
|
||||||
|
err = "missing required fields";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (findNameConflictUnlocked(site_id, name, id))
|
||||||
|
{
|
||||||
|
err = "path guide name already exists for this site";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string now = IdUtil::nowIso8601();
|
||||||
|
const std::string positions_json = positions->dump();
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(),
|
||||||
|
"UPDATE path_guides SET site_id=?2, map_id=?3, name=?4, positions_json=?5, updated_at=?6 WHERE id=?1",
|
||||||
|
-1,
|
||||||
|
&stmt,
|
||||||
|
nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathGuideStore::remove(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
if (!find(id))
|
||||||
|
{
|
||||||
|
err = "path guide not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM path_guides WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||||
|
{
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||||
|
if (!ok)
|
||||||
|
err = sqlite3_errmsg(db_.handle());
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
33
src/storage/path_guide_store.hpp
Normal file
33
src/storage/path_guide_store.hpp
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class Database;
|
||||||
|
|
||||||
|
class PathGuideStore
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PathGuideStore(Database& db);
|
||||||
|
|
||||||
|
nlohmann::json list(const std::string& site_id = "") const;
|
||||||
|
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||||
|
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||||
|
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||||
|
bool remove(const std::string& id, std::string& err);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database& db_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
|
||||||
|
bool findNameConflictUnlocked(const std::string& site_id,
|
||||||
|
const std::string& name,
|
||||||
|
const std::string& except_id) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -16,6 +16,7 @@ const pageUsersEl = el("pageUsers");
|
|||||||
const pageUserGroupsEl = el("pageUserGroups");
|
const pageUserGroupsEl = el("pageUserGroups");
|
||||||
const pageIoModulesEl = el("pageIoModules");
|
const pageIoModulesEl = el("pageIoModules");
|
||||||
const pagePathsEl = el("pagePaths");
|
const pagePathsEl = el("pagePaths");
|
||||||
|
const pagePathGuidesEl = el("pagePathGuides");
|
||||||
const pageMonitoringEl = el("pageMonitoring");
|
const pageMonitoringEl = el("pageMonitoring");
|
||||||
const pageHelpEl = el("pageHelp");
|
const pageHelpEl = el("pageHelp");
|
||||||
const contentEl = document.querySelector(".content");
|
const contentEl = document.querySelector(".content");
|
||||||
@@ -130,7 +131,7 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
function setActivePage(page) {
|
||||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "integrations", "monitoring", "help"];
|
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "integrations", "monitoring", "help"];
|
||||||
let p = valid.includes(page) ? page : "missions";
|
let p = valid.includes(page) ? page : "missions";
|
||||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||||
@@ -146,6 +147,7 @@ function setActivePage(page) {
|
|||||||
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
||||||
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
||||||
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
||||||
|
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
|
||||||
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
@@ -162,6 +164,7 @@ function setActivePage(page) {
|
|||||||
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
||||||
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
||||||
contentEl.classList.toggle("content--paths", p === "paths");
|
contentEl.classList.toggle("content--paths", p === "paths");
|
||||||
|
contentEl.classList.toggle("content--path-guides", p === "path-guides");
|
||||||
contentEl.classList.toggle("content--users", p === "users");
|
contentEl.classList.toggle("content--users", p === "users");
|
||||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||||
@@ -180,6 +183,8 @@ function setActivePage(page) {
|
|||||||
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
||||||
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
|
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
|
||||||
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
|
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
|
||||||
|
if (p === "path-guides" && window.PathGuidesApp) window.PathGuidesApp.onPageShow();
|
||||||
|
else if (window.PathGuidesApp?.onPageHide) window.PathGuidesApp.onPageHide();
|
||||||
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
||||||
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
||||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||||
|
|||||||
@@ -154,6 +154,7 @@
|
|||||||
"user-groups": "users",
|
"user-groups": "users",
|
||||||
"io-modules": "integrations",
|
"io-modules": "integrations",
|
||||||
paths: "maps",
|
paths: "maps",
|
||||||
|
"path-guides": "maps",
|
||||||
users: "users",
|
users: "users",
|
||||||
integrations: "integrations",
|
integrations: "integrations",
|
||||||
};
|
};
|
||||||
@@ -179,6 +180,7 @@
|
|||||||
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
||||||
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
||||||
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
|
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
|
||||||
|
document.body.classList.toggle("auth-readonly-path-guides", !canWrite("maps"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserMenu() {
|
function updateUserMenu() {
|
||||||
|
|||||||
110
www/i18n.js
110
www/i18n.js
@@ -77,6 +77,7 @@
|
|||||||
"nav.user-groups": "User groups",
|
"nav.user-groups": "User groups",
|
||||||
"nav.io-modules": "I/O modules",
|
"nav.io-modules": "I/O modules",
|
||||||
"nav.paths": "Paths",
|
"nav.paths": "Paths",
|
||||||
|
"nav.path-guides": "Path guides",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
@@ -714,6 +715,60 @@
|
|||||||
"paths.deleteTitle": "Xóa path?",
|
"paths.deleteTitle": "Xóa path?",
|
||||||
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.",
|
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.",
|
||||||
|
|
||||||
|
"pathGuides.title": "Path guides",
|
||||||
|
"pathGuides.subtitle": "Định nghĩa tuyến cố định giữa các position qua waypoint.",
|
||||||
|
"pathGuides.helpTitle": "Trợ giúp Path guides",
|
||||||
|
"pathGuides.helpBody": "Path guide buộc robot đi theo chuỗi start → waypoint → goal trên map. Tạo position trên map editor trước, sau đó thêm start, waypoint (có thứ tự) và goal. Robot tự dùng path guide khi mission nhắm tới các position đó (Phase D).",
|
||||||
|
"pathGuides.create": "Tạo path guide",
|
||||||
|
"pathGuides.clearFilters": "Xóa bộ lọc",
|
||||||
|
"pathGuides.filterLabel": "Lọc:",
|
||||||
|
"pathGuides.filterPlaceholder": "Lọc theo tên hoặc map...",
|
||||||
|
"pathGuides.itemsFound": "{n} mục",
|
||||||
|
"pathGuides.pageOf": "Trang {page} / {total}",
|
||||||
|
"pathGuides.colName": "Tên",
|
||||||
|
"pathGuides.colMap": "Map",
|
||||||
|
"pathGuides.colStarts": "Starts",
|
||||||
|
"pathGuides.colVias": "Waypoints",
|
||||||
|
"pathGuides.colGoals": "Goals",
|
||||||
|
"pathGuides.colFunctions": "Functions",
|
||||||
|
"pathGuides.empty": "Chưa có path guide. Tạo waypoint trên map trước, sau đó thêm path guide.",
|
||||||
|
"pathGuides.emptyFilter": "Không có path guide khớp bộ lọc.",
|
||||||
|
"pathGuides.createTitle": "Tạo path guide",
|
||||||
|
"pathGuides.editTitle": "Sửa path guide",
|
||||||
|
"pathGuides.name": "Tên",
|
||||||
|
"pathGuides.site": "Site",
|
||||||
|
"pathGuides.map": "Map",
|
||||||
|
"pathGuides.mapHint": "Tạo position trên map editor trước khi thêm start, waypoint và goal.",
|
||||||
|
"pathGuides.starts": "Start positions",
|
||||||
|
"pathGuides.vias": "Waypoints (có thứ tự)",
|
||||||
|
"pathGuides.goals": "Goal positions",
|
||||||
|
"pathGuides.addStart": "Thêm start",
|
||||||
|
"pathGuides.addVia": "Thêm waypoint",
|
||||||
|
"pathGuides.addGoal": "Thêm goal",
|
||||||
|
"pathGuides.selectPosition": "Chọn position…",
|
||||||
|
"pathGuides.noStarts": "Chưa có start",
|
||||||
|
"pathGuides.noVias": "Chưa có waypoint",
|
||||||
|
"pathGuides.noGoals": "Chưa có goal",
|
||||||
|
"pathGuides.moveUp": "Lên",
|
||||||
|
"pathGuides.moveDown": "Xuống",
|
||||||
|
"pathGuides.deleteTitle": "Xóa path guide?",
|
||||||
|
"pathGuides.deleteConfirmText": "Xóa path guide \"{name}\" trên map \"{map}\"?",
|
||||||
|
"pathGuides.error.missing": "Thiếu tên, site hoặc map.",
|
||||||
|
"pathGuides.error.positions": "Cần ít nhất một start và một goal.",
|
||||||
|
"pathGuides.error.noPositions": "Không có position nào trên map này. Tạo position trong map editor trước.",
|
||||||
|
"pathGuides.dragHandle": "Kéo để đổi thứ tự",
|
||||||
|
"pathGuides.createPage.title": "Tạo path guide",
|
||||||
|
"pathGuides.createPage.subtitle": "Nhập tên và chọn map cho path guide.",
|
||||||
|
"pathGuides.createPage.goBack": "Quay lại",
|
||||||
|
"pathGuides.createPage.namePlaceholder": "Nhập tên path guide...",
|
||||||
|
"pathGuides.createPage.continue": "Tiếp tục",
|
||||||
|
"pathGuides.editPage.title": "Sửa vị trí path guide",
|
||||||
|
"pathGuides.editPage.subtitle": "Chỉnh start, waypoint và goal của path guide.",
|
||||||
|
"pathGuides.editPage.helpTitle": "Trợ giúp vị trí path guide",
|
||||||
|
"pathGuides.editPage.helpBody": "Thêm start, waypoint (kéo để đổi thứ tự) và goal. Robot sẽ đi theo chuỗi này khi mission dùng các position tương ứng.",
|
||||||
|
"pathGuides.editPage.goBack": "Quay lại",
|
||||||
|
"pathGuides.editPage.meta": "{name} — {map}",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||||
"missions.create": "Tạo mission",
|
"missions.create": "Tạo mission",
|
||||||
@@ -910,6 +965,7 @@
|
|||||||
"nav.user-groups": "User groups",
|
"nav.user-groups": "User groups",
|
||||||
"nav.io-modules": "I/O modules",
|
"nav.io-modules": "I/O modules",
|
||||||
"nav.paths": "Paths",
|
"nav.paths": "Paths",
|
||||||
|
"nav.path-guides": "Path guides",
|
||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"nav.monitoring-log": "System log",
|
||||||
@@ -1547,6 +1603,60 @@
|
|||||||
"paths.deleteTitle": "Delete path?",
|
"paths.deleteTitle": "Delete path?",
|
||||||
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
|
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
|
||||||
|
|
||||||
|
"pathGuides.title": "Path guides",
|
||||||
|
"pathGuides.subtitle": "Define fixed routes between positions using waypoints.",
|
||||||
|
"pathGuides.helpTitle": "Path guides help",
|
||||||
|
"pathGuides.helpBody": "A path guide forces the robot to follow a start → waypoint → goal chain on the map. Create positions in the map editor first, then add starts, ordered waypoints, and goals. The robot will use the guide automatically when missions target those positions (planner integration in a later phase).",
|
||||||
|
"pathGuides.create": "Create path guide",
|
||||||
|
"pathGuides.clearFilters": "Clear filters",
|
||||||
|
"pathGuides.filterLabel": "Filter:",
|
||||||
|
"pathGuides.filterPlaceholder": "Filter by name or map...",
|
||||||
|
"pathGuides.itemsFound": "{n} item(s) found",
|
||||||
|
"pathGuides.pageOf": "Page {page} of {total}",
|
||||||
|
"pathGuides.colName": "Name",
|
||||||
|
"pathGuides.colMap": "Map",
|
||||||
|
"pathGuides.colStarts": "Starts",
|
||||||
|
"pathGuides.colVias": "Waypoints",
|
||||||
|
"pathGuides.colGoals": "Goals",
|
||||||
|
"pathGuides.colFunctions": "Functions",
|
||||||
|
"pathGuides.empty": "No path guides yet. Create waypoint positions on the map first, then add a path guide.",
|
||||||
|
"pathGuides.emptyFilter": "No path guides match the filter.",
|
||||||
|
"pathGuides.createTitle": "Create path guide",
|
||||||
|
"pathGuides.editTitle": "Edit path guide",
|
||||||
|
"pathGuides.name": "Name",
|
||||||
|
"pathGuides.site": "Site",
|
||||||
|
"pathGuides.map": "Map",
|
||||||
|
"pathGuides.mapHint": "Create positions on the map editor before adding starts, waypoints, and goals.",
|
||||||
|
"pathGuides.starts": "Start positions",
|
||||||
|
"pathGuides.vias": "Waypoints (ordered)",
|
||||||
|
"pathGuides.goals": "Goal positions",
|
||||||
|
"pathGuides.addStart": "Add start",
|
||||||
|
"pathGuides.addVia": "Add waypoint",
|
||||||
|
"pathGuides.addGoal": "Add goal",
|
||||||
|
"pathGuides.selectPosition": "Select position…",
|
||||||
|
"pathGuides.noStarts": "No starts yet",
|
||||||
|
"pathGuides.noVias": "No waypoints yet",
|
||||||
|
"pathGuides.noGoals": "No goals yet",
|
||||||
|
"pathGuides.moveUp": "Move up",
|
||||||
|
"pathGuides.moveDown": "Move down",
|
||||||
|
"pathGuides.deleteTitle": "Delete path guide?",
|
||||||
|
"pathGuides.deleteConfirmText": "Delete path guide \"{name}\" on map \"{map}\"?",
|
||||||
|
"pathGuides.error.missing": "Name, site, and map are required.",
|
||||||
|
"pathGuides.error.positions": "At least one start and one goal are required.",
|
||||||
|
"pathGuides.error.noPositions": "No positions on this map. Create positions in the map editor first.",
|
||||||
|
"pathGuides.dragHandle": "Drag to reorder",
|
||||||
|
"pathGuides.createPage.title": "Create path guide",
|
||||||
|
"pathGuides.createPage.subtitle": "Enter a name and select the map for this path guide.",
|
||||||
|
"pathGuides.createPage.goBack": "Go back",
|
||||||
|
"pathGuides.createPage.namePlaceholder": "Enter path guide name...",
|
||||||
|
"pathGuides.createPage.continue": "Continue",
|
||||||
|
"pathGuides.editPage.title": "Edit path guide positions",
|
||||||
|
"pathGuides.editPage.subtitle": "Edit the path guide's positions.",
|
||||||
|
"pathGuides.editPage.helpTitle": "Path guide positions help",
|
||||||
|
"pathGuides.editPage.helpBody": "Add starts, waypoints (drag to reorder), and goals. The robot follows this chain when missions use the matching positions.",
|
||||||
|
"pathGuides.editPage.goBack": "Go back",
|
||||||
|
"pathGuides.editPage.meta": "{name} — {map}",
|
||||||
|
|
||||||
"missions.title": "Missions",
|
"missions.title": "Missions",
|
||||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||||
"missions.create": "Create mission",
|
"missions.create": "Create mission",
|
||||||
|
|||||||
159
www/index.html
159
www/index.html
@@ -1334,6 +1334,164 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pagePathGuides" data-page-content="path-guides" hidden>
|
||||||
|
<div id="pathGuidesListView" class="mapsMirPage">
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="pathGuides.title">Path guides</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span data-i18n="pathGuides.subtitle">Define fixed routes between positions using waypoints.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="pathGuidesHelpBtn" data-i18n-title="pathGuides.helpTitle" aria-label="Help">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="pathGuideCreateBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="pathGuides.create">Create path guide</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathGuidesClearFiltersBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="pathGuides.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="pathGuidesFilterInput" data-i18n="pathGuides.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="pathGuidesFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="pathGuides.filterPlaceholder" placeholder="Filter by name or map..." autocomplete="off" />
|
||||||
|
<span id="pathGuidesFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathGuidesPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="pathGuidesPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="pathGuidesPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--path-guides" id="pathGuidesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="pathGuideMirThIcon" aria-hidden="true"></th>
|
||||||
|
<th data-i18n="pathGuides.colName">Name</th>
|
||||||
|
<th data-i18n="pathGuides.colMap">Map</th>
|
||||||
|
<th data-i18n="pathGuides.colStarts">Starts</th>
|
||||||
|
<th data-i18n="pathGuides.colVias">Waypoints</th>
|
||||||
|
<th data-i18n="pathGuides.colGoals">Goals</th>
|
||||||
|
<th class="mapsMirThFunctions" data-i18n="pathGuides.colFunctions">Functions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pathGuideList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="pathGuideListEmpty" class="mapsMirEmpty" hidden data-i18n="pathGuides.empty">No path guides yet. Create waypoint positions on the map first, then add a path guide.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pathGuideCreateView" class="mapsMirCreatePage" hidden>
|
||||||
|
<header class="mapsMirCreateHeader">
|
||||||
|
<div class="mapsMirCreateHeaderIntro">
|
||||||
|
<h1 class="mapsMirCreateTitle" data-i18n="pathGuides.createPage.title">Create path guide</h1>
|
||||||
|
<p class="mapsMirCreateSubtitle">
|
||||||
|
<span data-i18n="pathGuides.createPage.subtitle">Enter a name and select the map for this path guide.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="pathGuideCreateHelpBtn" data-i18n-title="pathGuides.helpTitle" aria-label="Help">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mapsMirGoBackBtn" id="pathGuideCreateGoBackBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span data-i18n="pathGuides.createPage.goBack">Go back</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<form id="pathGuideCreateForm" class="mapsMirCreateForm">
|
||||||
|
<div class="mapsMirCreatePanel">
|
||||||
|
<label class="mapsMirCreateField" for="pathGuideCreateName">
|
||||||
|
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.name">Name</span>
|
||||||
|
<input type="text" id="pathGuideCreateName" required autocomplete="off" data-i18n-placeholder="pathGuides.createPage.namePlaceholder" placeholder="Enter path guide name..." />
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirCreateField" for="pathGuideCreateSite">
|
||||||
|
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.site">Site</span>
|
||||||
|
<select id="pathGuideCreateSite" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirCreateField" for="pathGuideCreateMap">
|
||||||
|
<span class="mapsMirCreateFieldLabel" data-i18n="pathGuides.map">Map</span>
|
||||||
|
<select id="pathGuideCreateMap" required></select>
|
||||||
|
</label>
|
||||||
|
<p class="mapsMirHint" data-i18n="pathGuides.mapHint">Create positions on the map editor before adding starts, waypoints, and goals.</p>
|
||||||
|
<div class="mapsMirCreateActions">
|
||||||
|
<button type="submit" class="mapsMirCreateSubmitBtn">
|
||||||
|
<span class="mapsMirCircleIcon mapsMirCircleIcon--ok" aria-hidden="true">✓</span>
|
||||||
|
<span data-i18n="pathGuides.createPage.continue">Continue</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirCreateCancelBtn" id="pathGuideCreateCancelBtn">
|
||||||
|
<span class="mapsMirCircleIcon" aria-hidden="true">←</span>
|
||||||
|
<span data-i18n="common.cancel">Cancel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pathGuideEditView" class="pathGuideEditPage" hidden>
|
||||||
|
<header class="pathGuideEditHeader">
|
||||||
|
<div class="pathGuideEditHeaderIntro">
|
||||||
|
<h1 class="pathGuideEditTitle" id="pathGuideEditPageTitle" data-i18n="pathGuides.editPage.title">Edit path guide positions</h1>
|
||||||
|
<p class="pathGuideEditSubtitle">
|
||||||
|
<span data-i18n="pathGuides.editPage.subtitle">Edit the path guide's positions.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="pathGuideEditHelpBtn" data-i18n-title="pathGuides.editPage.helpTitle" aria-label="Help">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="pathGuideEditMeta" id="pathGuideEditMeta" hidden></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mapsMirGoBackBtn" id="pathGuideEditGoBackBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 3L5 7l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span data-i18n="pathGuides.editPage.goBack">Go back</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pathGuidePosGrid pathGuidePosGrid--page">
|
||||||
|
<section class="pathGuidePosPanel">
|
||||||
|
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.starts">Start positions</h2>
|
||||||
|
<ul id="pathGuideStartsList" class="pathGuidePosList"></ul>
|
||||||
|
<select id="pathGuideAddStartSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
|
||||||
|
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddStartBtn" data-i18n="pathGuides.addStart">Add start</button>
|
||||||
|
</section>
|
||||||
|
<section class="pathGuidePosPanel">
|
||||||
|
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.vias">Waypoints</h2>
|
||||||
|
<ul id="pathGuideViasList" class="pathGuidePosList pathGuidePosList--vias"></ul>
|
||||||
|
<select id="pathGuideAddViaSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
|
||||||
|
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddViaBtn" data-i18n="pathGuides.addVia">Add waypoint</button>
|
||||||
|
</section>
|
||||||
|
<section class="pathGuidePosPanel">
|
||||||
|
<h2 class="pathGuidePosTitle" data-i18n="pathGuides.goals">Goal positions</h2>
|
||||||
|
<ul id="pathGuideGoalsList" class="pathGuidePosList"></ul>
|
||||||
|
<select id="pathGuideAddGoalSelect" class="pathGuidePosPicker" hidden aria-hidden="true" tabindex="-1"></select>
|
||||||
|
<button type="button" class="pathGuidePosAddBtn" id="pathGuideAddGoalBtn" data-i18n="pathGuides.addGoal">Add goal</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pathGuideEditFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathGuideEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="pathGuideEditSaveBtn" data-i18n="common.save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="pathGuideDeleteConfirmDialog" class="mapsMirDialog">
|
||||||
|
<div class="mapsMirDialogPanel">
|
||||||
|
<h2 class="mapsMirDialogTitle" data-i18n="pathGuides.deleteTitle">Delete path guide?</h2>
|
||||||
|
<p id="pathGuideDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||||
|
<div class="mapsMirDialogFooter">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathGuideDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathGuideDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
||||||
<div id="usersListView" class="mapsMirPage">
|
<div id="usersListView" class="mapsMirPage">
|
||||||
<header class="mapsMirHeader">
|
<header class="mapsMirHeader">
|
||||||
@@ -2620,6 +2778,7 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/user-groups.js"></script>
|
<script src="/user-groups.js"></script>
|
||||||
<script src="/io-modules.js"></script>
|
<script src="/io-modules.js"></script>
|
||||||
<script src="/paths.js"></script>
|
<script src="/paths.js"></script>
|
||||||
|
<script src="/path-guides.js"></script>
|
||||||
<script src="/map-editor.js"></script>
|
<script src="/map-editor.js"></script>
|
||||||
<script src="/topbar.js"></script>
|
<script src="/topbar.js"></script>
|
||||||
<script src="/dashboard.js"></script>
|
<script src="/dashboard.js"></script>
|
||||||
|
|||||||
@@ -17,10 +17,11 @@
|
|||||||
{ section: "maps", page: "maps" },
|
{ section: "maps", page: "maps" },
|
||||||
{ section: "sounds", page: "sounds" },
|
{ section: "sounds", page: "sounds" },
|
||||||
{ section: "transitions", page: "transitions" },
|
{ section: "transitions", page: "transitions" },
|
||||||
|
{ section: "users", page: "users" },
|
||||||
{ section: "user-groups", page: "user-groups" },
|
{ section: "user-groups", page: "user-groups" },
|
||||||
{ section: "io-modules", page: "io-modules" },
|
{ section: "io-modules", page: "io-modules" },
|
||||||
{ section: "paths", page: "paths" },
|
{ section: "paths", page: "paths" },
|
||||||
{ section: "users", page: "users" },
|
{ section: "path-guides", page: "path-guides" },
|
||||||
{ section: "build-robot", page: "config" },
|
{ section: "build-robot", page: "config" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"user-groups": { module: "setup", section: "user-groups" },
|
"user-groups": { module: "setup", section: "user-groups" },
|
||||||
"io-modules": { module: "setup", section: "io-modules" },
|
"io-modules": { module: "setup", section: "io-modules" },
|
||||||
paths: { module: "setup", section: "paths" },
|
paths: { module: "setup", section: "paths" },
|
||||||
|
"path-guides": { module: "setup", section: "path-guides" },
|
||||||
users: { module: "setup", section: "users" },
|
users: { module: "setup", section: "users" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||||
|
|||||||
693
www/path-guides.js
Normal file
693
www/path-guides.js
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
(() => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
guide: `<svg class="pathGuideMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="4" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="18" cy="11" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.5 11h2M13.5 11h2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
|
||||||
|
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||||
|
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||||
|
drag: `<svg class="pathGuideDragIcon" width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M5 3h1v1H5V3zm3 0h1v1H8V3zM5 6h1v1H5V6zm3 0h1v1H8V6zm-3 3h1v1H5V9zm3 0h1v1H8V9z" fill="currentColor"/></svg>`,
|
||||||
|
remove: `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const listViewEl = el("pathGuidesListView");
|
||||||
|
const createViewEl = el("pathGuideCreateView");
|
||||||
|
const editViewEl = el("pathGuideEditView");
|
||||||
|
const listEl = el("pathGuideList");
|
||||||
|
const emptyEl = el("pathGuideListEmpty");
|
||||||
|
const tableEl = el("pathGuidesTable");
|
||||||
|
const createBtnEl = el("pathGuideCreateBtn");
|
||||||
|
const filterInputEl = el("pathGuidesFilterInput");
|
||||||
|
const filterCountEl = el("pathGuidesFilterCount");
|
||||||
|
const pageLabelEl = el("pathGuidesPageLabel");
|
||||||
|
const deleteConfirmDialogEl = el("pathGuideDeleteConfirmDialog");
|
||||||
|
const deleteConfirmTextEl = el("pathGuideDeleteConfirmText");
|
||||||
|
const editMetaEl = el("pathGuideEditMeta");
|
||||||
|
const deleteBtnEl = el("pathGuideEditDeleteBtn");
|
||||||
|
const saveBtnEl = el("pathGuideEditSaveBtn");
|
||||||
|
|
||||||
|
const createFields = {
|
||||||
|
name: el("pathGuideCreateName"),
|
||||||
|
site: el("pathGuideCreateSite"),
|
||||||
|
map: el("pathGuideCreateMap"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickers = {
|
||||||
|
start: el("pathGuideAddStartSelect"),
|
||||||
|
via: el("pathGuideAddViaSelect"),
|
||||||
|
goal: el("pathGuideAddGoalSelect"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lists = {
|
||||||
|
starts: el("pathGuideStartsList"),
|
||||||
|
vias: el("pathGuideViasList"),
|
||||||
|
goals: el("pathGuideGoalsList"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
guides: [],
|
||||||
|
sites: [],
|
||||||
|
maps: [],
|
||||||
|
editingId: null,
|
||||||
|
pendingDeleteId: null,
|
||||||
|
filter: "",
|
||||||
|
page: 1,
|
||||||
|
draft: { starts: [], vias: [], goals: [] },
|
||||||
|
meta: { name: "", site_id: "", map_id: "" },
|
||||||
|
dragViaIndex: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function canWrite() {
|
||||||
|
if (!window.AuthApp?.canWrite) return true;
|
||||||
|
return window.AuthApp.canWrite("maps");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUser() {
|
||||||
|
return window.AuthApp?.getUser?.() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDeleteGuide(guide) {
|
||||||
|
if (!canWrite() || !guide) return false;
|
||||||
|
const user = currentUser();
|
||||||
|
if (!user) return true;
|
||||||
|
const group = guide.created_by_group;
|
||||||
|
if (group) return group === user.group_id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(url, opts = {}) {
|
||||||
|
const res = await fetch(url, { credentials: "include", ...opts });
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
const [sites, maps, guides] = await Promise.all([
|
||||||
|
apiJson("/api/sites"),
|
||||||
|
apiJson("/api/maps"),
|
||||||
|
apiJson("/api/path_guides"),
|
||||||
|
]);
|
||||||
|
store.sites = Array.isArray(sites.sites) ? sites.sites : [];
|
||||||
|
store.maps = Array.isArray(maps.maps) ? maps.maps : [];
|
||||||
|
store.guides = Array.isArray(guides.path_guides) ? guides.path_guides : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteName(id) {
|
||||||
|
return store.sites.find((x) => x.id === id)?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapName(id) {
|
||||||
|
return store.maps.find((x) => x.id === id)?.name || id || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionName(mapId, positionId) {
|
||||||
|
const m = store.maps.find((x) => x.id === mapId);
|
||||||
|
const zones = Array.isArray(m?.zones) ? m.zones : [];
|
||||||
|
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
|
||||||
|
return hit?.name || positionId || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOptionsForMap(mapId, excludeIds = new Set()) {
|
||||||
|
const m = store.maps.find((x) => x.id === mapId);
|
||||||
|
const zones = Array.isArray(m?.zones) ? m.zones : [];
|
||||||
|
return zones
|
||||||
|
.filter((z) => z && z.type === "position" && typeof z.id === "string" && !excludeIds.has(z.id))
|
||||||
|
.map((p) => ({ value: p.id, label: p.name || p.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(selectEl, options, value, { allowBlank = false, blankLabel = "" } = {}) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
selectEl.innerHTML = "";
|
||||||
|
if (allowBlank) {
|
||||||
|
const blank = document.createElement("option");
|
||||||
|
blank.value = "";
|
||||||
|
blank.textContent = blankLabel || t("pathGuides.selectPosition");
|
||||||
|
selectEl.appendChild(blank);
|
||||||
|
}
|
||||||
|
options.forEach((opt) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt.value;
|
||||||
|
o.textContent = opt.label;
|
||||||
|
if (opt.value === value) o.selected = true;
|
||||||
|
selectEl.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGuidePositions(guide) {
|
||||||
|
const positions = Array.isArray(guide?.positions) ? guide.positions : [];
|
||||||
|
const starts = [];
|
||||||
|
const vias = [];
|
||||||
|
const goals = [];
|
||||||
|
positions.forEach((p) => {
|
||||||
|
if (!p || typeof p.position_id !== "string") return;
|
||||||
|
const role = String(p.role || "").toLowerCase();
|
||||||
|
if (role === "start") starts.push(p.position_id);
|
||||||
|
else if (role === "via") vias.push({ position_id: p.position_id, priority: Number(p.priority) || vias.length + 1 });
|
||||||
|
else if (role === "goal") goals.push(p.position_id);
|
||||||
|
});
|
||||||
|
vias.sort((a, b) => a.priority - b.priority);
|
||||||
|
return { starts, vias, goals };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftToPositions() {
|
||||||
|
const out = [];
|
||||||
|
store.draft.starts.forEach((id) => out.push({ position_id: id, role: "start" }));
|
||||||
|
store.draft.vias.forEach((v, idx) => out.push({ position_id: v.position_id, role: "via", priority: idx + 1 }));
|
||||||
|
store.draft.goals.forEach((id) => out.push({ position_id: id, role: "goal" }));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedPositionIds() {
|
||||||
|
const ids = new Set();
|
||||||
|
store.draft.starts.forEach((id) => ids.add(id));
|
||||||
|
store.draft.vias.forEach((v) => ids.add(v.position_id));
|
||||||
|
store.draft.goals.forEach((id) => ids.add(id));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMapId() {
|
||||||
|
return store.meta.map_id || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPickers() {
|
||||||
|
const mapId = currentMapId();
|
||||||
|
const used = usedPositionIds();
|
||||||
|
const opts = positionOptionsForMap(mapId, used);
|
||||||
|
fillSelect(pickers.start, opts, "", { allowBlank: true });
|
||||||
|
fillSelect(pickers.via, opts, "", { allowBlank: true });
|
||||||
|
fillSelect(pickers.goal, opts, "", { allowBlank: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPosCard(mapId, label, { draggable = false, viaIndex = -1, removeKind, removeKey }) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "pathGuidePosCard";
|
||||||
|
if (draggable) {
|
||||||
|
li.draggable = true;
|
||||||
|
li.dataset.viaIndex = String(viaIndex);
|
||||||
|
li.addEventListener("dragstart", (evt) => {
|
||||||
|
store.dragViaIndex = viaIndex;
|
||||||
|
evt.dataTransfer?.setData("text/plain", String(viaIndex));
|
||||||
|
li.classList.add("pathGuidePosCard--dragging");
|
||||||
|
});
|
||||||
|
li.addEventListener("dragend", () => {
|
||||||
|
store.dragViaIndex = null;
|
||||||
|
li.classList.remove("pathGuidePosCard--dragging");
|
||||||
|
});
|
||||||
|
li.addEventListener("dragover", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
li.classList.add("pathGuidePosCard--over");
|
||||||
|
});
|
||||||
|
li.addEventListener("dragleave", () => li.classList.remove("pathGuidePosCard--over"));
|
||||||
|
li.addEventListener("drop", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
li.classList.remove("pathGuidePosCard--over");
|
||||||
|
const from = store.dragViaIndex;
|
||||||
|
const to = viaIndex;
|
||||||
|
if (from == null || from === to) return;
|
||||||
|
const copy = [...store.draft.vias];
|
||||||
|
const [item] = copy.splice(from, 1);
|
||||||
|
copy.splice(to, 0, item);
|
||||||
|
store.draft.vias = copy;
|
||||||
|
renderDraftLists();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragHtml = draggable
|
||||||
|
? `<span class="pathGuidePosDrag" title="${escapeHtml(t("pathGuides.dragHandle"))}">${ICONS.drag}</span>`
|
||||||
|
: "";
|
||||||
|
li.innerHTML = `${dragHtml}<span class="pathGuidePosCardLabel">${escapeHtml(label)}</span>
|
||||||
|
<button type="button" class="pathGuidePosRemoveBtn" data-remove-kind="${removeKind}" data-remove-key="${escapeHtml(String(removeKey))}" title="${escapeHtml(t("common.delete"))}">${ICONS.remove}</button>`;
|
||||||
|
li.querySelector("[data-remove-kind]")?.addEventListener("click", () => {
|
||||||
|
if (removeKind === "start") store.draft.starts = store.draft.starts.filter((x) => x !== removeKey);
|
||||||
|
else if (removeKind === "via") store.draft.vias = store.draft.vias.filter((_, i) => i !== removeKey);
|
||||||
|
else if (removeKind === "goal") store.draft.goals = store.draft.goals.filter((x) => x !== removeKey);
|
||||||
|
renderDraftLists();
|
||||||
|
});
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDraftLists() {
|
||||||
|
const mapId = currentMapId();
|
||||||
|
|
||||||
|
if (lists.starts) {
|
||||||
|
lists.starts.innerHTML = "";
|
||||||
|
store.draft.starts.forEach((id) => {
|
||||||
|
lists.starts.appendChild(renderPosCard(mapId, positionName(mapId, id), { removeKind: "start", removeKey: id }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lists.vias) {
|
||||||
|
lists.vias.innerHTML = "";
|
||||||
|
store.draft.vias.forEach((v, idx) => {
|
||||||
|
lists.vias.appendChild(
|
||||||
|
renderPosCard(mapId, positionName(mapId, v.position_id), {
|
||||||
|
draggable: canWrite(),
|
||||||
|
viaIndex: idx,
|
||||||
|
removeKind: "via",
|
||||||
|
removeKey: idx,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lists.goals) {
|
||||||
|
lists.goals.innerHTML = "";
|
||||||
|
store.draft.goals.forEach((id) => {
|
||||||
|
lists.goals.appendChild(renderPosCard(mapId, positionName(mapId, id), { removeKind: "goal", removeKey: id }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPickers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAllViews() {
|
||||||
|
[listViewEl, createViewEl, editViewEl].forEach((view) => {
|
||||||
|
if (!view) return;
|
||||||
|
view.hidden = true;
|
||||||
|
view.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showList() {
|
||||||
|
hideAllViews();
|
||||||
|
if (listViewEl) {
|
||||||
|
listViewEl.hidden = false;
|
||||||
|
listViewEl.removeAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
store.editingId = null;
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCreateMapSelect() {
|
||||||
|
const siteId = createFields.site?.value || "";
|
||||||
|
const mapsForSite = store.maps.filter((m) => (m.site_id || "") === siteId);
|
||||||
|
const mapOpts = mapsForSite.map((m) => ({ value: m.id, label: m.name || m.id }));
|
||||||
|
fillSelect(createFields.map, mapOpts, createFields.map?.value || mapOpts[0]?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreate() {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
hideAllViews();
|
||||||
|
const siteOpts = store.sites.map((s) => ({ value: s.id, label: s.name || s.id }));
|
||||||
|
fillSelect(createFields.site, siteOpts, store.maps[0]?.site_id || siteOpts[0]?.value);
|
||||||
|
refreshCreateMapSelect();
|
||||||
|
if (createFields.name) createFields.name.value = "";
|
||||||
|
if (createViewEl) {
|
||||||
|
createViewEl.hidden = false;
|
||||||
|
createViewEl.removeAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
createFields.name?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditView({ id = null, meta = null, draft = null } = {}) {
|
||||||
|
hideAllViews();
|
||||||
|
store.editingId = id;
|
||||||
|
const existing = id ? store.guides.find((x) => x.id === id) : null;
|
||||||
|
if (existing) {
|
||||||
|
store.meta = { name: existing.name || "", site_id: existing.site_id || "", map_id: existing.map_id || "" };
|
||||||
|
store.draft = parseGuidePositions(existing);
|
||||||
|
} else if (meta) {
|
||||||
|
store.meta = { ...meta };
|
||||||
|
store.draft = draft || { starts: [], vias: [], goals: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMetaEl) {
|
||||||
|
const label = t("pathGuides.editPage.meta", {
|
||||||
|
name: store.meta.name || "—",
|
||||||
|
map: mapName(store.meta.map_id),
|
||||||
|
});
|
||||||
|
editMetaEl.textContent = label;
|
||||||
|
editMetaEl.hidden = !store.meta.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = !canWrite();
|
||||||
|
if (deleteBtnEl) {
|
||||||
|
deleteBtnEl.hidden = !existing || ro || !canDeleteGuide(existing);
|
||||||
|
}
|
||||||
|
if (saveBtnEl) saveBtnEl.hidden = ro;
|
||||||
|
[el("pathGuideAddStartBtn"), el("pathGuideAddViaBtn"), el("pathGuideAddGoalBtn")].forEach((btn) => {
|
||||||
|
btn?.toggleAttribute("disabled", ro);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDraftLists();
|
||||||
|
if (editViewEl) {
|
||||||
|
editViewEl.hidden = false;
|
||||||
|
editViewEl.removeAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredGuides() {
|
||||||
|
const q = store.filter.trim().toLowerCase();
|
||||||
|
let items = [...store.guides].sort((a, b) => {
|
||||||
|
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
|
||||||
|
if (ma !== 0) return ma;
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||||
|
});
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((g) => {
|
||||||
|
const name = String(g.name || "").toLowerCase();
|
||||||
|
const map = mapName(g.map_id).toLowerCase();
|
||||||
|
const site = siteName(g.site_id).toLowerCase();
|
||||||
|
return name.includes(q) || map.includes(q) || site.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageCount(total) {
|
||||||
|
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedItems(items) {
|
||||||
|
const totalPages = pageCount(items.length);
|
||||||
|
if (store.page > totalPages) store.page = totalPages;
|
||||||
|
if (store.page < 1) store.page = 1;
|
||||||
|
const start = (store.page - 1) * PAGE_SIZE;
|
||||||
|
return items.slice(start, start + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagerUi(totalItems) {
|
||||||
|
const totalPages = pageCount(totalItems);
|
||||||
|
if (filterCountEl) filterCountEl.textContent = t("pathGuides.itemsFound", { n: totalItems });
|
||||||
|
if (pageLabelEl) pageLabelEl.textContent = t("pathGuides.pageOf", { page: store.page, total: totalPages });
|
||||||
|
const atStart = store.page <= 1;
|
||||||
|
const atEnd = store.page >= totalPages;
|
||||||
|
el("pathGuidesPageFirst")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("pathGuidesPagePrev")?.toggleAttribute("disabled", atStart);
|
||||||
|
el("pathGuidesPageNext")?.toggleAttribute("disabled", atEnd);
|
||||||
|
el("pathGuidesPageLast")?.toggleAttribute("disabled", atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredGuides();
|
||||||
|
const pageItems = pagedItems(items);
|
||||||
|
updatePagerUi(items.length);
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const showEmpty = items.length === 0;
|
||||||
|
if (tableEl) tableEl.hidden = showEmpty;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = !showEmpty;
|
||||||
|
emptyEl.textContent = store.filter.trim() ? t("pathGuides.emptyFilter") : t("pathGuides.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((guide) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "mapsMirRow pathGuideMirRow";
|
||||||
|
const actions = canWrite()
|
||||||
|
? `<div class="mapsMirRowActions">
|
||||||
|
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||||
|
${canDeleteGuide(guide) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(guide.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="pathGuideMirCellIcon">${ICONS.guide}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="mapsMirNameLink" data-edit="${escapeHtml(guide.id)}">${escapeHtml(guide.name || "—")}</button>
|
||||||
|
</td>
|
||||||
|
<td>${escapeHtml(mapName(guide.map_id))}</td>
|
||||||
|
<td class="pathGuideMirCellNum">${guide.starts_count ?? 0}</td>
|
||||||
|
<td class="pathGuideMirCellNum">${guide.vias_count ?? 0}</td>
|
||||||
|
<td class="pathGuideMirCellNum">${guide.goals_count ?? 0}</td>
|
||||||
|
<td class="mapsMirCellActions">${actions}</td>`;
|
||||||
|
|
||||||
|
tr.querySelectorAll("[data-edit]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => showEditView({ id: btn.dataset.edit }));
|
||||||
|
});
|
||||||
|
tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(guide.id));
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createBtnEl) createBtnEl.hidden = !canWrite();
|
||||||
|
document.body.classList.toggle("auth-readonly-path-guides", !canWrite());
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayload() {
|
||||||
|
return {
|
||||||
|
name: store.meta.name?.trim() || "",
|
||||||
|
site_id: store.meta.site_id || "",
|
||||||
|
map_id: store.meta.map_id || "",
|
||||||
|
positions: draftToPositions(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!canWrite()) return;
|
||||||
|
const payload = readPayload();
|
||||||
|
if (!payload.name || !payload.site_id || !payload.map_id) {
|
||||||
|
alert(t("pathGuides.error.missing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.positions.filter((p) => p.role === "start").length < 1 || payload.positions.filter((p) => p.role === "goal").length < 1) {
|
||||||
|
alert(t("pathGuides.error.positions"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (store.editingId) {
|
||||||
|
await apiJson(`/api/path_guides/${encodeURIComponent(store.editingId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiJson("/api/path_guides", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refreshAll();
|
||||||
|
showList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteConfirm(id) {
|
||||||
|
const guide = store.guides.find((x) => x.id === id);
|
||||||
|
if (!guide || !canDeleteGuide(guide)) return;
|
||||||
|
store.pendingDeleteId = id;
|
||||||
|
if (deleteConfirmTextEl) {
|
||||||
|
deleteConfirmTextEl.textContent = t("pathGuides.deleteConfirmText", {
|
||||||
|
name: guide.name || guide.id,
|
||||||
|
map: mapName(guide.map_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deleteConfirmDialogEl?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const id = store.pendingDeleteId || store.editingId;
|
||||||
|
if (!id || !canWrite()) return;
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/path_guides/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
store.editingId = null;
|
||||||
|
await refreshAll();
|
||||||
|
showList();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickAndAdd(selectEl, onAdd) {
|
||||||
|
if (!canWrite() || !selectEl) return;
|
||||||
|
refreshPickers();
|
||||||
|
const options = Array.from(selectEl.options).filter((o) => o.value);
|
||||||
|
if (options.length === 0) {
|
||||||
|
alert(t("pathGuides.error.noPositions"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof selectEl.showPicker === "function") {
|
||||||
|
selectEl.showPicker();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const done = () => {
|
||||||
|
selectEl.removeEventListener("change", done);
|
||||||
|
selectEl.removeEventListener("cancel", done);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
selectEl.addEventListener("change", done, { once: true });
|
||||||
|
selectEl.addEventListener("cancel", done, { once: true });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
selectEl.hidden = false;
|
||||||
|
selectEl.removeAttribute("aria-hidden");
|
||||||
|
selectEl.tabIndex = 0;
|
||||||
|
selectEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = selectEl.value;
|
||||||
|
if (!id) return;
|
||||||
|
onAdd(id);
|
||||||
|
selectEl.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStart(id) {
|
||||||
|
if (!id || store.draft.starts.includes(id)) return;
|
||||||
|
store.draft.starts.push(id);
|
||||||
|
renderDraftLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVia(id) {
|
||||||
|
if (!id || store.draft.vias.some((v) => v.position_id === id)) return;
|
||||||
|
store.draft.vias.push({ position_id: id, priority: store.draft.vias.length + 1 });
|
||||||
|
renderDraftLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGoal(id) {
|
||||||
|
if (!id || store.draft.goals.includes(id)) return;
|
||||||
|
store.draft.goals.push(id);
|
||||||
|
renderDraftLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCreateForm(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const name = createFields.name?.value?.trim() || "";
|
||||||
|
const site_id = createFields.site?.value || "";
|
||||||
|
const map_id = createFields.map?.value || "";
|
||||||
|
if (!name || !site_id || !map_id) {
|
||||||
|
alert(t("pathGuides.error.missing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showEditView({
|
||||||
|
meta: { name, site_id, map_id },
|
||||||
|
draft: { starts: [], vias: [], goals: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
store.filter = "";
|
||||||
|
store.page = 1;
|
||||||
|
if (filterInputEl) filterInputEl.value = "";
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
createBtnEl?.addEventListener("click", showCreate);
|
||||||
|
el("pathGuideCreateForm")?.addEventListener("submit", submitCreateForm);
|
||||||
|
el("pathGuideCreateGoBackBtn")?.addEventListener("click", showList);
|
||||||
|
el("pathGuideCreateCancelBtn")?.addEventListener("click", showList);
|
||||||
|
el("pathGuideCreateHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.helpBody")));
|
||||||
|
|
||||||
|
el("pathGuideEditGoBackBtn")?.addEventListener("click", showList);
|
||||||
|
el("pathGuideEditHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.editPage.helpBody")));
|
||||||
|
saveBtnEl?.addEventListener("click", saveEdit);
|
||||||
|
deleteBtnEl?.addEventListener("click", () => {
|
||||||
|
if (store.editingId) openDeleteConfirm(store.editingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
el("pathGuideAddStartBtn")?.addEventListener("click", () => {
|
||||||
|
void pickAndAdd(pickers.start, addStart);
|
||||||
|
});
|
||||||
|
el("pathGuideAddViaBtn")?.addEventListener("click", () => {
|
||||||
|
void pickAndAdd(pickers.via, addVia);
|
||||||
|
});
|
||||||
|
el("pathGuideAddGoalBtn")?.addEventListener("click", () => {
|
||||||
|
void pickAndAdd(pickers.goal, addGoal);
|
||||||
|
});
|
||||||
|
pickers.start?.addEventListener("change", () => {
|
||||||
|
if (pickers.start.value) addStart(pickers.start.value);
|
||||||
|
pickers.start.value = "";
|
||||||
|
pickers.start.hidden = true;
|
||||||
|
pickers.start.setAttribute("aria-hidden", "true");
|
||||||
|
pickers.start.tabIndex = -1;
|
||||||
|
});
|
||||||
|
pickers.via?.addEventListener("change", () => {
|
||||||
|
if (pickers.via.value) addVia(pickers.via.value);
|
||||||
|
pickers.via.value = "";
|
||||||
|
pickers.via.hidden = true;
|
||||||
|
pickers.via.setAttribute("aria-hidden", "true");
|
||||||
|
pickers.via.tabIndex = -1;
|
||||||
|
});
|
||||||
|
pickers.goal?.addEventListener("change", () => {
|
||||||
|
if (pickers.goal.value) addGoal(pickers.goal.value);
|
||||||
|
pickers.goal.value = "";
|
||||||
|
pickers.goal.hidden = true;
|
||||||
|
pickers.goal.setAttribute("aria-hidden", "true");
|
||||||
|
pickers.goal.tabIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
el("pathGuideDeleteCancelBtn")?.addEventListener("click", () => {
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
el("pathGuideDeleteYesBtn")?.addEventListener("click", confirmDelete);
|
||||||
|
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
store.pendingDeleteId = null;
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
createFields.site?.addEventListener("change", refreshCreateMapSelect);
|
||||||
|
|
||||||
|
filterInputEl?.addEventListener("input", () => {
|
||||||
|
store.filter = filterInputEl.value;
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathGuidesClearFiltersBtn")?.addEventListener("click", clearFilters);
|
||||||
|
el("pathGuidesPageFirst")?.addEventListener("click", () => {
|
||||||
|
store.page = 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathGuidesPagePrev")?.addEventListener("click", () => {
|
||||||
|
store.page = Math.max(1, store.page - 1);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathGuidesPageNext")?.addEventListener("click", () => {
|
||||||
|
store.page += 1;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathGuidesPageLast")?.addEventListener("click", () => {
|
||||||
|
store.page = pageCount(filteredGuides().length);
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
el("pathGuidesHelpBtn")?.addEventListener("click", () => alert(t("pathGuides.helpBody")));
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => {
|
||||||
|
if (!editViewEl?.hidden) renderDraftLists();
|
||||||
|
if (!listViewEl?.hidden) renderList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageShow() {
|
||||||
|
try {
|
||||||
|
await refreshAll();
|
||||||
|
showList();
|
||||||
|
} catch (e) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
emptyEl.textContent = e.message;
|
||||||
|
}
|
||||||
|
if (tableEl) tableEl.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
deleteConfirmDialogEl?.close();
|
||||||
|
showList();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
window.PathGuidesApp = { onPageShow, onPageHide };
|
||||||
|
})();
|
||||||
205
www/style.css
205
www/style.css
@@ -5553,3 +5553,208 @@ body.auth-readonly-maps-page #mapsImportSiteBtn { display: none !important; }
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content.content--path-guides {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
align-content: stretch;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.content.content--path-guides > #pagePathGuides {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.mapsMirTable--path-guides thead th.pathGuideMirThIcon { width: 52px; }
|
||||||
|
.pathGuideMirIcon { color: var(--mir-accent, #0d9488); }
|
||||||
|
.pathGuideMirCellNum { text-align: center; width: 72px; }
|
||||||
|
.pathGuidePosGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
}
|
||||||
|
.pathGuidePosPanel {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pathGuidePosTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pathGuidePosList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.pathGuidePosItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pathGuidePosOrder {
|
||||||
|
color: #666;
|
||||||
|
min-width: 1.2em;
|
||||||
|
}
|
||||||
|
.pathGuidePosLabel { flex: 1; }
|
||||||
|
.pathGuidePosActions { display: flex; gap: 2px; margin-left: auto; }
|
||||||
|
.pathGuidePosEmpty {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.pathGuidePosAdd {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pathGuidePosAdd select { flex: 1; min-width: 0; }
|
||||||
|
body.auth-readonly-path-guides .pathGuideMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
|
||||||
|
body.auth-readonly-path-guides #pathGuideCreateBtn { display: none !important; }
|
||||||
|
|
||||||
|
.pathGuideEditPage {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 24px 32px 40px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pathGuideEditHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.pathGuideEditTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #222;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.pathGuideEditSubtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pathGuideEditMeta {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.pathGuidePosGrid--page {
|
||||||
|
gap: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pathGuidePosGrid--page .pathGuidePosPanel {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.pathGuidePosGrid--page .pathGuidePosTitle {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.pathGuidePosCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(180deg, #f8f8f8 0%, #ececec 100%);
|
||||||
|
border: 1px solid #d8d8d8;
|
||||||
|
border-radius: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.pathGuidePosCard--dragging { opacity: 0.55; }
|
||||||
|
.pathGuidePosCard--over { outline: 2px solid var(--mir-accent, #0d9488); }
|
||||||
|
.pathGuidePosDrag {
|
||||||
|
color: #888;
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pathGuidePosCardLabel {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.pathGuidePosRemoveBtn {
|
||||||
|
border: none;
|
||||||
|
background: #e74c3c;
|
||||||
|
color: #fff;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pathGuidePosRemoveBtn:hover { background: #c0392b; }
|
||||||
|
.pathGuidePosAddBtn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #337ab7;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pathGuidePosAddBtn:hover { background: #286090; }
|
||||||
|
.pathGuidePosAddBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.pathGuideEditFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
.pathGuidePosList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
#pagePathGuides .mapsMirGoBackBtn,
|
||||||
|
#pagePathGuides .pathGuidePosCard,
|
||||||
|
#pagePathGuides .pathGuidePosAddBtn,
|
||||||
|
#pagePathGuides .mapsMirCreatePanel,
|
||||||
|
#pagePathGuides .mapsMirCreateSubmitBtn,
|
||||||
|
#pagePathGuides .mapsMirCreateCancelBtn {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user