This commit is contained in:
@@ -152,9 +152,15 @@ void AuthService::saveUnlocked()
|
||||
db_.setDocument("auth", data_);
|
||||
}
|
||||
|
||||
const AuthSession* AuthService::currentSession() const
|
||||
bool AuthService::canDeleteMap(const nlohmann::json& map, const AuthSession& session)
|
||||
{
|
||||
return tls_session_;
|
||||
if (map.contains("created_by_group") && map["created_by_group"].is_string())
|
||||
{
|
||||
const std::string map_group = map["created_by_group"].get<std::string>();
|
||||
if (!map_group.empty())
|
||||
return map_group == session.group_id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AuthService::extractToken(const httplib::Request& req) const
|
||||
|
||||
@@ -30,7 +30,8 @@ public:
|
||||
|
||||
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res);
|
||||
|
||||
const AuthSession* currentSession() const;
|
||||
static const AuthSession* activeSession() { return tls_session_; }
|
||||
static bool canDeleteMap(const nlohmann::json& map, const AuthSession& session);
|
||||
|
||||
std::optional<nlohmann::json> loginPassword(const std::string& username,
|
||||
const std::string& password,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "auth/auth_service.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
|
||||
@@ -89,6 +90,11 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
if (const AuthSession* session = AuthService::activeSession())
|
||||
{
|
||||
body["created_by_user"] = session->user_id;
|
||||
body["created_by_group"] = session->group_id;
|
||||
}
|
||||
std::string err;
|
||||
const auto created = map_store_.create(body, err);
|
||||
if (!created)
|
||||
@@ -121,6 +127,19 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
|
||||
svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto map = map_store_.find(id);
|
||||
if (!map)
|
||||
return HttpUtil::jsonError(res, 404, "map not found");
|
||||
|
||||
if (const char* disabled = std::getenv("LM_AUTH_DISABLED"); !disabled || std::string(disabled) != "1")
|
||||
{
|
||||
const AuthSession* session = AuthService::activeSession();
|
||||
if (!session)
|
||||
return HttpUtil::jsonError(res, 401, "authentication required");
|
||||
if (!AuthService::canDeleteMap(*map, *session))
|
||||
return HttpUtil::jsonError(res, 403, "cannot delete map from another user group");
|
||||
}
|
||||
|
||||
std::string err;
|
||||
if (!map_store_.remove(id, err))
|
||||
return HttpUtil::jsonError(res, 404, err);
|
||||
|
||||
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS maps (
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
site_id TEXT,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_by_user TEXT,
|
||||
created_by_group TEXT,
|
||||
width REAL,
|
||||
height REAL,
|
||||
resolution REAL,
|
||||
@@ -268,7 +270,7 @@ bool tableHasColumn(sqlite3* db, const char* table, const char* column)
|
||||
|
||||
bool Database::applySchemaMigrations(std::string& err)
|
||||
{
|
||||
const std::string ver = getMeta("schema_version").value_or("1");
|
||||
std::string ver = getMeta("schema_version").value_or("1");
|
||||
|
||||
if (ver == "1")
|
||||
{
|
||||
@@ -317,6 +319,22 @@ bool Database::applySchemaMigrations(std::string& err)
|
||||
setMeta("schema_version", "2");
|
||||
}
|
||||
|
||||
ver = getMeta("schema_version").value_or("1");
|
||||
if (ver == "2")
|
||||
{
|
||||
if (!tableHasColumn(db_, "maps", "created_by_user"))
|
||||
{
|
||||
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_user TEXT", err))
|
||||
return false;
|
||||
}
|
||||
if (!tableHasColumn(db_, "maps", "created_by_group"))
|
||||
{
|
||||
if (!execSql(db_, "ALTER TABLE maps ADD COLUMN created_by_group TEXT", err))
|
||||
return false;
|
||||
}
|
||||
setMeta("schema_version", "3");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ namespace {
|
||||
constexpr const char* kBaseImageName = "map_base.png";
|
||||
|
||||
constexpr const char* kMapSelect =
|
||||
"SELECT id, name, description, site_id, created_by, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at "
|
||||
"FROM maps";
|
||||
"SELECT id, name, description, site_id, created_by, created_by_user, created_by_group, "
|
||||
"width, height, resolution, origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps";
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
@@ -32,11 +32,11 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
};
|
||||
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
if (sqlite3_column_type(stmt, 13) != SQLITE_NULL)
|
||||
if (sqlite3_column_type(stmt, 15) != SQLITE_NULL)
|
||||
{
|
||||
try
|
||||
{
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 13)));
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 15)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
@@ -49,17 +49,19 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{"description", textOrNull(2)},
|
||||
{"site_id", textOrNull(3)},
|
||||
{"created_by", textOrNull(4)},
|
||||
{"width", realOrNull(5)},
|
||||
{"height", realOrNull(6)},
|
||||
{"resolution", realOrNull(7)},
|
||||
{"origin_x", realOrNull(8)},
|
||||
{"origin_y", realOrNull(9)},
|
||||
{"origin_yaw", realOrNull(10)},
|
||||
{"image_file", textOrNull(11)},
|
||||
{"yaml_file", textOrNull(12)},
|
||||
{"created_by_user", textOrNull(5)},
|
||||
{"created_by_group", textOrNull(6)},
|
||||
{"width", realOrNull(7)},
|
||||
{"height", realOrNull(8)},
|
||||
{"resolution", realOrNull(9)},
|
||||
{"origin_x", realOrNull(10)},
|
||||
{"origin_y", realOrNull(11)},
|
||||
{"origin_yaw", realOrNull(12)},
|
||||
{"image_file", textOrNull(13)},
|
||||
{"yaml_file", textOrNull(14)},
|
||||
{"zones", zones},
|
||||
{"created_at", textOrNull(14)},
|
||||
{"updated_at", textOrNull(15)}};
|
||||
{"created_at", textOrNull(16)},
|
||||
{"updated_at", textOrNull(17)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -119,6 +121,8 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
const std::string description = payload.value("description", "");
|
||||
const std::string site_id = payload.value("site_id", "");
|
||||
const std::string created_by = payload.value("created_by", "");
|
||||
const std::string created_by_user = payload.value("created_by_user", "");
|
||||
const std::string created_by_group = payload.value("created_by_group", "");
|
||||
const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
|
||||
|
||||
std::error_code ec;
|
||||
@@ -132,10 +136,10 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO maps(id, name, description, site_id, created_by, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)",
|
||||
"INSERT INTO maps(id, name, description, site_id, created_by, created_by_user, "
|
||||
"created_by_group, width, height, resolution, origin_x, origin_y, origin_yaw, image_file, "
|
||||
"yaml_file, zones_json, created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
@@ -152,27 +156,35 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
else
|
||||
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, created_by.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, payload["width"].get<double>());
|
||||
else
|
||||
if (created_by_user.empty())
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 7, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_text(stmt, 6, created_by_user.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (created_by_group.empty())
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 8, payload["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 8, payload["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
sqlite3_bind_double(stmt, 9, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 10, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 11, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 12);
|
||||
sqlite3_bind_null(stmt, 13);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 9, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 9);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 10, payload["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 10);
|
||||
sqlite3_bind_double(stmt, 11, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 12, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 13, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 14);
|
||||
sqlite3_bind_null(stmt, 15);
|
||||
const std::string zones_str = zones.dump();
|
||||
sqlite3_bind_text(stmt, 14, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 15, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 16, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 16, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 17, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 18, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
@@ -188,6 +200,10 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
|
||||
created["description"] = description;
|
||||
created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id);
|
||||
created["created_by"] = created_by;
|
||||
if (!created_by_user.empty())
|
||||
created["created_by_user"] = created_by_user;
|
||||
if (!created_by_group.empty())
|
||||
created["created_by_group"] = created_by_group;
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
created["width"] = payload["width"];
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
@@ -216,7 +232,7 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key :
|
||||
{"name", "description", "site_id", "created_by", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
{"name", "description", "site_id", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
@@ -237,9 +253,8 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET name=?2, description=?3, site_id=?4, created_by=?5, width=?6, height=?7, "
|
||||
"resolution=?8, origin_x=?9, origin_y=?10, origin_yaw=?11, zones_json=?12, updated_at=?13 "
|
||||
"WHERE id=?1",
|
||||
"UPDATE maps SET name=?2, description=?3, site_id=?4, width=?5, height=?6, resolution=?7, "
|
||||
"origin_x=?8, origin_y=?9, origin_yaw=?10, zones_json=?11, updated_at=?12 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
@@ -256,24 +271,23 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
else
|
||||
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, merged.value("created_by", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["width"].get<double>());
|
||||
sqlite3_bind_double(stmt, 5, merged["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 7, merged["height"].get<double>());
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 7, merged["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 8, merged["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 10, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 11, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_double(stmt, 8, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 10, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 11, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 12, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
|
||||
@@ -334,6 +334,10 @@
|
||||
"maps.sitesDialog.empty": "Chưa có site.",
|
||||
"maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?",
|
||||
"maps.deleteConfirm": "Xóa map \"{name}\"?",
|
||||
"maps.deleteDialog.title": "Xóa map?",
|
||||
"maps.deleteDialog.text": "Xóa map \"{name}\"? Hành động không hoàn tác.",
|
||||
"maps.deleteDialog.activeWarning": "Map này đang là map hoạt động trên robot.",
|
||||
"maps.deleteForbidden": "Bạn không thể xóa map thuộc nhóm người dùng khác.",
|
||||
"maps.error.nameEmpty": "Tên map không được để trống.",
|
||||
"maps.error.noImage": "Map chưa có ảnh — upload PNG trước khi kích hoạt.",
|
||||
"maps.error.pngOnly": "Chỉ chấp nhận file PNG.",
|
||||
@@ -948,6 +952,10 @@
|
||||
"maps.sitesDialog.empty": "No sites yet.",
|
||||
"maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?",
|
||||
"maps.deleteConfirm": "Delete map \"{name}\"?",
|
||||
"maps.deleteDialog.title": "Delete map?",
|
||||
"maps.deleteDialog.text": "Delete map \"{name}\"? This cannot be undone.",
|
||||
"maps.deleteDialog.activeWarning": "This map is currently active on the robot.",
|
||||
"maps.deleteForbidden": "You cannot delete a map from another user group.",
|
||||
"maps.error.nameEmpty": "Map name is required.",
|
||||
"maps.error.noImage": "Map has no image — upload a PNG before activating.",
|
||||
"maps.error.pngOnly": "Only PNG files are accepted.",
|
||||
|
||||
@@ -1264,6 +1264,18 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapsDeleteDialog" class="mapsMirDialog">
|
||||
<div class="mapsMirDialogPanel">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="maps.deleteDialog.title">Delete map?</h2>
|
||||
<p id="mapsDeleteDialogText" class="mapsMirDialogText"></p>
|
||||
<p id="mapsDeleteDialogActiveWarn" class="mapsMirDialogHint" hidden data-i18n="maps.deleteDialog.activeWarning">This map is currently active on the robot.</p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="mapsDeleteNoBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="mapsDeleteYesBtn" data-i18n="common.delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mapEditorMenuDialog" class="mapsMirDialog mapsMirDialog--mapMenu">
|
||||
<h2 class="mapsMirDialogTitle mapsMirMapMenuTitle" data-i18n="maps.menu.title">Upload, download and record maps</h2>
|
||||
<div class="mapsMirMapMenuGrid">
|
||||
|
||||
62
www/maps.js
62
www/maps.js
@@ -25,8 +25,11 @@
|
||||
const sitesDialogEl = el("mapsSitesDialog");
|
||||
const sitesListEl = el("mapsSitesList");
|
||||
const siteFormDialogEl = el("mapsSiteFormDialog");
|
||||
const deleteDialogEl = el("mapsDeleteDialog");
|
||||
const createSiteSelectEl = el("mapsCreateSite");
|
||||
|
||||
let deleteDialogResolve = null;
|
||||
|
||||
const SITE_ICONS = {
|
||||
chevron: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 3l3 4-3 4M8 3l3 4-3 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="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>`,
|
||||
@@ -48,6 +51,19 @@
|
||||
return window.AuthApp?.canWrite?.("maps") ?? true;
|
||||
}
|
||||
|
||||
function currentUser() {
|
||||
return window.AuthApp?.getUser?.() || null;
|
||||
}
|
||||
|
||||
function canDeleteMap(map) {
|
||||
if (!canWrite() || !map) return false;
|
||||
const user = currentUser();
|
||||
if (!user) return true;
|
||||
const mapGroup = map.created_by_group;
|
||||
if (mapGroup) return mapGroup === user.group_id;
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
@@ -216,7 +232,7 @@
|
||||
? `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn" data-edit="${map.id}" data-i18n-title="common.edit" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
|
||||
${canDeleteMap(map) ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${map.id}" data-i18n-title="common.delete" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
|
||||
</div>`
|
||||
: `<div class="mapsMirRowActions">
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${map.id}" data-i18n-title="maps.view" title="${escapeHtml(t("maps.view"))}">${ICONS.view}</button>
|
||||
@@ -310,11 +326,31 @@
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteMap(map) {
|
||||
return new Promise((resolve) => {
|
||||
deleteDialogResolve = resolve;
|
||||
const textEl = el("mapsDeleteDialogText");
|
||||
const activeWarnEl = el("mapsDeleteDialogActiveWarn");
|
||||
if (textEl) textEl.textContent = t("maps.deleteDialog.text", { name: map.name || map.id });
|
||||
if (activeWarnEl) {
|
||||
const isActive = map.id === store.activeMapId;
|
||||
activeWarnEl.hidden = !isActive;
|
||||
if (isActive) activeWarnEl.textContent = t("maps.deleteDialog.activeWarning");
|
||||
}
|
||||
deleteDialogEl?.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteMapFromList(mapId) {
|
||||
const map = findMap(mapId);
|
||||
if (!map) return;
|
||||
if (!confirm(t("maps.deleteConfirm", { name: map.name }))) return;
|
||||
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" });
|
||||
if (!map || !canDeleteMap(map)) return;
|
||||
if (!(await confirmDeleteMap(map))) return;
|
||||
try {
|
||||
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" });
|
||||
} catch (e) {
|
||||
alert(e.message || t("maps.deleteForbidden"));
|
||||
return;
|
||||
}
|
||||
store.maps = store.maps.filter((m) => m.id !== map.id);
|
||||
if (store.activeMapId === map.id) store.activeMapId = null;
|
||||
renderList();
|
||||
@@ -465,6 +501,8 @@
|
||||
name,
|
||||
site_id,
|
||||
created_by: user?.display_name || user?.username || "",
|
||||
created_by_user: user?.id || "",
|
||||
created_by_group: user?.group_id || "",
|
||||
resolution: 0.05,
|
||||
origin_x: 0,
|
||||
origin_y: 0,
|
||||
@@ -515,6 +553,22 @@
|
||||
el("mapsCreateForm")?.addEventListener("submit", (evt) => {
|
||||
createMap(evt).catch((e) => alert(e.message));
|
||||
});
|
||||
el("mapsDeleteYesBtn")?.addEventListener("click", () => {
|
||||
deleteDialogEl?.close();
|
||||
deleteDialogResolve?.(true);
|
||||
deleteDialogResolve = null;
|
||||
});
|
||||
el("mapsDeleteNoBtn")?.addEventListener("click", () => {
|
||||
deleteDialogEl?.close();
|
||||
deleteDialogResolve?.(false);
|
||||
deleteDialogResolve = null;
|
||||
});
|
||||
deleteDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
deleteDialogEl.close();
|
||||
deleteDialogResolve?.(false);
|
||||
deleteDialogResolve = null;
|
||||
});
|
||||
el("mapsSiteForm")?.addEventListener("submit", (evt) => {
|
||||
saveSite(evt).catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
@@ -3259,6 +3259,14 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
||||
|
||||
.mapsMirBtn--outline:hover { background: #f5f5f5; }
|
||||
|
||||
.mapsMirBtn--danger {
|
||||
background: #c0392b;
|
||||
color: #fff;
|
||||
border: 1px solid #c0392b;
|
||||
}
|
||||
|
||||
.mapsMirBtn--danger:hover { background: #a93226; }
|
||||
|
||||
.mapsMirActiveHint {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
|
||||
Reference in New Issue
Block a user