4.7.2 delete maps
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-21 09:18:14 +02:00
parent 7a850937b0
commit 199f8c0537
9 changed files with 199 additions and 59 deletions

View File

@@ -152,9 +152,15 @@ void AuthService::saveUnlocked()
db_.setDocument("auth", data_); 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 std::string AuthService::extractToken(const httplib::Request& req) const

View File

@@ -30,7 +30,8 @@ public:
httplib::Server::HandlerResponse preRoute(const httplib::Request& req, httplib::Response& res); 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, std::optional<nlohmann::json> loginPassword(const std::string& username,
const std::string& password, const std::string& password,

View File

@@ -1,5 +1,6 @@
#include "server/api_server.hpp" #include "server/api_server.hpp"
#include "auth/auth_service.hpp"
#include "util/file_util.hpp" #include "util/file_util.hpp"
#include "util/http_util.hpp" #include "util/http_util.hpp"
@@ -89,6 +90,11 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
{ {
return HttpUtil::jsonError(res, 400, "invalid JSON"); 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; std::string err;
const auto created = map_store_.create(body, err); const auto created = map_store_.create(body, err);
if (!created) 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) { svr.Delete(R"(/api/maps/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res); HttpUtil::addCors(res);
const std::string id = req.matches[1]; 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; std::string err;
if (!map_store_.remove(id, err)) if (!map_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err); return HttpUtil::jsonError(res, 404, err);

View File

@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS maps (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
site_id TEXT, site_id TEXT,
created_by TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL DEFAULT '',
created_by_user TEXT,
created_by_group TEXT,
width REAL, width REAL,
height REAL, height REAL,
resolution REAL, resolution REAL,
@@ -268,7 +270,7 @@ bool tableHasColumn(sqlite3* db, const char* table, const char* column)
bool Database::applySchemaMigrations(std::string& err) 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") if (ver == "1")
{ {
@@ -317,6 +319,22 @@ bool Database::applySchemaMigrations(std::string& err)
setMeta("schema_version", "2"); 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; return true;
} }

View File

@@ -14,9 +14,9 @@ namespace {
constexpr const char* kBaseImageName = "map_base.png"; constexpr const char* kBaseImageName = "map_base.png";
constexpr const char* kMapSelect = constexpr const char* kMapSelect =
"SELECT id, name, description, site_id, created_by, width, height, resolution, " "SELECT id, name, description, site_id, created_by, created_by_user, created_by_group, "
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at " "width, height, resolution, origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
"FROM maps"; "created_at, updated_at FROM maps";
nlohmann::json rowToJson(sqlite3_stmt* stmt) nlohmann::json rowToJson(sqlite3_stmt* stmt)
{ {
@@ -32,11 +32,11 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
}; };
nlohmann::json zones = nlohmann::json::array(); nlohmann::json zones = nlohmann::json::array();
if (sqlite3_column_type(stmt, 13) != SQLITE_NULL) if (sqlite3_column_type(stmt, 15) != SQLITE_NULL)
{ {
try 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 (...) catch (...)
{ {
@@ -49,17 +49,19 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt)
{"description", textOrNull(2)}, {"description", textOrNull(2)},
{"site_id", textOrNull(3)}, {"site_id", textOrNull(3)},
{"created_by", textOrNull(4)}, {"created_by", textOrNull(4)},
{"width", realOrNull(5)}, {"created_by_user", textOrNull(5)},
{"height", realOrNull(6)}, {"created_by_group", textOrNull(6)},
{"resolution", realOrNull(7)}, {"width", realOrNull(7)},
{"origin_x", realOrNull(8)}, {"height", realOrNull(8)},
{"origin_y", realOrNull(9)}, {"resolution", realOrNull(9)},
{"origin_yaw", realOrNull(10)}, {"origin_x", realOrNull(10)},
{"image_file", textOrNull(11)}, {"origin_y", realOrNull(11)},
{"yaml_file", textOrNull(12)}, {"origin_yaw", realOrNull(12)},
{"image_file", textOrNull(13)},
{"yaml_file", textOrNull(14)},
{"zones", zones}, {"zones", zones},
{"created_at", textOrNull(14)}, {"created_at", textOrNull(16)},
{"updated_at", textOrNull(15)}}; {"updated_at", textOrNull(17)}};
} }
} // namespace } // 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 description = payload.value("description", "");
const std::string site_id = payload.value("site_id", ""); const std::string site_id = payload.value("site_id", "");
const std::string created_by = payload.value("created_by", ""); 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(); const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
std::error_code ec; 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_); std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO maps(id, name, description, site_id, created_by, width, height, resolution, " "INSERT INTO maps(id, name, description, site_id, created_by, created_by_user, "
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, " "created_by_group, width, height, resolution, origin_x, origin_y, origin_yaw, image_file, "
"created_at, updated_at) " "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)", "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)",
-1, -1,
&stmt, &stmt,
nullptr) != SQLITE_OK) nullptr) != SQLITE_OK)
@@ -152,27 +156,35 @@ std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, st
else else
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, created_by.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()) if (created_by_user.empty())
sqlite3_bind_double(stmt, 6, payload["width"].get<double>());
else
sqlite3_bind_null(stmt, 6); sqlite3_bind_null(stmt, 6);
if (payload.contains("height") && payload["height"].is_number())
sqlite3_bind_double(stmt, 7, payload["height"].get<double>());
else else
sqlite3_bind_text(stmt, 6, created_by_user.c_str(), -1, SQLITE_TRANSIENT);
if (created_by_group.empty())
sqlite3_bind_null(stmt, 7); sqlite3_bind_null(stmt, 7);
if (payload.contains("resolution") && payload["resolution"].is_number()) else
sqlite3_bind_double(stmt, 8, payload["resolution"].get<double>()); 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 else
sqlite3_bind_null(stmt, 8); sqlite3_bind_null(stmt, 8);
sqlite3_bind_double(stmt, 9, payload.value("origin_x", 0.0)); if (payload.contains("height") && payload["height"].is_number())
sqlite3_bind_double(stmt, 10, payload.value("origin_y", 0.0)); sqlite3_bind_double(stmt, 9, payload["height"].get<double>());
sqlite3_bind_double(stmt, 11, payload.value("origin_yaw", 0.0)); else
sqlite3_bind_null(stmt, 12); sqlite3_bind_null(stmt, 9);
sqlite3_bind_null(stmt, 13); 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(); const std::string zones_str = zones.dump();
sqlite3_bind_text(stmt, 14, zones_str.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 16, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 15, now.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 17, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 16, now.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 18, now.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) 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["description"] = description;
created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id); created["site_id"] = site_id.empty() ? nullptr : nlohmann::json(site_id);
created["created_by"] = created_by; 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()) if (payload.contains("width") && payload["width"].is_number())
created["width"] = payload["width"]; created["width"] = payload["width"];
if (payload.contains("height") && payload["height"].is_number()) 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; nlohmann::json merged = *existing;
for (const char* key : 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)) if (payload.contains(key))
merged[key] = payload[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_); std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr; sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), if (sqlite3_prepare_v2(db_.handle(),
"UPDATE maps SET name=?2, description=?3, site_id=?4, created_by=?5, width=?6, height=?7, " "UPDATE maps SET name=?2, description=?3, site_id=?4, width=?5, height=?6, resolution=?7, "
"resolution=?8, origin_x=?9, origin_y=?10, origin_yaw=?11, zones_json=?12, updated_at=?13 " "origin_x=?8, origin_y=?9, origin_yaw=?10, zones_json=?11, updated_at=?12 WHERE id=?1",
"WHERE id=?1",
-1, -1,
&stmt, &stmt,
nullptr) != SQLITE_OK) nullptr) != SQLITE_OK)
@@ -256,24 +271,23 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std:
sqlite3_bind_null(stmt, 4); sqlite3_bind_null(stmt, 4);
else else
sqlite3_bind_text(stmt, 4, site_id.c_str(), -1, SQLITE_TRANSIENT); 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()) 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 else
sqlite3_bind_null(stmt, 6); sqlite3_bind_null(stmt, 6);
if (merged["height"].is_number()) if (merged["resolution"].is_number())
sqlite3_bind_double(stmt, 7, merged["height"].get<double>()); sqlite3_bind_double(stmt, 7, merged["resolution"].get<double>());
else else
sqlite3_bind_null(stmt, 7); sqlite3_bind_null(stmt, 7);
if (merged["resolution"].is_number()) sqlite3_bind_double(stmt, 8, merged.value("origin_x", 0.0));
sqlite3_bind_double(stmt, 8, merged["resolution"].get<double>()); sqlite3_bind_double(stmt, 9, merged.value("origin_y", 0.0));
else sqlite3_bind_double(stmt, 10, merged.value("origin_yaw", 0.0));
sqlite3_bind_null(stmt, 8); sqlite3_bind_text(stmt, 11, zones_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_double(stmt, 9, merged.value("origin_x", 0.0)); sqlite3_bind_text(stmt, 12, now.c_str(), -1, SQLITE_TRANSIENT);
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);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE; const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
if (!ok) if (!ok)

