hm_ems_report/static/index.html
Vadym Samoilenko 14d67b5f2b Add production deployment setup for Ubuntu with Apache
Configured application for deployment with Apache reverse proxy and systemd service. The app runs without Docker, using Python venv and Gunicorn.

Key changes:
- deploy.sh: Idempotent deployment script with backup, health checks
- systemd/hm-ems.service: Systemd unit for auto-start/restart
- DEPLOY.md: Complete deployment and troubleshooting guide
- .env.example: Configuration template
- Updated API paths: /api/* → /hm-ems/api/* for Apache routing
- Updated image URLs: /images/* → /hm-ems/images/* for Apache alias
- Updated CLAUDE.md: Added production deployment section

Architecture:
- Flask/Gunicorn on localhost:5000 (systemd service)
- Apache reverse proxy for /hm-ems/api/*
- Apache serves static files from /var/www/html/hm-ems-report/
- Apache serves images from /opt/hm-ems-data/campaign_images/
- Data persistence in /opt/hm-ems-data/ (JSON + images)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:30:54 +00:00

693 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H&M EMS Review Tool</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #f0f0f0; color: #333; font-size: 13px;
}
.header {
background: #333; color: #fff; padding: 0 20px; height: 44px;
display: flex; align-items: center; justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.header-brand { font-size: 18px; font-weight: 700; color: #cc0000; letter-spacing: 1px; }
.header-campaign { font-size: 14px; font-weight: 500; }
.header-campaign span { color: #aaa; font-weight: 400; margin-left: 6px; }
.header-right { display: flex; align-items: center; gap: 10px; }
.header-meta { font-size: 10px; color: #999; text-align: right; line-height: 1.4; }
.file-selector {
padding: 4px 8px; font-size: 12px; border: 1px solid #666;
border-radius: 3px; background: #555; color: #fff; cursor: pointer;
}
.file-selector option { background: #444; color: #fff; }
.btn {
padding: 6px 14px; border: none; border-radius: 3px; cursor: pointer;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; transition: background 0.2s;
}
.btn-export { background: #cc0000; color: #fff; }
.btn-export:hover { background: #a30000; }
.btn-export:disabled { background: #888; cursor: default; }
.toolbar {
background: #fff; border-bottom: 1px solid #ddd; padding: 0 20px; height: 38px;
display: flex; align-items: center; gap: 12px;
}
.toolbar .stats {
margin-left: auto; font-size: 11px; color: #888; white-space: nowrap;
}
.toolbar .stats strong { color: #cc0000; }
.approved-count { color: #4caf50; }
.sticky-top { position: sticky; top: 0; z-index: 100; }
.table-container { padding: 0 0 40px; overflow-x: auto; }
.loading-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.85); display: flex;
align-items: center; justify-content: center; z-index: 200;
font-size: 16px; color: #666;
}
.loading-overlay.hidden { display: none; }
.product-table {
border-collapse: collapse; background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 100%;
}
.product-table thead th {
background: #333; color: #fff; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; padding: 6px 8px;
text-align: left; white-space: nowrap; border-right: 1px solid #444;
}
.product-table thead th:last-child { border-right: none; }
.product-table thead th.lang-group-header {
background: #3a3a3a; text-align: center; padding: 5px 6px;
border-left: 2px solid #666;
}
.product-table thead th.lang-group-header.empty-col { background: #2a2a2a; }
.product-table thead th.lang-sub-header {
background: #484848; font-size: 9px; padding: 4px 6px;
}
.product-table thead th.lang-sub-header-first { border-left: 2px solid #666; }
.product-table tbody td {
padding: 5px 8px; border-bottom: 1px solid #e5e5e5;
border-right: 1px solid #e5e5e5; vertical-align: middle; font-size: 12px;
}
.product-table tbody td:last-child { border-right: none; }
.product-table tbody td.lang-group-first { border-left: 2px solid #ddd; }
.product-table tbody td.empty-cell { background: #fafafa; }
.product-table tbody tr:nth-child(even) { background: #f5f5f5; }
.product-table tbody tr:nth-child(even) td.empty-cell { background: #f2f2f2; }
.product-table tbody tr:hover { background: #eef4ff; }
.col-visual { width: 90px; text-align: center; }
.col-article { width: 90px; font-family: 'SF Mono','Consolas',monospace; font-size: 11px; }
.col-gb-name { width: 160px; font-size: 12px; }
.image-group { display: flex; gap: 4px; justify-content: center; flex-wrap: wrap; }
.product-image { max-width: 80px; max-height: 80px; object-fit: contain; border-radius: 2px; cursor: pointer; }
.product-image:hover { opacity: 0.8; }
.image-placeholder {
width: 60px; height: 60px; background: #e0e0e0; border-radius: 2px;
display: inline-flex; align-items: center; justify-content: center;
color: #aaa; font-size: 9px;
}
.image-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); z-index: 300;
display: flex; align-items: center; justify-content: center;
}
.image-modal-overlay.hidden { display: none; }
.image-modal {
background: #fff; border-radius: 8px; padding: 16px;
max-width: 90vw; max-height: 90vh; display: flex;
flex-direction: column; align-items: center; gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.image-modal img {
max-width: 80vw; max-height: 75vh; object-fit: contain; border-radius: 4px;
}
.image-modal-close {
padding: 8px 24px; border: none; border-radius: 4px; cursor: pointer;
font-size: 13px; font-weight: 600; background: #333; color: #fff;
transition: background 0.2s;
}
.image-modal-close:hover { background: #cc0000; }
.edit-input {
width: 100%; padding: 3px 6px; border: 1px solid #ddd; border-radius: 2px;
font-size: 12px; font-family: inherit; background: #fff;
transition: border-color 0.15s, background 0.15s;
}
.edit-input:focus {
outline: none; border-color: #cc0000;
box-shadow: 0 0 0 2px rgba(204,0,0,0.1);
}
.edit-input.changed { background: #fff8e1; border-color: #f5a623; }
.edit-input.differs-from-gb { background: #fffde7; }
.edit-input.changed.differs-from-gb { background: #fff3cd; border-color: #f5a623; }
.edit-input:disabled {
background: #f0f0f0; color: #999; border-color: #e0e0e0; cursor: not-allowed;
}
.approve-btn {
width: 24px; height: 24px; border-radius: 50%; border: 2px solid #ccc;
background: #fff; cursor: pointer; display: inline-flex;
align-items: center; justify-content: center; transition: all 0.15s;
font-size: 13px; color: #ccc; padding: 0; line-height: 1;
}
.approve-btn:hover { border-color: #4caf50; color: #4caf50; }
.approve-btn.approved { background: #4caf50; border-color: #4caf50; color: #fff; }
.approve-btn.saving { opacity: 0.5; pointer-events: none; }
.col-approve { width: 34px; text-align: center; }
.lang-header-controls { display: inline-flex; align-items: center; gap: 6px; }
.lang-header-controls select {
padding: 3px 4px; font-size: 11px; border: 1px solid #666;
border-radius: 2px; background: #555; color: #fff; max-width: 180px;
}
.lang-header-controls select option { background: #444; color: #fff; }
.lang-remove-btn {
width: 18px; height: 18px; border-radius: 50%; border: 1px solid #888;
background: transparent; color: #aaa; cursor: pointer; font-size: 14px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0; line-height: 1; transition: all 0.15s;
}
.lang-remove-btn:hover { background: #cc0000; border-color: #cc0000; color: #fff; }
.change-count {
display: inline-block; background: #cc0000; color: #fff; border-radius: 10px;
padding: 1px 6px; font-size: 10px; font-weight: 600; margin-left: 4px;
min-width: 16px; text-align: center;
}
.change-count.hidden { display: none; }
.save-flash { animation: flashGreen 0.6s ease; }
@keyframes flashGreen {
0% { box-shadow: 0 0 0 0 rgba(76,175,80,0.6); }
50% { box-shadow: 0 0 0 6px rgba(76,175,80,0); }
100% { box-shadow: none; }
}
</style>
</head>
<body>
<div id="loadingOverlay" class="loading-overlay">Loading...</div>
<div id="imageModal" class="image-modal-overlay hidden" onclick="closeImageModal(event)">
<div class="image-modal">
<img id="imageModalImg" src="" alt="Preview">
<button class="image-modal-close" onclick="closeImageModal()">Close</button>
</div>
</div>
<div class="sticky-top">
<div class="header">
<div class="header-left">
<div class="header-brand">H&M</div>
<select class="file-selector" id="fileSelector" onchange="onFileChange()"></select>
<div class="header-campaign" id="campaignInfo"></div>
</div>
<div class="header-right">
<div class="header-meta" id="headerMeta"></div>
<button class="btn btn-export" id="exportBtn" onclick="exportChanges()" disabled>
Export Changes <span class="change-count hidden" id="changeBadge">0</span>
</button>
</div>
</div>
<div class="toolbar">
<div class="stats">
<span id="productCount">0</span> products &middot;
<span id="langCount">0</span> languages &middot;
<strong><span id="editCount">0</span> edits</strong> &middot;
<span class="approved-count"><span id="approvedCount">0</span> approved</span>
</div>
</div>
</div>
<div class="table-container">
<table class="product-table" id="productTable">
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<script>
// ========== State ==========
var RAW_DATA = [];
var IMAGE_MAP = {};
var LANG_DISPLAY = {};
var currentFilename = "";
var MAX_LANG_COLS = 4;
var dataByArticle = {};
var articleOrder = [];
var langColumns = [];
var changes = {};
var approvals = {};
var ALL_LANGS = [];
function langOptionsHtml(selectedLang) {
var h = '<option value="">-- Select Language --</option>';
ALL_LANGS.forEach(function(l) {
var sel = (l === selectedLang) ? " selected" : "";
h += '<option value="' + l + '"' + sel + '>' + LANG_DISPLAY[l] + '</option>';
});
return h;
}
function filledCount() {
var n = 0;
langColumns.forEach(function(l) { if (l !== "") n++; });
return n;
}
function ensureTrailingEmpty() {
while (langColumns.length > 1 && langColumns[langColumns.length - 1] === "" && langColumns[langColumns.length - 2] === "") {
langColumns.pop();
}
if (langColumns.length < MAX_LANG_COLS && (langColumns.length === 0 || langColumns[langColumns.length - 1] !== "")) {
langColumns.push("");
}
}
// ========== Init ==========
async function init() {
try {
var res = await fetch("/hm-ems/api/files");
var files = await res.json();
var sel = document.getElementById("fileSelector");
sel.innerHTML = "";
files.forEach(function(f) {
var opt = document.createElement("option");
opt.value = f.filename;
opt.textContent = f.campaign + " (" + f.filename + ")";
sel.appendChild(opt);
});
if (files.length > 0) {
await loadCampaign(files[0].filename);
}
} catch (err) {
console.error("Init error:", err);
document.getElementById("loadingOverlay").textContent = "Error loading data: " + err.message;
}
}
async function loadCampaign(filename) {
showLoading(true);
try {
var res = await fetch("/hm-ems/api/load/" + encodeURIComponent(filename));
var payload = await res.json();
RAW_DATA = payload.data;
IMAGE_MAP = payload.image_map;
LANG_DISPLAY = payload.lang_display;
currentFilename = payload.filename;
approvals = payload.approvals || {};
ALL_LANGS = Object.keys(LANG_DISPLAY).filter(function(l) { return l !== "en-gb"; }).sort();
// Update header
document.getElementById("campaignInfo").innerHTML =
"Campaign " + payload.campaign + '<span>Season ' + payload.season + '</span>';
document.getElementById("headerMeta").innerHTML =
"Source: " + payload.filename + "<br>Loaded: " + new Date().toLocaleString();
// Reset state
dataByArticle = {};
articleOrder = [];
langColumns = [""];
changes = {};
RAW_DATA.forEach(function(rec) {
var artId = rec["Article id"];
var lang = (rec["Language"] || "").toLowerCase();
if (!dataByArticle[artId]) {
dataByArticle[artId] = {};
articleOrder.push(artId);
}
dataByArticle[artId][lang] = rec;
});
document.getElementById("productCount").textContent = articleOrder.length;
document.getElementById("langCount").textContent = Object.keys(LANG_DISPLAY).length;
rebuildAll();
updateEditCount();
} catch (err) {
console.error("Load error:", err);
alert("Failed to load campaign: " + err.message);
}
showLoading(false);
}
function showLoading(show) {
document.getElementById("loadingOverlay").classList.toggle("hidden", !show);
}
async function onFileChange() {
var filename = document.getElementById("fileSelector").value;
if (filename) await loadCampaign(filename);
}
// ========== Full rebuild ==========
function rebuildAll() {
buildHead();
buildBody();
populateAllColumns();
updateApprovedCount();
}
function buildHead() {
var thead = document.getElementById("tableHead");
var r1 = '<tr>';
r1 += '<th class="col-visual" rowspan="2">Visual</th>';
r1 += '<th class="col-article" rowspan="2">Article ID</th>';
r1 += '<th class="col-gb-name" rowspan="2">Product Name (English)</th>';
langColumns.forEach(function(lang, idx) {
var isEmpty = (lang === "");
var cls = "lang-group-header" + (isEmpty ? " empty-col" : "");
var showRemove = !isEmpty && filledCount() > 1;
r1 += '<th class="' + cls + '" colspan="3">';
r1 += '<div class="lang-header-controls">';
r1 += '<select onchange="onLangColChange(' + idx + ', this.value)">' + langOptionsHtml(lang) + '</select>';
if (showRemove) {
r1 += '<button class="lang-remove-btn" onclick="removeLanguageColumn(' + idx + ')" title="Remove">&minus;</button>';
}
r1 += '</div></th>';
});
r1 += '</tr>';
var r2 = '<tr>';
langColumns.forEach(function(lang) {
var isEmpty = (lang === "");
r2 += '<th class="lang-sub-header lang-sub-header-first">' + (isEmpty ? "" : "Product Name") + '</th>';
r2 += '<th class="lang-sub-header">' + (isEmpty ? "" : "Price") + '</th>';
r2 += '<th class="lang-sub-header col-approve">' + (isEmpty ? "" : "OK") + '</th>';
});
r2 += '</tr>';
thead.innerHTML = r1 + r2;
}
function buildBody() {
var tbody = document.getElementById("tableBody");
var html = "";
articleOrder.forEach(function(artId) {
var gbRec = dataByArticle[artId]["en-gb"];
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
var imgPaths = IMAGE_MAP[artId];
var imgHtml;
if (imgPaths && imgPaths.length > 0) {
imgHtml = '<div class="image-group">';
imgPaths.forEach(function(p) {
imgHtml += '<img class="product-image" src="' + p + '" alt="' + artId + '" loading="lazy" onclick="openImageModal(this.src)">';
});
imgHtml += '</div>';
} else {
imgHtml = '<div class="image-placeholder">No Image</div>';
}
html += '<tr data-article="' + artId + '" id="row_' + artId + '">';
html += '<td class="col-visual">' + imgHtml + '</td>';
html += '<td class="col-article">' + artId + '</td>';
html += '<td class="col-gb-name">' + escapeHtml(gbName) + '</td>';
langColumns.forEach(function(lang, idx) {
var cid = idx + '_' + artId;
if (lang === "") {
html += '<td class="lang-group-first empty-cell"></td>';
html += '<td class="empty-cell"></td>';
html += '<td class="col-approve empty-cell"></td>';
} else {
html += '<td class="lang-group-first"><input type="text" class="edit-input" id="name_' + cid + '" data-article="' + artId + '" data-field="Product name" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
html += '<td><input type="text" class="edit-input" id="price_' + cid + '" data-article="' + artId + '" data-field="Price" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
html += '<td class="col-approve"><button class="approve-btn" id="approve_' + cid + '" onclick="toggleApprove(\x27' + artId + '\x27,' + idx + ')" title="Approve">&#10003;</button></td>';
}
});
html += '</tr>';
});
tbody.innerHTML = html;
}
// ========== Populate ==========
function populateAllColumns() {
langColumns.forEach(function(lang, idx) {
if (lang !== "") populateColumn(idx);
});
}
function populateColumn(idx) {
var lang = langColumns[idx];
if (!lang) return;
articleOrder.forEach(function(artId) {
var rec = dataByArticle[artId][lang];
var gbRec = dataByArticle[artId]["en-gb"];
var cid = idx + '_' + artId;
var nameInput = document.getElementById("name_" + cid);
var priceInput = document.getElementById("price_" + cid);
var approveBtn = document.getElementById("approve_" + cid);
if (!nameInput || !priceInput) return;
var targetName = rec ? (rec["Product name"] || "") : "";
var targetPrice = rec ? (rec["Price"] || "") : "";
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
var nameChangeKey = artId + "::" + lang + "::Product name";
var priceChangeKey = artId + "::" + lang + "::Price";
var approveKey = artId + "::" + lang;
var isApproved = !!approvals[approveKey];
nameInput.value = changes[nameChangeKey] ? changes[nameChangeKey]["New value"] : targetName;
priceInput.value = changes[priceChangeKey] ? changes[priceChangeKey]["New value"] : targetPrice;
nameInput.classList.toggle("changed", !!changes[nameChangeKey]);
priceInput.classList.toggle("changed", !!changes[priceChangeKey]);
nameInput.setAttribute("data-original", targetName);
priceInput.setAttribute("data-original", targetPrice);
nameInput.setAttribute("data-lang", lang);
priceInput.setAttribute("data-lang", lang);
var differs = targetName !== "" && targetName.toUpperCase() !== gbName.toUpperCase();
nameInput.classList.toggle("differs-from-gb", differs);
nameInput.disabled = isApproved;
priceInput.disabled = isApproved;
if (approveBtn) approveBtn.classList.toggle("approved", isApproved);
});
}
// ========== Language columns ==========
function onLangColChange(idx, newLang) {
langColumns[idx] = newLang;
ensureTrailingEmpty();
rebuildAll();
}
function removeLanguageColumn(idx) {
langColumns.splice(idx, 1);
if (langColumns.length === 0) langColumns.push("");
ensureTrailingEmpty();
rebuildAll();
}
// ========== Editing ==========
function onInputChange(el) {
var original = el.getAttribute("data-original");
el.classList.toggle("changed", el.value !== original);
}
function onEdit(el) {
var artId = el.getAttribute("data-article");
var field = el.getAttribute("data-field");
var lang = el.getAttribute("data-lang");
var original = el.getAttribute("data-original");
var newVal = el.value;
var changeKey = artId + "::" + lang + "::" + field;
if (newVal !== original) {
changes[changeKey] = {
"Article id": artId,
"Language": lang,
"Field": field,
"Original value": original,
"New value": newVal,
"Country": getCountryForArticleLang(artId, lang),
"Product id": getProductIdForArticle(artId),
};
el.classList.add("changed");
} else {
delete changes[changeKey];
el.classList.remove("changed");
}
updateEditCount();
}
// ========== Approve with server save ==========
async function toggleApprove(artId, colIdx) {
var lang = langColumns[colIdx];
if (!lang) return;
var approveKey = artId + "::" + lang;
var cid = colIdx + '_' + artId;
var btn = document.getElementById("approve_" + cid);
var nameInput = document.getElementById("name_" + cid);
var priceInput = document.getElementById("price_" + cid);
if (approvals[approveKey]) {
// Un-approve
btn.classList.add("saving");
try {
var res = await fetch("/hm-ems/api/approve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
filename: currentFilename,
article_id: artId,
language: lang,
action: "unapprove",
edits: {}
})
});
if (!res.ok) throw new Error("Server error");
delete approvals[approveKey];
btn.classList.remove("approved");
if (nameInput) nameInput.disabled = false;
if (priceInput) priceInput.disabled = false;
} catch (err) {
alert("Failed to save: " + err.message);
}
btn.classList.remove("saving");
} else {
// Approve — collect any pending edits for this article+lang
var edits = {};
var nameChangeKey = artId + "::" + lang + "::Product name";
var priceChangeKey = artId + "::" + lang + "::Price";
if (nameInput) edits["Product name"] = nameInput.value;
if (priceInput) edits["Price"] = priceInput.value;
btn.classList.add("saving");
try {
var res = await fetch("/hm-ems/api/approve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
filename: currentFilename,
article_id: artId,
language: lang,
action: "approve",
edits: edits
})
});
if (!res.ok) throw new Error("Server error");
approvals[approveKey] = true;
btn.classList.add("approved");
if (nameInput) nameInput.disabled = true;
if (priceInput) priceInput.disabled = true;
// Update the local data so the "original" reflects saved state
var rec = dataByArticle[artId] && dataByArticle[artId][lang];
if (rec) {
if (edits["Product name"] != null) rec["Product name"] = edits["Product name"];
if (edits["Price"] != null) rec["Price"] = edits["Price"];
}
if (nameInput) {
nameInput.setAttribute("data-original", nameInput.value);
nameInput.classList.remove("changed");
}
if (priceInput) {
priceInput.setAttribute("data-original", priceInput.value);
priceInput.classList.remove("changed");
}
// Clear changes for this article+lang since they're now saved
delete changes[nameChangeKey];
delete changes[priceChangeKey];
updateEditCount();
// Visual flash
btn.classList.add("save-flash");
setTimeout(function() { btn.classList.remove("save-flash"); }, 600);
} catch (err) {
alert("Failed to save: " + err.message);
}
btn.classList.remove("saving");
}
updateApprovedCount();
}
// ========== Helpers ==========
function getCountryForArticleLang(artId, lang) {
var rec = dataByArticle[artId] && dataByArticle[artId][lang];
return rec ? (rec["Country"] || "") : "";
}
function getProductIdForArticle(artId) {
var rec = dataByArticle[artId] && dataByArticle[artId]["en-gb"];
return rec ? (rec["Product id"] || "") : "";
}
function updateEditCount() {
var count = Object.keys(changes).length;
document.getElementById("editCount").textContent = count;
var badge = document.getElementById("changeBadge");
var btn = document.getElementById("exportBtn");
if (count > 0) {
badge.textContent = count;
badge.classList.remove("hidden");
btn.disabled = false;
} else {
badge.classList.add("hidden");
btn.disabled = true;
}
}
function updateApprovedCount() {
document.getElementById("approvedCount").textContent = Object.keys(approvals).length;
}
function exportChanges() {
var changeList = Object.values(changes);
if (changeList.length === 0) return;
changeList.forEach(function(c) {
c["Approved"] = !!approvals[c["Article id"] + "::" + c["Language"]];
});
var exportData = {
"export_date": new Date().toISOString(),
"source_file": currentFilename,
"total_changes": changeList.length,
"changes": changeList
};
var blob = new Blob([JSON.stringify(exportData, null, 2)], {type: "application/json"});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "ems_changes_" + new Date().toISOString().slice(0,19).replace(/[:T]/g,"_") + ".json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function openImageModal(src) {
document.getElementById("imageModalImg").src = src;
document.getElementById("imageModal").classList.remove("hidden");
}
function closeImageModal(event) {
if (event && event.target !== document.getElementById("imageModal") && event.type === "click") return;
document.getElementById("imageModal").classList.add("hidden");
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
document.addEventListener("DOMContentLoaded", init);
</script>
</body>
</html>