refactor: replace innerHTML with HTML template cloning and event delegation
Move dynamic HTML generation from JS template literals to native <template> elements rendered server-side with i18n. JS now clones templates, fills data-slot attributes via textContent (auto-escaped), and uses event delegation instead of inline onclick/window globals.
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
function show(id) { $(id).classList.remove("hidden"); }
|
||||
function hide(id) { $(id).classList.add("hidden"); }
|
||||
|
||||
// Template cloning helper
|
||||
function cloneTemplate(id) {
|
||||
return document.getElementById(id).content.cloneNode(true);
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" },
|
||||
comfort: { bg: "bg-teal-100 dark:bg-teal-900", text: "text-teal-700 dark:text-teal-300", border: "border-teal-500" },
|
||||
@@ -55,15 +60,6 @@
|
||||
high: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
|
||||
};
|
||||
|
||||
const categoryIcons = {
|
||||
shading: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>',
|
||||
ventilation: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>',
|
||||
internal_gains: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
|
||||
ac_strategy: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
hydration: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>',
|
||||
care: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
|
||||
};
|
||||
|
||||
function tempColorHex(temp) {
|
||||
if (temp >= 40) return "#dc2626";
|
||||
if (temp >= 35) return "#f97316";
|
||||
@@ -124,7 +120,8 @@
|
||||
|
||||
hide("loading");
|
||||
show("data-display");
|
||||
renderDashboard(data);
|
||||
await renderDashboard(data);
|
||||
await initProfileSwitcher(profileId);
|
||||
initQuickSettings();
|
||||
|
||||
// LLM credentials (shared between summary and actions)
|
||||
@@ -263,7 +260,7 @@
|
||||
}
|
||||
|
||||
// ========== Main Render ==========
|
||||
function renderDashboard(data) {
|
||||
async function renderDashboard(data) {
|
||||
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
|
||||
|
||||
// Detect comfort day: cold weather, low risk
|
||||
@@ -295,39 +292,49 @@
|
||||
$("poor-night-cool").classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Warnings — severity-based coloring
|
||||
// Warnings
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
show("warnings-section");
|
||||
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">' + esc(t().warnings) + '</h2>' +
|
||||
data.warnings.map(w => {
|
||||
const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
|
||||
return `
|
||||
<div class="${sc.bg} border ${sc.border} rounded-lg p-3 transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium ${sc.text}">${esc(w.headline)}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${sc.pill}">${esc(w.severity)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
|
||||
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
|
||||
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
const warnList = $("warnings-list");
|
||||
warnList.replaceChildren();
|
||||
for (const w of data.warnings) {
|
||||
const el = cloneTemplate("tpl-warning-card");
|
||||
const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
|
||||
const card = el.firstElementChild;
|
||||
card.classList.add(...sc.bg.split(" "), ...sc.border.split(" "));
|
||||
const headlineEl = el.querySelector('[data-slot="headline"]');
|
||||
headlineEl.textContent = w.headline;
|
||||
headlineEl.classList.add(...sc.text.split(" "));
|
||||
const sevEl = el.querySelector('[data-slot="severity"]');
|
||||
sevEl.textContent = w.severity;
|
||||
sevEl.classList.add(...sc.pill.split(" "));
|
||||
el.querySelector('[data-slot="description"]').textContent = w.description;
|
||||
if (w.instruction) {
|
||||
const instrEl = el.querySelector('[data-slot="instruction"]');
|
||||
instrEl.textContent = w.instruction;
|
||||
instrEl.classList.remove("hidden");
|
||||
}
|
||||
el.querySelector('[data-slot="onset-expires"]').textContent = `${w.onset} \u2014 ${w.expires}`;
|
||||
warnList.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Risk windows
|
||||
if (data.riskWindows && data.riskWindows.length > 0) {
|
||||
show("risk-windows-section");
|
||||
$("risk-windows").innerHTML = data.riskWindows.map(w => {
|
||||
const rwContainer = $("risk-windows");
|
||||
rwContainer.replaceChildren();
|
||||
for (const w of data.riskWindows) {
|
||||
const el = cloneTemplate("tpl-risk-window");
|
||||
const wc = riskColors[w.level] || riskColors.low;
|
||||
return `
|
||||
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
|
||||
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
|
||||
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
el.firstElementChild.classList.add(...wc.bg.split(" "));
|
||||
el.querySelector('[data-slot="time-range"]').textContent = `${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00`;
|
||||
const levelEl = el.querySelector('[data-slot="level"]');
|
||||
levelEl.textContent = w.level;
|
||||
levelEl.classList.add(...wc.text.split(" "));
|
||||
el.querySelector('[data-slot="details"]').textContent = `${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${w.reason}`;
|
||||
rwContainer.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline heatmap
|
||||
@@ -338,60 +345,77 @@
|
||||
// Room budgets
|
||||
if (data.roomBudgets && data.roomBudgets.length > 0) {
|
||||
show("budgets-section");
|
||||
$("room-budgets").innerHTML = data.roomBudgets.map(rb => {
|
||||
const budgetContainer = $("room-budgets");
|
||||
budgetContainer.replaceChildren();
|
||||
for (const rb of data.roomBudgets) {
|
||||
const el = cloneTemplate("tpl-room-budget");
|
||||
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
|
||||
const internalPct = (rb.internalGainsW / maxVal) * 100;
|
||||
const solarPct = (rb.solarGainW / maxVal) * 100;
|
||||
const ventPct = (rb.ventGainW / maxVal) * 100;
|
||||
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
|
||||
const bc = budgetBorderColors[rb.status] || budgetBorderColors.comfortable;
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4 ${bc}">
|
||||
<div class="font-medium text-sm mb-2">${esc(rb.roomName)}</div>
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div class="flex justify-between"><span>${esc(t().internalGains)}</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().solarGain)}</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().ventGain)}</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
||||
<div class="flex justify-between font-medium"><span>${esc(t().totalGain)}</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between"><span>${esc(t().acCapacity)}</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
||||
<div class="flex justify-between font-medium"><span>${esc(t().headroom)}</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
|
||||
<div class="h-full bg-amber-400" style="width: ${internalPct.toFixed(1)}%" title="${esc(t().internalGains)}"></div>
|
||||
<div class="h-full bg-orange-400" style="width: ${solarPct.toFixed(1)}%" title="${esc(t().solarGain)}"></div>
|
||||
<div class="h-full bg-rose-400" style="width: ${ventPct.toFixed(1)}%" title="${esc(t().ventGain)}"></div>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct.toFixed(1)}%"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>${esc(t().internalGains)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>${esc(t().solarGain)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>${esc(t().ventGain)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>${esc(t().acCapacity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
el.firstElementChild.classList.add(bc);
|
||||
el.querySelector('[data-slot="name"]').textContent = rb.roomName;
|
||||
el.querySelector('[data-slot="internal-gains"]').textContent = `${rb.internalGainsW.toFixed(0)} W`;
|
||||
el.querySelector('[data-slot="solar-gain"]').textContent = `${rb.solarGainW.toFixed(0)} W`;
|
||||
el.querySelector('[data-slot="vent-gain"]').textContent = `${rb.ventGainW.toFixed(0)} W`;
|
||||
el.querySelector('[data-slot="total-gain"]').textContent = `${rb.totalGainBtuh.toFixed(0)} BTU/h`;
|
||||
el.querySelector('[data-slot="ac-capacity"]').textContent = `${rb.acCapacityBtuh.toFixed(0)} BTU/h`;
|
||||
el.querySelector('[data-slot="headroom-value"]').textContent = `${rb.headroomBtuh.toFixed(0)} BTU/h`;
|
||||
|
||||
if (rb.headroomBtuh >= 0) {
|
||||
const badEl = el.querySelector('[data-slot="headroom-bad"]');
|
||||
if (badEl) badEl.remove();
|
||||
} else {
|
||||
const okEl = el.querySelector('[data-slot="headroom-ok"]');
|
||||
if (okEl) okEl.remove();
|
||||
const badEl = el.querySelector('[data-slot="headroom-bad"]');
|
||||
badEl.textContent = `${badEl.dataset.label} ${Math.abs(rb.headroomBtuh).toFixed(0)} BTU/h`;
|
||||
badEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
el.querySelector('[data-slot="bar-internal"]').style.width = `${internalPct.toFixed(1)}%`;
|
||||
el.querySelector('[data-slot="bar-solar"]').style.width = `${solarPct.toFixed(1)}%`;
|
||||
el.querySelector('[data-slot="bar-vent"]').style.width = `${ventPct.toFixed(1)}%`;
|
||||
el.querySelector('[data-slot="bar-ac"]').style.width = `${capPct.toFixed(1)}%`;
|
||||
|
||||
budgetContainer.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Care checklist
|
||||
// Care checklist with persistence
|
||||
if (data.careChecklist && data.careChecklist.length > 0) {
|
||||
show("care-section");
|
||||
$("care-checklist").innerHTML = data.careChecklist.map(item => `
|
||||
<li class="flex items-start gap-2">
|
||||
<input type="checkbox" class="mt-1 rounded">
|
||||
<span class="text-sm">${esc(item)}</span>
|
||||
</li>
|
||||
`).join("");
|
||||
const careKey = "care_" + data.date;
|
||||
const savedState = (await getSetting(careKey)) || {};
|
||||
const careList = $("care-checklist");
|
||||
careList.replaceChildren();
|
||||
data.careChecklist.forEach((item, i) => {
|
||||
const el = cloneTemplate("tpl-care-item");
|
||||
const cb = el.querySelector(".care-check");
|
||||
cb.dataset.careIdx = i;
|
||||
cb.dataset.careKey = careKey;
|
||||
if (savedState[i]) cb.checked = true;
|
||||
el.querySelector('[data-slot="text"]').textContent = item;
|
||||
careList.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Fade in LLM skeleton
|
||||
requestAnimationFrame(() => $("llm-summary").classList.remove("opacity-0"));
|
||||
}
|
||||
|
||||
// Care checklist event delegation
|
||||
$("care-checklist").addEventListener("change", async (e) => {
|
||||
const cb = e.target.closest(".care-check");
|
||||
if (!cb) return;
|
||||
const state = (await getSetting(cb.dataset.careKey)) || {};
|
||||
state[cb.dataset.careIdx] = cb.checked;
|
||||
await setSetting(cb.dataset.careKey, state);
|
||||
});
|
||||
|
||||
function formatHourRange(hours) {
|
||||
if (hours.length === 0) return "";
|
||||
if (hours.length === 1) return String(hours[0]).padStart(2, '0') + ":00";
|
||||
@@ -430,39 +454,50 @@
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||||
});
|
||||
|
||||
const html = sortedCats.map(cat => {
|
||||
const icon = categoryIcons[cat] || '';
|
||||
const label = (t().category && t().category[cat]) || cat;
|
||||
const cards = groups[cat].map(a => {
|
||||
const hours = (a.hours || []).sort((x, y) => x - y);
|
||||
const hourRange = formatHourRange(hours);
|
||||
const ec = effortColors[a.effort] || effortColors.none;
|
||||
const ic = impactColors[a.impact] || impactColors.low;
|
||||
return `
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="font-medium text-sm">${esc(a.name)}</div>
|
||||
<div class="text-xs text-gray-400 whitespace-nowrap">${hourRange}</div>
|
||||
</div>
|
||||
${a.description ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${esc(a.description)}</div>` : ''}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${ec}">${esc(t().effort)}: ${esc(a.effort || 'none')}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full ${ic}">${esc(t().impact)}: ${esc(a.impact || 'low')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
const list = $("actions-list");
|
||||
list.replaceChildren();
|
||||
|
||||
return `
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
|
||||
${icon}
|
||||
<span class="font-medium text-sm">${esc(label)}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2">${cards}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
for (const cat of sortedCats) {
|
||||
const groupEl = cloneTemplate("tpl-action-group");
|
||||
|
||||
// Clone icon from hidden container
|
||||
const iconSrc = document.getElementById(`icon-${cat}`);
|
||||
if (iconSrc) {
|
||||
groupEl.querySelector('[data-slot="icon"]').appendChild(iconSrc.cloneNode(true));
|
||||
}
|
||||
|
||||
const label = (t().category && t().category[cat]) || cat;
|
||||
groupEl.querySelector('[data-slot="label"]').textContent = label;
|
||||
|
||||
const cardsContainer = groupEl.querySelector('[data-slot="cards"]');
|
||||
for (const a of groups[cat]) {
|
||||
const cardEl = cloneTemplate("tpl-action-card");
|
||||
cardEl.querySelector('[data-slot="name"]').textContent = a.name;
|
||||
|
||||
const hours = (a.hours || []).sort((x, y) => x - y);
|
||||
cardEl.querySelector('[data-slot="hours"]').textContent = formatHourRange(hours);
|
||||
|
||||
if (a.description) {
|
||||
const descEl = cardEl.querySelector('[data-slot="description"]');
|
||||
descEl.textContent = a.description;
|
||||
descEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const effortEl = cardEl.querySelector('[data-slot="effort"]');
|
||||
const ec = effortColors[a.effort] || effortColors.none;
|
||||
effortEl.textContent = `${effortEl.dataset.label}: ${a.effort || 'none'}`;
|
||||
effortEl.classList.add(...ec.split(" "));
|
||||
|
||||
const impactEl = cardEl.querySelector('[data-slot="impact"]');
|
||||
const ic = impactColors[a.impact] || impactColors.low;
|
||||
impactEl.textContent = `${impactEl.dataset.label}: ${a.impact || 'low'}`;
|
||||
impactEl.classList.add(...ic.split(" "));
|
||||
|
||||
cardsContainer.appendChild(cardEl);
|
||||
}
|
||||
|
||||
list.appendChild(groupEl);
|
||||
}
|
||||
|
||||
hide("actions-loading");
|
||||
show("actions-section");
|
||||
@@ -471,7 +506,6 @@
|
||||
badge.textContent = t().aiActions || "AI";
|
||||
badge.classList.remove("hidden");
|
||||
}
|
||||
$("actions-list").innerHTML = html;
|
||||
}
|
||||
|
||||
// ========== Heatmap Timeline ==========
|
||||
@@ -524,7 +558,7 @@
|
||||
// Hour labels (every 3h) + current hour marker
|
||||
const hourLabelsHtml = timeline.map(s => {
|
||||
const isCurrent = s.hour === currentHour;
|
||||
if (isCurrent) return `<div class="text-center text-xs font-bold text-orange-500">▼</div>`;
|
||||
if (isCurrent) return `<div class="text-center text-xs font-bold text-orange-500">\u25bc</div>`;
|
||||
if (s.hour % 3 !== 0) return `<div></div>`;
|
||||
return `<div class="text-center text-xs text-gray-400">${String(s.hour).padStart(2, "0")}</div>`;
|
||||
}).join("");
|
||||
@@ -741,6 +775,43 @@
|
||||
const refreshBtn = $("refresh-forecast-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast);
|
||||
|
||||
// ========== Profile Switcher ==========
|
||||
let _profileSwitcherInit = false;
|
||||
async function initProfileSwitcher(activeId) {
|
||||
const profiles = await dbGetAll("profiles");
|
||||
const switcher = $("profile-switcher");
|
||||
const nameEl = $("profile-name");
|
||||
if (profiles.length <= 1) {
|
||||
switcher.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
switcher.replaceChildren();
|
||||
for (const p of profiles) {
|
||||
const opt = cloneTemplate("tpl-profile-option").firstElementChild;
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
if (p.id === activeId) opt.selected = true;
|
||||
switcher.appendChild(opt);
|
||||
}
|
||||
switcher.classList.remove("hidden");
|
||||
nameEl.classList.add("hidden");
|
||||
|
||||
if (!_profileSwitcherInit) {
|
||||
_profileSwitcherInit = true;
|
||||
switcher.addEventListener("change", async () => {
|
||||
const newId = parseInt(switcher.value);
|
||||
await setActiveProfileId(newId);
|
||||
_qsInitialized = false;
|
||||
_hourActionMap = null;
|
||||
_currentTimeline = null;
|
||||
_currentTimezone = null;
|
||||
_currentDashDate = null;
|
||||
_profileSwitcherInit = false;
|
||||
await loadDashboard();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Quick Settings ==========
|
||||
let _qsInitialized = false;
|
||||
async function initQuickSettings() {
|
||||
@@ -771,6 +842,11 @@
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
$("qs-apply").addEventListener("click", async () => {
|
||||
const btn = $("qs-apply");
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="inline-block animate-spin mr-1">\u21bb</span>' + esc(origText);
|
||||
|
||||
const tempVal = parseFloat($("qs-indoor-temp").value);
|
||||
const humVal = parseFloat($("qs-indoor-humidity").value);
|
||||
try {
|
||||
@@ -791,9 +867,12 @@
|
||||
_currentTimeline = null;
|
||||
_currentTimezone = null;
|
||||
_currentDashDate = null;
|
||||
loadDashboard();
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
console.error("Quick settings apply error:", e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = origText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
483
web/js/setup.js
483
web/js/setup.js
@@ -2,10 +2,6 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// SVG icon templates
|
||||
const iconEdit = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
||||
const iconDelete = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
||||
|
||||
// Tab switching
|
||||
const tabBtns = document.querySelectorAll(".tab-btn");
|
||||
const tabPanels = document.querySelectorAll(".tab-panel");
|
||||
@@ -13,6 +9,12 @@
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
// Reset edit mode on all forms when switching tabs
|
||||
document.querySelectorAll(".tab-panel form").forEach(f => resetForm(f));
|
||||
// Hide windows section when leaving rooms tab
|
||||
const winSection = document.getElementById("windows-section");
|
||||
if (winSection) winSection.classList.add("hidden");
|
||||
|
||||
tabBtns.forEach(b => {
|
||||
b.classList.remove("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400");
|
||||
b.classList.add("border-transparent", "text-gray-500");
|
||||
@@ -31,31 +33,43 @@
|
||||
if (btn) btn.click();
|
||||
}
|
||||
|
||||
// Toast
|
||||
function showToast(msg, isError) {
|
||||
const toast = document.getElementById("toast");
|
||||
toast.textContent = msg;
|
||||
toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity";
|
||||
toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"]));
|
||||
toast.classList.remove("hidden");
|
||||
setTimeout(() => toast.classList.add("hidden"), 3000);
|
||||
// Template cloning helper
|
||||
function cloneTemplate(id) {
|
||||
return document.getElementById(id).content.cloneNode(true);
|
||||
}
|
||||
|
||||
// Tooltip handling
|
||||
document.addEventListener("click", (e) => {
|
||||
// Toast
|
||||
let _toastTimer = null;
|
||||
function showToast(msg, isError) {
|
||||
const toast = document.getElementById("toast");
|
||||
if (_toastTimer) clearTimeout(_toastTimer);
|
||||
toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity flex items-center gap-2";
|
||||
toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"]));
|
||||
const tpl = cloneTemplate("tpl-toast");
|
||||
tpl.querySelector('[data-slot="message"]').textContent = msg;
|
||||
tpl.querySelector('[data-action="close"]').addEventListener("click", () => toast.classList.add("hidden"));
|
||||
toast.replaceChildren(tpl);
|
||||
toast.classList.remove("hidden");
|
||||
_toastTimer = setTimeout(() => toast.classList.add("hidden"), isError ? 8000 : 3000);
|
||||
}
|
||||
|
||||
// Tooltip handling (hover-based)
|
||||
document.addEventListener("mouseenter", (e) => {
|
||||
const trigger = e.target.closest(".tooltip-trigger");
|
||||
if (!trigger) {
|
||||
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
||||
return;
|
||||
}
|
||||
if (!trigger) return;
|
||||
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
||||
const tip = document.createElement("div");
|
||||
tip.className = "tooltip-popup absolute z-50 p-2 bg-gray-800 text-white text-xs rounded-lg shadow-lg max-w-xs";
|
||||
tip.textContent = trigger.dataset.tooltip;
|
||||
trigger.parentElement.style.position = "relative";
|
||||
trigger.parentElement.appendChild(tip);
|
||||
setTimeout(() => tip.remove(), 5000);
|
||||
});
|
||||
}, true);
|
||||
|
||||
document.addEventListener("mouseleave", (e) => {
|
||||
const trigger = e.target.closest(".tooltip-trigger");
|
||||
if (!trigger) return;
|
||||
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
||||
}, true);
|
||||
|
||||
// Form helpers
|
||||
function formData(form) {
|
||||
@@ -104,22 +118,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
// List item card classes
|
||||
const cardClasses = "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200";
|
||||
|
||||
// Action button builders
|
||||
function editBtn(onclick) {
|
||||
return `<button onclick="${onclick}" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit">${iconEdit}</button>`;
|
||||
}
|
||||
|
||||
function deleteBtn(onclick) {
|
||||
return `<button onclick="${onclick}" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete">${iconDelete}</button>`;
|
||||
}
|
||||
|
||||
function actionGroup(editOnclick, deleteOnclick) {
|
||||
return `<div class="flex items-center gap-1">${editBtn(editOnclick)}${deleteBtn(deleteOnclick)}</div>`;
|
||||
}
|
||||
|
||||
// ========== Profiles ==========
|
||||
async function loadProfiles() {
|
||||
const profiles = await dbGetAll("profiles");
|
||||
@@ -129,32 +127,32 @@
|
||||
return;
|
||||
}
|
||||
const activeId = await getActiveProfileId();
|
||||
list.innerHTML = profiles.map(p => {
|
||||
list.replaceChildren();
|
||||
for (const p of profiles) {
|
||||
const el = cloneTemplate("tpl-profile-card");
|
||||
const isActive = activeId === p.id;
|
||||
return `
|
||||
<div class="${cardClasses}">
|
||||
<div>
|
||||
<span class="font-medium">${esc(p.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="activateProfile(${p.id})" class="text-xs px-2 py-1 rounded ${isActive ? 'bg-orange-600 text-white' : 'bg-gray-100 dark:bg-gray-700'}">
|
||||
${isActive ? '● Active' : 'Set Active'}
|
||||
</button>
|
||||
${actionGroup(`editProfileUI(${p.id})`, `deleteProfileUI(${p.id})`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
el.querySelector('[data-slot="name"]').textContent = p.name;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} \u00b7 ${p.timezone || ""}`;
|
||||
const actBtn = el.querySelector('[data-action="activate"]');
|
||||
actBtn.textContent = isActive ? "\u25cf Active" : "Set Active";
|
||||
if (isActive) {
|
||||
actBtn.classList.add("bg-orange-600", "text-white");
|
||||
} else {
|
||||
actBtn.classList.add("bg-gray-100", "dark:bg-gray-700");
|
||||
}
|
||||
el.firstElementChild.dataset.id = p.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
window.activateProfile = async function(id) {
|
||||
async function activateProfile(id) {
|
||||
await setActiveProfileId(id);
|
||||
await loadProfiles();
|
||||
await refreshRoomSelects();
|
||||
showToast("Profile activated", false);
|
||||
};
|
||||
}
|
||||
|
||||
window.editProfileUI = async function(id) {
|
||||
async function editProfileUI(id) {
|
||||
const p = await dbGet("profiles", id);
|
||||
if (!p) return;
|
||||
const form = document.getElementById("profile-form");
|
||||
@@ -165,16 +163,29 @@
|
||||
form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin";
|
||||
enterEditMode(form);
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
}
|
||||
|
||||
window.deleteProfileUI = async function(id) {
|
||||
async function deleteProfileUI(id) {
|
||||
if (!confirm("Delete this profile and all its data?")) return;
|
||||
const activeId = await getActiveProfileId();
|
||||
await deleteProfile(id);
|
||||
if (activeId === id) await setSetting("activeProfileId", null);
|
||||
await loadProfiles();
|
||||
await updateTabBadges();
|
||||
showToast("Profile deleted", false);
|
||||
};
|
||||
}
|
||||
|
||||
// Event delegation for profiles list
|
||||
document.getElementById("profiles-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editProfileUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteProfileUI(id);
|
||||
else if (btn.dataset.action === "activate") await activateProfile(id);
|
||||
});
|
||||
|
||||
document.getElementById("profile-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -196,6 +207,7 @@
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadProfiles();
|
||||
await updateTabBadges();
|
||||
showToast("Profile saved", false);
|
||||
});
|
||||
|
||||
@@ -203,7 +215,7 @@
|
||||
document.getElementById("geolocate-btn").addEventListener("click", () => {
|
||||
const btn = document.getElementById("geolocate-btn");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "⟳ Detecting…";
|
||||
btn.textContent = "\u27f3 Detecting\u2026";
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
document.querySelector('#profile-form input[name="latitude"]').value = pos.coords.latitude.toFixed(4);
|
||||
@@ -211,13 +223,13 @@
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz) document.querySelector('#profile-form input[name="timezone"]').value = tz;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "📍 Use my location";
|
||||
btn.textContent = "\ud83d\udccd Use my location";
|
||||
},
|
||||
(err) => {
|
||||
const msgs = { 1: "Permission denied.", 2: "Location unavailable.", 3: "Timed out." };
|
||||
showToast(msgs[err.code] || "Location error.", true);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "📍 Use my location";
|
||||
btn.textContent = "\ud83d\udccd Use my location";
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
@@ -233,18 +245,25 @@
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No rooms yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rooms.map(r => `
|
||||
<div class="${cardClasses}">
|
||||
<div>
|
||||
<span class="font-medium">${esc(r.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C</span>
|
||||
</div>
|
||||
${actionGroup(`editRoomUI(${r.id})`, `deleteRoomUI(${r.id})`)}
|
||||
</div>
|
||||
`).join("");
|
||||
// Fetch window counts
|
||||
const winCounts = {};
|
||||
for (const r of rooms) {
|
||||
const wins = await dbGetByIndex("windows", "roomId", r.id);
|
||||
winCounts[r.id] = wins.length;
|
||||
}
|
||||
list.replaceChildren();
|
||||
for (const r of rooms) {
|
||||
const el = cloneTemplate("tpl-room-card");
|
||||
const wc = winCounts[r.id] || 0;
|
||||
const wcLabel = wc > 0 ? ` \u00b7 ${wc} window${wc > 1 ? 's' : ''}` : '';
|
||||
el.querySelector('[data-slot="name"]').textContent = r.name;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${r.areaSqm}m\u00b2 \u00b7 ${r.orientation} \u00b7 SHGC ${r.shgc} \u00b7 ${r.indoorTempC || 25}\u00b0C${wcLabel}`;
|
||||
el.firstElementChild.dataset.id = r.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
window.editRoomUI = async function(id) {
|
||||
async function editRoomUI(id) {
|
||||
const r = await dbGet("rooms", id);
|
||||
if (!r) return;
|
||||
const form = document.getElementById("room-form");
|
||||
@@ -263,15 +282,28 @@
|
||||
form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25;
|
||||
enterEditMode(form);
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
await loadWindowsForRoom(r.id);
|
||||
}
|
||||
|
||||
window.deleteRoomUI = async function(id) {
|
||||
async function deleteRoomUI(id) {
|
||||
if (!confirm("Delete this room and its devices/occupants?")) return;
|
||||
await deleteRoomData(id);
|
||||
await loadRooms();
|
||||
await refreshRoomSelects();
|
||||
await updateTabBadges();
|
||||
showToast("Room deleted", false);
|
||||
};
|
||||
}
|
||||
|
||||
// Event delegation for rooms list
|
||||
document.getElementById("rooms-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editRoomUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteRoomUI(id);
|
||||
});
|
||||
|
||||
document.getElementById("room-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -287,25 +319,121 @@
|
||||
orientation: data.orientation || "S",
|
||||
shadingType: data.shadingType || "none",
|
||||
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
|
||||
ventilation: data.ventilation || "natural",
|
||||
ventilationAch: numOrDefault(data.ventilationAch, 0.5),
|
||||
windowFraction: numOrDefault(data.windowFraction, 0.15),
|
||||
shgc: numOrDefault(data.shgc, 0.6),
|
||||
insulation: data.insulation || "average",
|
||||
indoorTempC: numOrDefault(data.indoorTempC, 25),
|
||||
};
|
||||
let roomId;
|
||||
if (data.id) {
|
||||
room.id = parseInt(data.id);
|
||||
await dbPut("rooms", room);
|
||||
roomId = room.id;
|
||||
} else {
|
||||
await dbAdd("rooms", room);
|
||||
roomId = await dbAdd("rooms", room);
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadRooms();
|
||||
await refreshRoomSelects();
|
||||
await loadWindowsForRoom(roomId);
|
||||
await updateTabBadges();
|
||||
showToast("Room saved", false);
|
||||
});
|
||||
|
||||
// ========== Windows ==========
|
||||
let _currentWindowRoomId = null;
|
||||
|
||||
async function loadWindowsForRoom(roomId) {
|
||||
_currentWindowRoomId = roomId;
|
||||
const section = document.getElementById("windows-section");
|
||||
const form = document.getElementById("window-form");
|
||||
const saveFirst = document.getElementById("windows-save-first");
|
||||
|
||||
if (!roomId) {
|
||||
section.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
section.classList.remove("hidden");
|
||||
saveFirst.classList.add("hidden");
|
||||
form.classList.remove("hidden");
|
||||
form.querySelector('input[name="roomId"]').value = roomId;
|
||||
|
||||
const windows = await dbGetByIndex("windows", "roomId", roomId);
|
||||
const list = document.getElementById("windows-list");
|
||||
if (windows.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No windows. Room-level solar defaults are used.</p>';
|
||||
return;
|
||||
}
|
||||
list.replaceChildren();
|
||||
for (const w of windows) {
|
||||
const el = cloneTemplate("tpl-window-card");
|
||||
el.querySelector('[data-slot="orientation"]').textContent = w.orientation;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${w.areaSqm}m\u00b2 \u00b7 SHGC ${w.shgc} \u00b7 ${w.shadingType} (${w.shadingFactor})`;
|
||||
el.firstElementChild.dataset.id = w.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function editWindowUI(id) {
|
||||
const w = await dbGet("windows", id);
|
||||
if (!w) return;
|
||||
const form = document.getElementById("window-form");
|
||||
form.querySelector('input[name="id"]').value = w.id;
|
||||
form.querySelector('input[name="roomId"]').value = w.roomId;
|
||||
form.querySelector('select[name="orientation"]').value = w.orientation || "S";
|
||||
form.querySelector('input[name="areaSqm"]').value = w.areaSqm || "";
|
||||
form.querySelector('input[name="shgc"]').value = w.shgc || 0.6;
|
||||
form.querySelector('select[name="shadingType"]').value = w.shadingType || "none";
|
||||
form.querySelector('input[name="shadingFactor"]').value = w.shadingFactor ?? 1.0;
|
||||
enterEditMode(form);
|
||||
}
|
||||
|
||||
async function deleteWindowUI(id) {
|
||||
await dbDelete("windows", id);
|
||||
if (_currentWindowRoomId) await loadWindowsForRoom(_currentWindowRoomId);
|
||||
await loadRooms();
|
||||
showToast("Window deleted", false);
|
||||
}
|
||||
|
||||
// Event delegation for windows list
|
||||
document.getElementById("windows-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editWindowUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteWindowUI(id);
|
||||
});
|
||||
|
||||
document.getElementById("window-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = formData(e.target);
|
||||
const roomId = parseInt(data.roomId);
|
||||
if (!roomId) { showToast("No room selected", true); return; }
|
||||
const win = {
|
||||
roomId,
|
||||
orientation: data.orientation || "S",
|
||||
areaSqm: numOrDefault(data.areaSqm, 1.0),
|
||||
shgc: numOrDefault(data.shgc, 0.6),
|
||||
shadingType: data.shadingType || "none",
|
||||
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
|
||||
};
|
||||
if (data.id) {
|
||||
win.id = parseInt(data.id);
|
||||
await dbPut("windows", win);
|
||||
} else {
|
||||
await dbAdd("windows", win);
|
||||
}
|
||||
resetForm(e.target);
|
||||
e.target.querySelector('input[name="roomId"]').value = roomId;
|
||||
await loadWindowsForRoom(roomId);
|
||||
await loadRooms();
|
||||
showToast("Window saved", false);
|
||||
});
|
||||
|
||||
// ========== Devices ==========
|
||||
async function loadDevices() {
|
||||
const profileId = await getActiveProfileId();
|
||||
@@ -321,18 +449,17 @@
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No devices yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allDevices.map(d => `
|
||||
<div class="${cardClasses}">
|
||||
<div>
|
||||
<span class="font-medium">${esc(d.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(d._roomName)} · ${d.wattsTypical}W typical</span>
|
||||
</div>
|
||||
${actionGroup(`editDeviceUI(${d.id})`, `deleteDeviceUI(${d.id})`)}
|
||||
</div>
|
||||
`).join("");
|
||||
list.replaceChildren();
|
||||
for (const d of allDevices) {
|
||||
const el = cloneTemplate("tpl-device-card");
|
||||
el.querySelector('[data-slot="name"]').textContent = d.name;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${d._roomName} \u00b7 ${d.wattsTypical}W typical`;
|
||||
el.firstElementChild.dataset.id = d.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
window.editDeviceUI = async function(id) {
|
||||
async function editDeviceUI(id) {
|
||||
const d = await dbGet("devices", id);
|
||||
if (!d) return;
|
||||
const form = document.getElementById("device-form");
|
||||
@@ -346,13 +473,25 @@
|
||||
form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0;
|
||||
enterEditMode(form);
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
}
|
||||
|
||||
window.deleteDeviceUI = async function(id) {
|
||||
async function deleteDeviceUI(id) {
|
||||
await dbDelete("devices", id);
|
||||
await loadDevices();
|
||||
await updateTabBadges();
|
||||
showToast("Device deleted", false);
|
||||
};
|
||||
}
|
||||
|
||||
// Event delegation for devices list
|
||||
document.getElementById("devices-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editDeviceUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteDeviceUI(id);
|
||||
});
|
||||
|
||||
document.getElementById("device-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -374,6 +513,7 @@
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadDevices();
|
||||
await updateTabBadges();
|
||||
showToast("Device saved", false);
|
||||
});
|
||||
|
||||
@@ -392,18 +532,17 @@
|
||||
list.innerHTML = '<p class="text-sm text-gray-400">No occupants yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = allOccupants.map(o => `
|
||||
<div class="${cardClasses}">
|
||||
<div>
|
||||
<span class="font-medium">${o.count}x ${esc(o.activityLevel)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}</span>
|
||||
</div>
|
||||
${actionGroup(`editOccupantUI(${o.id})`, `deleteOccupantUI(${o.id})`)}
|
||||
</div>
|
||||
`).join("");
|
||||
list.replaceChildren();
|
||||
for (const o of allOccupants) {
|
||||
const el = cloneTemplate("tpl-occupant-card");
|
||||
el.querySelector('[data-slot="count-activity"]').textContent = `${o.count}x ${o.activityLevel}`;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${o._roomName}${o.vulnerable ? ' \u00b7 \u26a0 vulnerable' : ''}`;
|
||||
el.firstElementChild.dataset.id = o.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
window.editOccupantUI = async function(id) {
|
||||
async function editOccupantUI(id) {
|
||||
const o = await dbGet("occupants", id);
|
||||
if (!o) return;
|
||||
const form = document.getElementById("occupant-form");
|
||||
@@ -414,13 +553,25 @@
|
||||
form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable;
|
||||
enterEditMode(form);
|
||||
form.querySelector('input[name="count"]').focus();
|
||||
};
|
||||
}
|
||||
|
||||
window.deleteOccupantUI = async function(id) {
|
||||
async function deleteOccupantUI(id) {
|
||||
await dbDelete("occupants", id);
|
||||
await loadOccupants();
|
||||
await updateTabBadges();
|
||||
showToast("Occupant deleted", false);
|
||||
};
|
||||
}
|
||||
|
||||
// Event delegation for occupants list
|
||||
document.getElementById("occupants-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editOccupantUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteOccupantUI(id);
|
||||
});
|
||||
|
||||
document.getElementById("occupant-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -439,6 +590,7 @@
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadOccupants();
|
||||
await updateTabBadges();
|
||||
showToast("Occupant saved", false);
|
||||
});
|
||||
|
||||
@@ -456,22 +608,19 @@
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name]));
|
||||
|
||||
list.innerHTML = units.map(u => {
|
||||
list.replaceChildren();
|
||||
for (const u of units) {
|
||||
const el = cloneTemplate("tpl-ac-card");
|
||||
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
|
||||
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
|
||||
return `
|
||||
<div class="${cardClasses}">
|
||||
<div>
|
||||
<span class="font-medium">${esc(u.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}</span>
|
||||
</div>
|
||||
${actionGroup(`editACUI(${u.id})`, `deleteACUI(${u.id})`)}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
el.querySelector('[data-slot="name"]').textContent = u.name;
|
||||
el.querySelector('[data-slot="details"]').textContent = `${u.capacityBtu} BTU \u00b7 ${u.acType}${roomNames ? ' \u00b7 ' + roomNames : ''}`;
|
||||
el.firstElementChild.dataset.id = u.id;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
window.editACUI = async function(id) {
|
||||
async function editACUI(id) {
|
||||
const u = await dbGet("ac_units", id);
|
||||
if (!u) return;
|
||||
const form = document.getElementById("ac-form");
|
||||
@@ -489,17 +638,29 @@
|
||||
});
|
||||
enterEditMode(form);
|
||||
form.querySelector('input[name="name"]').focus();
|
||||
};
|
||||
}
|
||||
|
||||
window.deleteACUI = async function(id) {
|
||||
async function deleteACUI(id) {
|
||||
await dbDelete("ac_units", id);
|
||||
const assignments = await dbGetAll("ac_assignments");
|
||||
for (const a of assignments) {
|
||||
if (a.acId === id) await dbDelete("ac_assignments", [a.acId, a.roomId]);
|
||||
}
|
||||
await loadACUnits();
|
||||
await updateTabBadges();
|
||||
showToast("AC unit deleted", false);
|
||||
};
|
||||
}
|
||||
|
||||
// Event delegation for AC list
|
||||
document.getElementById("ac-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const card = btn.closest("[data-id]");
|
||||
if (!card) return;
|
||||
const id = parseInt(card.dataset.id);
|
||||
if (btn.dataset.action === "edit") await editACUI(id);
|
||||
else if (btn.dataset.action === "delete") await deleteACUI(id);
|
||||
});
|
||||
|
||||
document.getElementById("ac-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -533,6 +694,7 @@
|
||||
}
|
||||
resetForm(e.target);
|
||||
await loadACUnits();
|
||||
await updateTabBadges();
|
||||
showToast("AC unit saved", false);
|
||||
});
|
||||
|
||||
@@ -634,29 +796,93 @@
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
const options = rooms.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join("");
|
||||
const hasRooms = rooms.length > 0;
|
||||
|
||||
["device-room-select", "occupant-room-select"].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.innerHTML = options;
|
||||
if (!el) return;
|
||||
el.replaceChildren();
|
||||
if (hasRooms) {
|
||||
for (const r of rooms) {
|
||||
const opt = cloneTemplate("tpl-room-option").firstElementChild;
|
||||
opt.value = r.id;
|
||||
opt.textContent = r.name;
|
||||
el.appendChild(opt);
|
||||
}
|
||||
} else {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "\u2014";
|
||||
el.appendChild(opt);
|
||||
}
|
||||
el.disabled = !hasRooms;
|
||||
});
|
||||
|
||||
// Show/hide no-rooms warnings and disable submit
|
||||
const noRoomIds = ["device-no-rooms", "occupant-no-rooms", "ac-no-rooms"];
|
||||
const formIds = ["device-form", "occupant-form", "ac-form"];
|
||||
noRoomIds.forEach((id, i) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.toggle("hidden", hasRooms);
|
||||
const form = document.getElementById(formIds[i]);
|
||||
if (form) {
|
||||
const btn = form.querySelector(".submit-btn");
|
||||
if (btn) btn.disabled = !hasRooms;
|
||||
}
|
||||
});
|
||||
|
||||
// AC room checkboxes
|
||||
const acCheckboxes = document.getElementById("ac-room-checkboxes");
|
||||
if (acCheckboxes) {
|
||||
acCheckboxes.innerHTML = rooms.map(r => `
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
|
||||
</label>
|
||||
`).join("");
|
||||
acCheckboxes.replaceChildren();
|
||||
for (const r of rooms) {
|
||||
const el = cloneTemplate("tpl-ac-room-checkbox");
|
||||
el.querySelector('input').value = r.id;
|
||||
el.querySelector('[data-slot="name"]').textContent = r.name;
|
||||
acCheckboxes.appendChild(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Utility ==========
|
||||
function esc(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
// ========== Tab Badges ==========
|
||||
async function updateTabBadges() {
|
||||
const profileId = await getActiveProfileId();
|
||||
if (!profileId) return;
|
||||
|
||||
const profiles = await dbGetAll("profiles");
|
||||
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
||||
let deviceCount = 0, occupantCount = 0;
|
||||
for (const r of rooms) {
|
||||
const devs = await dbGetByIndex("devices", "roomId", r.id);
|
||||
deviceCount += devs.length;
|
||||
const occs = await dbGetByIndex("occupants", "roomId", r.id);
|
||||
occupantCount += occs.length;
|
||||
}
|
||||
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
||||
|
||||
const badges = {
|
||||
profiles: profiles.length,
|
||||
rooms: rooms.length,
|
||||
devices: deviceCount,
|
||||
occupants: occupantCount,
|
||||
ac: acUnits.length,
|
||||
};
|
||||
|
||||
tabBtns.forEach(btn => {
|
||||
const tab = btn.dataset.tab;
|
||||
const count = badges[tab];
|
||||
let badge = btn.querySelector(".tab-badge");
|
||||
if (count !== undefined && count > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "tab-badge ml-1 text-xs bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 px-1.5 py-0.5 rounded-full";
|
||||
btn.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
@@ -670,6 +896,7 @@
|
||||
await loadForecastStatus();
|
||||
await loadLLMConfig();
|
||||
await refreshRoomSelects();
|
||||
await updateTabBadges();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<svg id="refresh-icon" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
||||
<span class="hidden sm:inline">{{t "dashboard.refreshForecast"}}</span>
|
||||
</button>
|
||||
<select id="profile-switcher" class="hidden text-sm text-gray-500 dark:text-gray-400 bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1"></select>
|
||||
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +75,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div id="warnings-section" class="hidden space-y-2"></div>
|
||||
<div id="warnings-section" class="hidden space-y-2">
|
||||
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.warnings"}}</h2>
|
||||
<div id="warnings-list" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column: Main content + Sidebar -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
@@ -161,35 +165,118 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden category icons (cloned by dashboard.js) -->
|
||||
<div id="category-icons" class="hidden">
|
||||
<svg id="icon-shading" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>
|
||||
<svg id="icon-ventilation" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>
|
||||
<svg id="icon-internal_gains" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<svg id="icon-ac_strategy" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg id="icon-hydration" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>
|
||||
<svg id="icon-care" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard templates (cloned by dashboard.js) -->
|
||||
<template id="tpl-warning-card">
|
||||
<div class="border rounded-lg p-3 transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium" data-slot="headline"></span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="severity"></span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400" data-slot="description"></div>
|
||||
<div class="text-sm text-orange-700 dark:text-orange-300 mt-1 hidden" data-slot="instruction"></div>
|
||||
<div class="text-xs text-gray-400 mt-1" data-slot="onset-expires"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-risk-window">
|
||||
<div class="flex items-center gap-3 rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
|
||||
<span class="font-mono text-sm" data-slot="time-range"></span>
|
||||
<span class="capitalize font-medium" data-slot="level"></span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400" data-slot="details"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-room-budget">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4">
|
||||
<div class="font-medium text-sm mb-2" data-slot="name"></div>
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div class="flex justify-between"><span>{{t "dashboard.internalGains"}}</span><span data-slot="internal-gains"></span></div>
|
||||
<div class="flex justify-between"><span>{{t "dashboard.solarGain"}}</span><span data-slot="solar-gain"></span></div>
|
||||
<div class="flex justify-between"><span>{{t "dashboard.ventGain"}}</span><span data-slot="vent-gain"></span></div>
|
||||
<div class="flex justify-between font-medium"><span>{{t "dashboard.totalGain"}}</span><span data-slot="total-gain"></span></div>
|
||||
<div class="flex justify-between"><span>{{t "dashboard.acCapacity"}}</span><span data-slot="ac-capacity"></span></div>
|
||||
<div class="flex justify-between font-medium"><span>{{t "dashboard.headroom"}}</span><span data-slot="headroom-value"></span></div>
|
||||
<div class="text-xs mt-0.5 text-green-600 dark:text-green-400" data-slot="headroom-ok">{{t "dashboard.headroomOk"}}</div>
|
||||
<div class="text-xs mt-0.5 text-red-600 dark:text-red-400 hidden" data-slot="headroom-bad" data-label="{{t "dashboard.headroomInsufficient"}}"></div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
|
||||
<div class="h-full bg-amber-400" data-slot="bar-internal" title="{{t "dashboard.internalGains"}}"></div>
|
||||
<div class="h-full bg-orange-400" data-slot="bar-solar" title="{{t "dashboard.solarGain"}}"></div>
|
||||
<div class="h-full bg-rose-400" data-slot="bar-vent" title="{{t "dashboard.ventGain"}}"></div>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-blue-400" data-slot="bar-ac"></div>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>{{t "dashboard.internalGains"}}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>{{t "dashboard.solarGain"}}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>{{t "dashboard.ventGain"}}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>{{t "dashboard.acCapacity"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-care-item">
|
||||
<li class="flex items-start gap-2">
|
||||
<input type="checkbox" class="mt-1 rounded care-check">
|
||||
<span class="text-sm" data-slot="text"></span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<template id="tpl-action-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="font-medium text-sm" data-slot="name"></div>
|
||||
<div class="text-xs text-gray-400 whitespace-nowrap" data-slot="hours"></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden" data-slot="description"></div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="effort" data-label="{{t "dashboard.effort"}}"></span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="impact" data-label="{{t "dashboard.impact"}}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-action-group">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
|
||||
<span data-slot="icon"></span>
|
||||
<span class="font-medium text-sm" data-slot="label"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2" data-slot="cards"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-profile-option">
|
||||
<option></option>
|
||||
</template>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
window.HG.t = {
|
||||
warnings: "{{t "dashboard.warnings"}}",
|
||||
noActions: "{{t "dashboard.noActions"}}",
|
||||
effort: "{{t "dashboard.effort"}}",
|
||||
impact: "{{t "dashboard.impact"}}",
|
||||
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
|
||||
actions: "{{t "dashboard.actions"}}",
|
||||
internalGains: "{{t "dashboard.internalGains"}}",
|
||||
solarGain: "{{t "dashboard.solarGain"}}",
|
||||
ventGain: "{{t "dashboard.ventGain"}}",
|
||||
totalGain: "{{t "dashboard.totalGain"}}",
|
||||
acCapacity: "{{t "dashboard.acCapacity"}}",
|
||||
headroom: "{{t "dashboard.headroom"}}",
|
||||
riskComfort: "{{t "dashboard.riskComfort"}}",
|
||||
coolComfort: "{{t "dashboard.coolComfort"}}",
|
||||
coolVentilate: "{{t "dashboard.coolVentilate"}}",
|
||||
coolAC: "{{t "dashboard.coolAC"}}",
|
||||
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
|
||||
coolSealed: "{{t "dashboard.coolSealed"}}",
|
||||
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
|
||||
aiActions: "{{t "dashboard.aiActions"}}",
|
||||
quickSettings: "{{t "dashboard.quickSettings"}}",
|
||||
qsIndoorTemp: "{{t "dashboard.qsIndoorTemp"}}",
|
||||
qsIndoorHumidity: "{{t "dashboard.qsIndoorHumidity"}}",
|
||||
qsApply: "{{t "dashboard.qsApply"}}",
|
||||
legendTemp: "{{t "dashboard.legendTemp"}}",
|
||||
legendCooling: "{{t "dashboard.legendCooling"}}",
|
||||
legendAI: "{{t "dashboard.legendAI"}}",
|
||||
|
||||
@@ -127,11 +127,59 @@
|
||||
<div id="rooms-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.rooms.noItems"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Windows sub-section (shown after a room is saved/selected for editing) -->
|
||||
<div id="windows-section" class="mt-6 hidden">
|
||||
<h3 class="text-md font-semibold mb-2">{{t "setup.windows.title"}}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">{{t "setup.windows.help"}}</p>
|
||||
<div id="windows-save-first" class="hidden text-sm text-gray-400 italic mb-3">{{t "setup.windows.saveRoomFirst"}}</div>
|
||||
<form id="window-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{{t "setup.windows.orientation.label"}}</label>
|
||||
<select name="orientation" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="N">{{t "setup.rooms.orientation.options.N"}}</option><option value="NE">{{t "setup.rooms.orientation.options.NE"}}</option><option value="E">{{t "setup.rooms.orientation.options.E"}}</option>
|
||||
<option value="SE">{{t "setup.rooms.orientation.options.SE"}}</option><option value="S" selected>{{t "setup.rooms.orientation.options.S"}}</option><option value="SW">{{t "setup.rooms.orientation.options.SW"}}</option>
|
||||
<option value="W">{{t "setup.rooms.orientation.options.W"}}</option><option value="NW">{{t "setup.rooms.orientation.options.NW"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{{t "setup.windows.area.label"}}</label>
|
||||
<input type="number" name="areaSqm" step="0.1" min="0.1" required class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shgc.label"}}</label>
|
||||
<input type="number" name="shgc" step="0.1" min="0" max="1" value="0.6" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shadingType.label"}}</label>
|
||||
<select name="shadingType" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
<option value="none">{{t "setup.rooms.shadingType.options.none"}}</option><option value="blinds">{{t "setup.rooms.shadingType.options.blinds"}}</option>
|
||||
<option value="shutters">{{t "setup.rooms.shadingType.options.shutters"}}</option><option value="awning">{{t "setup.rooms.shadingType.options.awning"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shadingFactor.label"}}</label>
|
||||
<input type="number" name="shadingFactor" step="0.1" min="0" max="1" value="1.0" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="submit-btn px-3 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition" data-add-text="{{t "setup.windows.add"}}" data-save-text="{{t "setup.windows.save"}}">{{t "setup.windows.add"}}</button>
|
||||
<button type="button" class="cancel-btn hidden px-3 py-1 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition">{{t "common.cancel"}}</button>
|
||||
</div>
|
||||
<input type="hidden" name="id" value="">
|
||||
<input type="hidden" name="roomId" value="">
|
||||
</form>
|
||||
<div id="windows-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.windows.noItems"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Devices tab -->
|
||||
<section id="tab-devices" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.devices.help"}}</p>
|
||||
<div id="device-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.devices.noRooms"}}</div>
|
||||
<form id="device-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -177,6 +225,7 @@
|
||||
<!-- Occupants tab -->
|
||||
<section id="tab-occupants" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.occupants.help"}}</p>
|
||||
<div id="occupant-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.occupants.noRooms"}}</div>
|
||||
<form id="occupant-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
@@ -215,6 +264,7 @@
|
||||
<!-- AC Units tab -->
|
||||
<section id="tab-ac" class="tab-panel hidden">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.ac.help"}}</p>
|
||||
<div id="ac-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.ac.noRooms"}}</div>
|
||||
<form id="ac-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div>
|
||||
@@ -328,6 +378,104 @@
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Card templates (cloned by setup.js) -->
|
||||
<template id="tpl-profile-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="name"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button data-action="activate" class="text-xs px-2 py-1 rounded"></button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-room-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="name"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-window-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="orientation"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-device-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="name"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-occupant-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="count-activity"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-ac-card">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
|
||||
<div>
|
||||
<span class="font-medium" data-slot="name"></span>
|
||||
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button data-action="edit" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg></button>
|
||||
<button data-action="delete" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-room-option">
|
||||
<option></option>
|
||||
</template>
|
||||
|
||||
<template id="tpl-ac-room-checkbox">
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" class="rounded">
|
||||
<span data-slot="name"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template id="tpl-toast">
|
||||
<span data-slot="message"></span>
|
||||
<button class="ml-2 text-white/80 hover:text-white text-lg leading-none" data-action="close">×</button>
|
||||
</template>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user