View File

@@ -334,6 +334,10 @@
"maps.sitesDialog.empty": "Chưa có site.", "maps.sitesDialog.empty": "Chưa có site.",
"maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?", "maps.sitesDialog.deleteConfirm": "Xóa site \"{name}\"?",
"maps.deleteConfirm": "Xóa map \"{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.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.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.", "maps.error.pngOnly": "Chỉ chấp nhận file PNG.",
@@ -948,6 +952,10 @@
"maps.sitesDialog.empty": "No sites yet.", "maps.sitesDialog.empty": "No sites yet.",
"maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?", "maps.sitesDialog.deleteConfirm": "Delete site \"{name}\"?",
"maps.deleteConfirm": "Delete map \"{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.nameEmpty": "Map name is required.",
"maps.error.noImage": "Map has no image — upload a PNG before activating.", "maps.error.noImage": "Map has no image — upload a PNG before activating.",
"maps.error.pngOnly": "Only PNG files are accepted.", "maps.error.pngOnly": "Only PNG files are accepted.",

View File

@@ -1264,6 +1264,18 @@
</form> </form>
</dialog> </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"> <dialog id="mapEditorMenuDialog" class="mapsMirDialog mapsMirDialog--mapMenu">
<h2 class="mapsMirDialogTitle mapsMirMapMenuTitle" data-i18n="maps.menu.title">Upload, download and record maps</h2> <h2 class="mapsMirDialogTitle mapsMirMapMenuTitle" data-i18n="maps.menu.title">Upload, download and record maps</h2>
<div class="mapsMirMapMenuGrid"> <div class="mapsMirMapMenuGrid">

