Files

564 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* CIS Enhanced - client logic */
"use strict";
/* ================= helpers ================= */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
function lvColor(lv) {
let h = 0;
for (const c of lv) h = (h * 31 + c.charCodeAt(0)) % 997;
const hue = (h * 1.375) % 360;
return `hsl(${hue}, 52%, 42%)`;
}
function fmtTime(d) {
return d.toTimeString().slice(0, 5);
}
function fmtDate(d) {
const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
return `${String(d.getDate()).padStart(2, "0")}.${months[d.getMonth()]}`;
}
function parseISO(s) {
const [date, time] = s.split("T");
const [y, m, d] = date.split("-").map(Number);
const [hh, mm, ss] = time.split(":").map(Number);
return new Date(y, m - 1, d, hh, mm, ss || 0);
}
function addDays(d, n) {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
/* ================= plan view ================= */
const PX_PER_HOUR = 48;
const DAY_START_H = 8;
let plan = null;
let weekStart = null;
let gruppeFilter = "";
function visibleEvents() {
if (!gruppeFilter) return plan.events;
return plan.events.filter((e) => e.gruppe === gruppeFilter);
}
function setGruppeFilter(v) {
gruppeFilter = v;
renderWeek();
renderSemester();
}
function initPlan() {
plan = window.__PLAN_DATA__;
plan.events = plan.events.map((e) => ({ ...e, s: parseISO(e.start), e2: parseISO(e.end) }));
weekStart = parseISO(plan.initWeek + "T00:00:00");
if (plan.kind === "stg") {
const sel = $("#gruppe-filter");
const groups = [...new Set(plan.events.map((e) => e.gruppe).filter(Boolean))].sort();
if (groups.length > 1) {
for (const g of groups) {
sel.insertAdjacentHTML("beforeend", `<option value="${esc(g)}">${esc(g)}</option>`);
}
sel.style.display = "";
}
}
renderWeek();
renderSemester();
if (plan.tab === "sem") switchTab("sem");
}
function stundeOf(ev) {
const mins = ev.s.getHours() * 60 + ev.s.getMinutes();
let best = 1;
plan.slots.forEach((slot, i) => {
const [hh, mm] = slot.split(":").map(Number);
if (hh * 60 + mm <= mins) best = i + 1;
});
return best;
}
function weekEnd() {
let endH = 18;
for (const e of visibleEvents()) {
const h = e.e2.getHours() + (e.e2.getMinutes() / 60);
if (h > endH) endH = Math.ceil(h + 0.25);
}
return endH;
}
function layoutDay(evs) {
evs.sort((a, b) => (a.s - b.s) || (b.e2 - a.e2));
const clusters = [];
let cur = null, curEnd = null;
for (const e of evs) {
if (!cur || e.s >= curEnd) {
cur = [];
clusters.push(cur);
curEnd = e.e2;
} else {
curEnd = Math.max(curEnd, e.e2);
}
cur.push(e);
}
for (const cluster of clusters) {
const cols = [];
for (const e of cluster) {
let placed = false;
for (let i = 0; i < cols.length; i++) {
if (cols[i][cols[i].length - 1].e2 <= e.s) {
cols[i].push(e);
e._col = i;
placed = true;
break;
}
}
if (!placed) {
e._col = cols.length;
cols.push([e]);
}
}
for (const e of cluster) e._cols = cols.length;
}
}
function renderWeek() {
const grid = $("#week-grid");
if (!grid) return;
const endH = weekEnd();
const height = (endH - DAY_START_H) * PX_PER_HOUR;
const today = new Date();
const monday = weekStart;
const sunday = addDays(monday, 6);
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
const names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
let html = `<div class="wg-corner"></div>`;
for (let i = 0; i < 7; i++) {
const d = addDays(monday, i);
const isToday = sameDay(d, today) ? "today" : "";
const we = i >= 5 ? "weekend" : "";
const monthLabel = (i === 0 || d.getDate() <= 7) ? ` ${MONTHS[d.getMonth()]}` : "";
html += `<div class="wg-dayhead ${isToday} ${we}">${names[i]}<span class="d">${d.getDate()}</span>${monthLabel}</div>`;
}
html += `<div class="wg-time" style="height:${height}px">`;
for (let h = DAY_START_H; h <= endH; h++) {
const top = (h - DAY_START_H) * PX_PER_HOUR;
html += `<span style="top:${top}px">${String(h).padStart(2, "0")}:00</span>`;
}
html += `</div>`;
for (let i = 0; i < 7; i++) {
const d = addDays(monday, i);
const we = i >= 5 ? "weekend" : "";
html += `<div class="wg-col ${we}" style="height:${height}px" data-day="${i}">`;
for (let m = 0; m < (endH - DAY_START_H) * 60; m += 30) {
const top = (m / 60) * PX_PER_HOUR;
if (m % 60 === 0 && m > 0) continue;
html += `<div class="wg-slotline" style="top:${top}px"></div>`;
}
const dayEvents = visibleEvents().filter((ev) => ev.s >= d && ev.s < addDays(d, 1) && ev.s.getHours() >= DAY_START_H);
layoutDay(dayEvents);
for (const ev of dayEvents) {
const top = ((ev.s.getHours() + ev.s.getMinutes() / 60) - DAY_START_H) * PX_PER_HOUR;
const dur = (ev.e2 - ev.s) / 3600000;
const hgt = Math.max(dur * PX_PER_HOUR, 14);
const w = 100 / (ev._cols || 1);
const l = (ev._col || 0) * w;
const pay = { lv: ev.lv, lektor: ev.lektor, gruppe: ev.gruppe, ort: ev.ort,
start: ev.start, end: ev.end, datum: ev.start.slice(0, 10) };
html += `<button class="ev" style="top:${top + 1}px;height:${hgt - 2}px;background:${lvColor(ev.lv)};left:calc(${l}% + 1px);width:calc(${w}% - 3px);right:auto"
onclick='openEvent(${JSON.stringify(pay).replace(/'/g, "&#39;")})'>
<span class="t">${esc(ev.lv)}</span>
${hgt > 34 ? `<span class="sub">${esc(ev.ort)}${ev.gruppe ? " · " + esc(ev.gruppe) : ""}</span>` : ""}
</button>`;
}
html += `</div>`;
}
grid.style.gridTemplateRows = `auto ${height}px`;
grid.innerHTML = html;
$("#week-label").textContent = `KW ${isoWeek(monday)}`;
$("#week-range").textContent = `${fmtDate(monday)} - ${fmtDate(sunday)} ${sunday.getFullYear()}`;
renderLegend();
}
function isoWeek(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
}
function renderLegend() {
const lvs = [...new Set(visibleEvents().map((e) => e.lv))];
$("#legend").innerHTML = lvs.map((lv) =>
`<span class="item"><span class="dot" style="background:${lvColor(lv)}"></span>${esc(lv)}</span>`).join("");
}
function renderSemester() {
const byLv = {};
for (const e of visibleEvents()) {
if (!byLv[e.lv]) byLv[e.lv] = { lv: e.lv, lektors: new Set(), gruppens: new Set(), orte: new Set(), n: 0, hours: 0, first: e.s, last: e.s };
const g = byLv[e.lv];
g.lektors.add(e.lektor);
if (e.gruppe) g.gruppens.add(e.gruppe);
if (e.ort) g.orte.add(e.ort);
g.n++;
g.hours += (e.e2 - e.s) / 3600000;
if (e.s < g.first) g.first = e.s;
if (e.s > g.last) g.last = e.s;
}
const rows = Object.values(byLv).sort((a, b) => a.lv.localeCompare(b.lv));
$("#sem-lv").innerHTML = `
<h3>Lehrveranstaltungen</h3>
${rows.length ? `<table class="data">
<thead><tr><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Orte</th>
<th class="num">Termine</th><th class="num">Std.</th><th>Zeitraum</th></tr></thead>
<tbody>
${rows.map((g) => `<tr>
<td><span class="dot" style="display:inline-block;width:10px;height:10px;border-radius:3px;background:${lvColor(g.lv)};margin-right:6px"></span><b>${esc(g.lv)}</b></td>
<td>${esc([...g.lektors].join(", "))}</td>
<td>${esc([...g.gruppens].join(", ")) || "-"}</td>
<td>${esc([...g.orte].join(", ")) || "-"}</td>
<td class="num">${g.n}</td>
<td class="num">${g.hours.toFixed(1)}</td>
<td>${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}</td>
</tr>`).join("")}
</tbody></table>` : `<div class="empty">Keine Termine in diesem Semester.</div>`}`;
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
$("#sem-events").innerHTML = `
<table class="data">
<thead><tr><th>Datum</th><th>KW</th><th>Zeit</th><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Ort</th></tr></thead>
<tbody>
${visibleEvents().map((e) => `<tr>
<td>${fmtDate(e.s)} ${e.s.getFullYear()}</td>
<td>${names[e.s.getDay()]} / ${isoWeek(e.s)}</td>
<td>${fmtTime(e.s)} - ${fmtTime(e.e2)}</td>
<td><b>${esc(e.lv)}</b></td>
<td>${esc(e.lektor)}</td>
<td>${esc(e.gruppe) || "-"}</td>
<td>${esc(e.ort)}</td>
</tr>`).join("")}
</tbody></table>`;
}
function shiftWeek(n) {
weekStart = addDays(weekStart, n * 7);
renderWeek();
}
function goToday() {
const t = new Date();
weekStart = addDays(t, -((t.getDay() + 6) % 7));
renderWeek();
}
function switchTab(which) {
$("#tab-week").classList.toggle("active", which === "week");
$("#tab-sem").classList.toggle("active", which === "sem");
$("#pane-week").classList.toggle("active", which === "week");
$("#pane-sem").classList.toggle("active", which === "sem");
}
/* ---------- detail modal ---------- */
async function openEvent(ev) {
const modal = $("#detail-modal");
modal.classList.add("open");
const body = $("#detail-body");
body.innerHTML = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`;
const stunde = stundeOf({ s: parseISO(ev.start) });
const detailKind = plan.kind === "student"
? (plan.ptype === "lektor" ? "lektor" : "student")
: plan.kind;
const params = new URLSearchParams({
kind: detailKind, datum: ev.datum, stunde, ort: ev.ort,
});
if (plan.kind === "stg") {
params.set("kz", plan.kz);
params.set("sem", plan.sem);
params.set("ver", plan.ver);
params.set("grp", plan.grp);
} else {
params.set("uid", plan.uid);
}
try {
const r = await fetch("/api/detail?" + params.toString());
const j = await r.json();
if (j.error) throw new Error(j.error);
const rows = j.rows || {};
const lektorCell = rows["LektorIn"] ? (j.lektor_uid
? `<a href="/person/${encodeURIComponent(j.lektor_uid)}" title="Profil anzeigen">${esc(rows["LektorIn"])}</a>`
: esc(rows["LektorIn"])) : null;
body.innerHTML = `
<table>
<tr><td>Datum / Stunde</td><td>${esc(ev.datum)} / Stunde ${esc(stunde)}</td></tr>
<tr><td>LV-Code</td><td><b>${esc(ev.lv)}</b></td></tr>
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
${lektorCell ? `<tr><td>LektorIn</td><td>${lektorCell}</td></tr>` : ""}
${rows["Ort"] ? `<tr><td>Ort</td><td>${esc(rows["Ort"])}</td></tr>` : ""}
${rows["Lehrfach"] ? `<tr><td>Lehrfach</td><td>${esc(rows["Lehrfach"])}</td></tr>` : ""}
${rows["Bezeichnung"] ? `<tr><td>Bezeichnung</td><td>${esc(rows["Bezeichnung"])}</td></tr>` : ""}
${rows["Verband"] ? `<tr><td>Gruppe</td><td>${esc(rows["Verband"])}</td></tr>` : ""}
${rows["Info"] ? `<tr><td>Info</td><td>${esc(rows["Info"])}</td></tr>` : ""}
</table>`;
} catch (e) {
body.innerHTML = `<p style="color:var(--red)">Details konnten nicht geladen werden: ${esc(e.message)}</p>
<table>
<tr><td>Datum</td><td>${esc(ev.datum)}</td></tr>
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
<tr><td>LV</td><td><b>${esc(ev.lv)}</b></td></tr>
<tr><td>LektorIn</td><td>${esc(ev.lektor)}</td></tr>
<tr><td>Gruppe</td><td>${esc(ev.gruppe)}</td></tr>
<tr><td>Ort</td><td>${esc(ev.ort)}</td></tr>
</table>`;
}
}
function closeDetail() {
$("#detail-modal").classList.remove("open");
}
/* ---------- CSV ---------- */
function downloadCSV() {
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
let csv;
if (window.__PLAN_DATA__) {
csv = "Datum;Wochentag;Beginn;Ende;LV;Lektor;Gruppe;Ort\n" + plan.events.map((e) =>
[e.start.slice(0, 10), names[e.s.getDay()], fmtTime(e.s), fmtTime(e.e2),
e.lv, e.lektor, e.gruppe, e.ort].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
).join("\n");
} else if (window.__LIST_DATA__) {
csv = "Nr;Nachname;Vorname;Geschlecht;Matrikelnummer;Mail\n" + window.__LIST_DATA__.map((s, i) =>
[i + 1, s.nachname, s.vorname, s.geschlecht, s.matrikel, s.mail].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
).join("\n");
} else return;
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = (window.__PLAN_DATA__ ? `lvplan_${window.__PLAN_DATA__.semester}` : `namensliste`) + ".csv";
a.click();
URL.revokeObjectURL(a.href);
}
/* ================= Namensliste ================= */
function filterList() {
const q = ($("#list-search").value || "").toLowerCase().trim();
let n = 0;
for (const tr of $("#list-table").querySelectorAll("tbody tr")) {
const hay = (tr.dataset.search || "").toLowerCase();
const show = !q || hay.includes(q);
tr.style.display = show ? "" : "none";
if (show) n++;
}
const c = $("#student-count");
if (c) c.textContent = q ? `${n}` : window.__LIST_COUNT__ || n;
}
/* ================= Matrikel decoder (home) ================= */
function decodeMat() {
const m = $("#decode-input").value.trim();
const box = $("#decode-result");
if (!/^\d{9,10}$/.test(m)) {
box.classList.remove("show");
return;
}
const stgKz = m.slice(-6, -3);
const nr = m.slice(-3);
const year = m.slice(0, 2);
const code = m.slice(2, 4);
let start = `20${year}`;
if (code === "10") start = `WS 20${year}`;
else if (code === "20") start = `SS 20${year}`;
const stg = (window.__STGS__ || []).find((s) => s.kz === stgKz);
box.innerHTML = `
<div class="kv">
<div class="item" style="background:#fff"><div class="k">Startsemester</div><div class="v">${esc(start)}</div></div>
<div class="item" style="background:#fff"><div class="k">Studiengang</div>
<div class="v">${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}</div></div>
<div class="item" style="background:#fff"><div class="k">Fortlaufnummer</div><div class="v">${esc(nr)}</div></div>
<div class="item" style="background:#fff"><div class="k">Plan ansehen</div>
<div class="v"><a class="btn btn-sm" href="/plan?uid=${esc(m)}" style="margin-top:2px">Studentenplan &rarr;</a></div></div>
</div>`;
box.classList.add("show");
}
/* ================= Seminar- + Verband-Selects dynamisch ================= */
async function reloadSemesters() {
const sel = $("#sem-select");
const stgSel = $("#stg-select");
if (!sel || !stgSel) return;
const kz = stgSel.value;
const planSem = (($('select[name="semester"]') || {}).value || "").toUpperCase();
try {
const r = await fetch(`/api/semesters_for_stg?kz=${encodeURIComponent(kz)}`);
if (!r.ok) throw new Error("bad status");
let data = await r.json();
let arr = (Array.isArray(data) ? data : Object.values(data))
.map(Number).filter(n => Number.isInteger(n) && n > 0);
if (planSem.startsWith("WS")) arr = arr.filter(n => n % 2 === 1);
else if (planSem.startsWith("SS")) arr = arr.filter(n => n % 2 === 0);
arr = [...new Set(arr)].sort((a, b) => a - b);
if (arr.length) {
const current = sel.value;
sel.innerHTML = arr.map(n =>
`<option value="${n}"${n === Number(current) ? " selected" : ""}>${n}</option>`).join("");
if (current && !arr.includes(Number(current))) sel.selectedIndex = 0;
}
} catch (e) { /* Keep static options */ }
reloadVerbands();
}
async function reloadVerbands() {
const sel = $("#ver-select");
if (!sel) return;
const kz = $("#stg-select").value;
const sem = $("#sem-select").value;
const current = sel.value;
try {
const r = await fetch(`/api/verbands?kz=${encodeURIComponent(kz)}&sem=${encodeURIComponent(sem)}`);
if (!r.ok) return;
const verbs = await r.json();
if (!Array.isArray(verbs) || !verbs.length) return;
const labels = { V: " (Vollzeit)", B: " (berufsbegl.)" };
sel.innerHTML = verbs.map((v) =>
`<option value="${esc(v)}"${v === current ? " selected" : ""}>${esc(v)}${labels[v] || ""}</option>`).join("");
if (current && !verbs.includes(current)) sel.selectedIndex = 0;
} catch (e) { /* Keep static options */ }
}
/* ================= Gesichtssuche: Job-Polling ================= */
function initSearchJob() {
const job = window.__SEARCH_JOB__;
if (!job) return;
const state = $("#search-state");
const count = $("#search-count");
const progress = $("#search-progress");
const timeBadge = $("#search-time");
const bar = $("#search-bar");
const grid = $("#match-grid");
const hint = $("#search-hint");
const t0 = Date.now();
function tile(r) {
const name = `${esc(r.nachname)}${r.vorname ? ", " + esc(r.vorname) : ""}`;
const pct = Math.max(0, Math.round(r.sim * 100));
const cls = r.sim >= 0.5 ? "match-strong" : (r.sim >= 0.35 ? "match-mid" : "");
const badge = r.kind === "lektor" ? ` <span class="badge badge-muted">Lektor</span>` : "";
return `<div class="match-tile ${cls}">
<a href="/person/${encodeURIComponent(r.uid)}"><img src="${esc(r.img)}" alt="${name}"></a>
<div class="match-name"><a href="/person/${encodeURIComponent(r.uid)}">${name}</a>${badge}</div>
<div class="match-sim">
<div class="sim-bar"><div style="width:${pct}%"></div></div>
<span class="sim-num">${pct} %</span>
</div>
</div>`;
}
function poll() {
fetch(`/api/suche/status/${job.id}`)
.then(r => { if (!r.ok) throw new Error("status"); return r.json(); })
.then(d => {
if (d.total) bar.style.width = (d.done / d.total * 100).toFixed(1) + "%";
progress.textContent = `${d.done}/${d.total}`;
grid.innerHTML = (d.top || []).map(tile).join("");
count.textContent = `${(d.top || []).filter(r => r.sim >= 0.35).length} Treffer`;
if (d.status === "done") {
clearInterval(timer);
state.textContent = d.error ? "Suche fehlgeschlagen" : "Fertig";
hint.style.display = "block";
if (d.error) hint.innerHTML = `<span style="color:#b3261e">${esc(d.error)}</span>`;
const secs = d.took ? `${d.took} s` : `${((Date.now() - t0) / 1000).toFixed(0)} s`;
timeBadge.textContent = secs;
timeBadge.style.display = "";
}
})
.catch(() => { /* retry on next tick */ });
}
const timer = setInterval(poll, 2000);
poll();
}
/* ================= Matrikel/Lektor Vorschläge ================= */
function initUidSuggest() {
const inp = $("#uid-input");
if (!inp) return;
const box = $("#uid-suggest");
let timer = null;
inp.addEventListener("input", () => {
clearTimeout(timer);
const q = inp.value.trim();
if (q.length < 2) { box.classList.remove("show"); return; }
timer = setTimeout(async () => {
try {
const r = await fetch("/api/benutzer?q=" + encodeURIComponent(q));
const arr = await r.json();
if (!arr.length) { box.classList.remove("show"); return; }
box.innerHTML = arr.map(p =>
`<a href="#" data-uid="${esc(p.uid)}">${esc(p.name)}
<span class="mono muted">${esc(p.uid)}</span>
${p.kind === "lektor" ? '<span class="badge badge-muted">Lektor</span>' : ""}</a>`
).join("");
box.classList.add("show");
} catch (e) { box.classList.remove("show"); }
}, 250);
});
box.addEventListener("click", (e) => {
const a = e.target.closest("a[data-uid]");
if (!a) return;
e.preventDefault();
inp.value = a.dataset.uid;
box.classList.remove("show");
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".uid-field")) box.classList.remove("show");
});
}
/* ================= Security-Check ================= */
async function runProbe() {
const btn = $("#probe-btn");
const st = $("#probe-status");
const inp = $("#probe-uid");
const uid = inp ? inp.value.trim() : "";
btn.disabled = true;
st.textContent = "Läuft… 8–10 Requests gegen das CIS, ein paar Sekunden.";
try {
const r = await fetch("/api/security/probe" + (uid ? "?uid=" + encodeURIComponent(uid) : ""));
const j = await r.json();
if (j.error) { st.textContent = j.error; return; }
st.textContent = "";
document.querySelector("#probe-table tbody").innerHTML = j.checks.map((c, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(c.name)}</td>
<td>${c.ok ? '<span class="badge badge-red">REICHWEITE</span>'
: '<span class="badge badge-green">GESCHÜTZT</span>'}</td>
<td>${esc(c.info || "")}</td>
</tr>`).join("");
$("#probe-table").style.display = "";
const tt = $("#probe-target");
tt.innerHTML = `Ziel dieses Laufs: <span class="mono">${esc(j.target)}</span> · ` +
`Semester ${esc(j.semester)} · Beim nächsten Klick neues Zufallsziel.`;
tt.style.display = "block";
} catch (e) {
st.textContent = "Fehler: " + e.message;
} finally {
btn.disabled = false;
}
}
/* ================= boot ================= */
document.addEventListener("DOMContentLoaded", () => {
if (window.__PLAN_DATA__) initPlan();
if (window.__STGS__) decodeMat();
if (window.__SEARCH_JOB__) initSearchJob();
initUidSuggest();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDetail();
});