View File

@@ -25,8 +25,11 @@
const sitesDialogEl = el("mapsSitesDialog"); const sitesDialogEl = el("mapsSitesDialog");
const sitesListEl = el("mapsSitesList"); const sitesListEl = el("mapsSitesList");
const siteFormDialogEl = el("mapsSiteFormDialog"); const siteFormDialogEl = el("mapsSiteFormDialog");
const deleteDialogEl = el("mapsDeleteDialog");
const createSiteSelectEl = el("mapsCreateSite"); const createSiteSelectEl = el("mapsCreateSite");
let deleteDialogResolve = null;
const SITE_ICONS = { 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>`, 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>`, 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; 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) { function escapeHtml(str) {
return String(str) return String(str)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -216,7 +232,7 @@
? `<div class="mapsMirRowActions"> ? `<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-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" 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>`
: `<div class="mapsMirRowActions"> : `<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> <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) { async function deleteMapFromList(mapId) {
const map = findMap(mapId); const map = findMap(mapId);
if (!map) return; if (!map || !canDeleteMap(map)) return;
if (!confirm(t("maps.deleteConfirm", { name: map.name }))) return; if (!(await confirmDeleteMap(map))) return;
await api(`/api/maps/${encodeURIComponent(map.id)}`, { method: "DELETE" }); 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); store.maps = store.maps.filter((m) => m.id !== map.id);
if (store.activeMapId === map.id) store.activeMapId = null; if (store.activeMapId === map.id) store.activeMapId = null;
renderList(); renderList();
@@ -465,6 +501,8 @@
name, name,
site_id, site_id,
created_by: user?.display_name || user?.username || "", created_by: user?.display_name || user?.username || "",
created_by_user: user?.id || "",
created_by_group: user?.group_id || "",
resolution: 0.05, resolution: 0.05,
origin_x: 0, origin_x: 0,
origin_y: 0, origin_y: 0,
@@ -515,6 +553,22 @@
el("mapsCreateForm")?.addEventListener("submit", (evt) => { el("mapsCreateForm")?.addEventListener("submit", (evt) => {
createMap(evt).catch((e) => alert(e.message)); 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) => { el("mapsSiteForm")?.addEventListener("submit", (evt) => {
saveSite(evt).catch((e) => alert(e.message)); saveSite(evt).catch((e) => alert(e.message));
}); });

View File

@@ -3259,6 +3259,14 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
.mapsMirBtn--outline:hover { background: #f5f5f5; } .mapsMirBtn--outline:hover { background: #f5f5f5; }
.mapsMirBtn--danger {
background: #c0392b;
color: #fff;
border: 1px solid #c0392b;
}
.mapsMirBtn--danger:hover { background: #a93226; }
.mapsMirActiveHint { .mapsMirActiveHint {
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 12px; padding: 8px 12px;