(function () { const body = document.body; const sidebarToggle = document.querySelector("[data-sidebar-toggle]"); const sidebar = document.querySelector(".sidebar"); const toastRoot = document.getElementById("toastRoot"); const workspaceTabsEl = document.querySelector(".workspace-tabs"); const openTabsKey = "teraclone.openTabs"; const collapsedNavGroupsKey = "teraclone.collapsedNavGroups"; const legacyHiddenTabsKey = "teraclone.hiddenTabs"; const portTargetsKey = "teraclone.portTargets"; function showToast(message) { if (!toastRoot) { return; } const toast = document.createElement("div"); toast.className = "toast"; toast.textContent = message; toastRoot.appendChild(toast); setTimeout(function () { toast.remove(); }, 2600); } function parseJSONSafely(response) { return response.json().catch(function () { return {}; }); } sidebarToggle?.addEventListener("click", function () { body.classList.toggle("sidebar-open"); }); function closeSidebar() { body.classList.remove("sidebar-open"); } function readStoredObject(storageKey) { try { const parsed = JSON.parse(window.localStorage.getItem(storageKey) || "{}"); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch (error) { console.error(error); return {}; } } function writeStoredObject(storageKey, value) { window.localStorage.setItem(storageKey, JSON.stringify(value)); } function readOpenTabs() { try { const parsed = JSON.parse(window.localStorage.getItem(openTabsKey) || "[]"); if (!Array.isArray(parsed)) { return []; } return parsed.filter(function (tab) { return tab && typeof tab.key === "string" && tab.key && typeof tab.path === "string" && tab.path && typeof tab.label === "string" && tab.label; }); } catch (error) { console.error(error); return []; } } function writeOpenTabs(tabs) { window.localStorage.setItem(openTabsKey, JSON.stringify(tabs)); } function getCurrentTab() { const path = body.getAttribute("data-current-path") || window.location.pathname; if (path.startsWith("/ports/")) { return { key: "ports", path: path, label: "포트 설정" }; } const key = body.getAttribute("data-current-tab-key") || path; const label = body.getAttribute("data-current-tab-label") || document.title; return { key: key, path: path, label: label }; } function dedupeTabs(tabs) { const seenKeys = new Set(); return tabs.filter(function (tab) { if (seenKeys.has(tab.key)) { return false; } seenKeys.add(tab.key); return true; }); } function syncOpenTabs() { const currentTab = getCurrentTab(); const openTabs = dedupeTabs(readOpenTabs()); const existingTab = openTabs.find(function (tab) { return tab.key === currentTab.key; }); if (existingTab) { existingTab.key = currentTab.key; existingTab.path = currentTab.path; existingTab.label = currentTab.label; } else { openTabs.push(currentTab); } writeOpenTabs(openTabs); return openTabs; } function renderWorkspaceTabs() { if (!workspaceTabsEl) { return; } const currentTab = getCurrentTab(); const openTabs = syncOpenTabs(); workspaceTabsEl.replaceChildren(); openTabs.forEach(function (tab) { const tabEl = document.createElement("div"); tabEl.className = "workspace-tab"; tabEl.setAttribute("data-tab-key", tab.key); tabEl.setAttribute("data-tab-path", tab.path); if (tab.key === currentTab.key) { tabEl.classList.add("active"); } const linkEl = document.createElement("a"); linkEl.className = "workspace-tab-link"; linkEl.href = tab.path; linkEl.textContent = tab.label; const closeEl = document.createElement("button"); closeEl.className = "workspace-close"; closeEl.type = "button"; closeEl.setAttribute("aria-label", tab.label + " 닫기"); closeEl.textContent = "x"; closeEl.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); closeWorkspaceTab(tab.key); }); tabEl.append(linkEl, closeEl); workspaceTabsEl.appendChild(tabEl); }); } function closeWorkspaceTab(tabKey) { const openTabs = syncOpenTabs(); const tabIndex = openTabs.findIndex(function (tab) { return tab.key === tabKey; }); if (tabIndex === -1) { return; } const nextTabs = openTabs.filter(function (tab) { return tab.key !== tabKey; }); writeOpenTabs(nextTabs); if (getCurrentTab().key === tabKey) { const fallbackTab = nextTabs[tabIndex] || nextTabs[tabIndex - 1]; window.location.assign(fallbackTab ? fallbackTab.path : "/"); return; } renderWorkspaceTabs(); } function applyNavGroupState() { const storedGroupState = readStoredObject(collapsedNavGroupsKey); document.querySelectorAll("[data-nav-group]").forEach(function (group) { const groupPath = group.getAttribute("data-path"); const toggle = group.querySelector("[data-nav-toggle]"); const defaultExpanded = group.getAttribute("data-default-expanded") === "true"; if (!groupPath || !toggle) { return; } const expanded = Object.prototype.hasOwnProperty.call(storedGroupState, groupPath) ? Boolean(storedGroupState[groupPath]) : defaultExpanded; group.classList.toggle("expanded", expanded); toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); }); } function bindNavGroups() { applyNavGroupState(); document.querySelectorAll("[data-nav-toggle]").forEach(function (toggle) { toggle.addEventListener("click", function (event) { event.preventDefault(); const group = toggle.closest("[data-nav-group]"); const groupPath = group?.getAttribute("data-path"); if (!groupPath) { return; } const storedGroupState = readStoredObject(collapsedNavGroupsKey); storedGroupState[groupPath] = !group.classList.contains("expanded"); writeStoredObject(collapsedNavGroupsKey, storedGroupState); applyNavGroupState(); }); }); } function bindSidebarDismiss() { document.addEventListener("click", function (event) { if (!body.classList.contains("sidebar-open")) { return; } const target = event.target; if (!(target instanceof Element)) { return; } if (sidebar?.contains(target) || sidebarToggle?.contains(target)) { return; } closeSidebar(); }); document.addEventListener("keydown", function (event) { if (event.key === "Escape") { closeSidebar(); } }); document.querySelectorAll(".nav a:not([data-nav-toggle])").forEach(function (link) { link.addEventListener("click", function () { closeSidebar(); }); }); } function bindModal() { const modals = Array.from(document.querySelectorAll("[data-modal-root]")); if (!modals.length) { return { openNamedModal: function () {}, closeNamedModal: function () {}, }; } function openModal(modal) { modals.forEach(function (item) { item.hidden = true; }); modal.hidden = false; body.classList.add("modal-open"); } function closeModal(modal) { modal.hidden = true; if (modals.every(function (item) { return item.hidden; })) { body.classList.remove("modal-open"); } } function openNamedModal(name) { const modal = document.querySelector('[data-modal-root="' + name + '"]'); if (modal) { openModal(modal); } } function closeNamedModal(name) { const modal = document.querySelector('[data-modal-root="' + name + '"]'); if (modal) { closeModal(modal); } } document.querySelectorAll("[data-open-modal]").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); const modalName = button.getAttribute("data-open-modal"); if (modalName) { openNamedModal(modalName); } }); }); modals.forEach(function (modal) { modal.querySelectorAll("[data-modal-close]").forEach(function (button) { button.addEventListener("click", function () { closeModal(modal); }); }); modal.addEventListener("click", function (event) { const target = event.target; if (target instanceof Element && target.hasAttribute("data-modal-close")) { closeModal(modal); } }); modal.querySelectorAll("[data-mock-action]").forEach(function (button) { button.addEventListener("click", function () { window.setTimeout(function () { closeModal(modal); }, 0); }); }); }); document.addEventListener("keydown", function (event) { if (event.key !== "Escape") { return; } modals.forEach(function (modal) { if (!modal.hidden) { closeModal(modal); } }); }); return { openNamedModal: openNamedModal, closeNamedModal: closeNamedModal, }; } function bindMockActions() { document.querySelectorAll("[data-mock-action]").forEach(function (button) { button.addEventListener("click", async function () { const action = button.getAttribute("data-mock-action") || "mock-action"; try { const response = await fetch("/api/mock/action", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ action: action }).toString(), }); const payload = await parseJSONSafely(response); showToast(payload.message || "동작을 처리했습니다."); } catch (error) { console.error(error); showToast("동작 처리 중 오류가 발생했습니다."); } }); }); document.querySelectorAll("[data-mock-form]").forEach(function (form) { form.addEventListener("submit", async function (event) { event.preventDefault(); const formData = new FormData(form); if (!formData.get("action")) { formData.set("action", "mock-form-submit"); } try { const response = await fetch(form.getAttribute("action") || "/api/mock/action", { method: "POST", body: new URLSearchParams(Array.from(formData.entries())), }); const payload = await parseJSONSafely(response); showToast(payload.message || "적용이 완료되었습니다."); } catch (error) { console.error(error); showToast("처리 중 오류가 발생했습니다."); } }); }); } function bindPortSelection() { const portCards = Array.from(document.querySelectorAll("[data-port-card]")); if (!portCards.length) { return; } const currentPath = body.getAttribute("data-current-path") || window.location.pathname; const currentPortMatch = currentPath.match(/^\/ports\/(\d+)\//); const currentPort = currentPortMatch ? currentPortMatch[1] : ""; function readStoredTargets() { try { const parsed = JSON.parse(window.localStorage.getItem(portTargetsKey) || "[]"); if (!Array.isArray(parsed)) { return []; } return parsed.map(function (value) { return String(value); }); } catch (error) { console.error(error); return []; } } function writeStoredTargets() { const selected = portCards .map(function (card) { return card.classList.contains("selected") ? card.getAttribute("data-port-card") : null; }) .filter(Boolean); window.localStorage.setItem(portTargetsKey, JSON.stringify(selected)); } function syncCard(card) { const checked = card.classList.contains("selected"); const number = card.getAttribute("data-port-card"); if (!number) { return; } const shortcutButton = card.querySelector('[data-port-shortcut="' + number + '"]'); if (shortcutButton) { shortcutButton.classList.toggle("selected", checked); shortcutButton.textContent = checked ? "적용 대상 제외" : "적용 대상 추가"; } } const storedTargets = readStoredTargets(); portCards.forEach(function (card) { const number = card.getAttribute("data-port-card"); card.classList.toggle("selected", Boolean(number && storedTargets.includes(number))); if (number && number === currentPort) { card.classList.add("active"); } syncCard(card); }); function togglePort(number) { const card = document.querySelector('[data-port-card="' + number + '"]'); if (!card) { return; } card.classList.toggle("selected"); syncCard(card); writeStoredTargets(); showToast(card.classList.contains("selected") ? ("Port " + number + " 추가됨") : ("Port " + number + " 제외됨")); } document.querySelectorAll("[data-port-shortcut]").forEach(function (button) { button.addEventListener("click", function (event) { event.preventDefault(); event.stopPropagation(); const number = button.getAttribute("data-port-shortcut"); if (number) { togglePort(number); } }); }); document.querySelectorAll("[data-port-view]").forEach(function (button) { const number = button.getAttribute("data-port-view"); button.addEventListener("click", function () { if (!number) { return; } writeStoredTargets(); window.location.assign("/ports/" + number + "/parameters"); }); }); writeStoredTargets(); } function bindGroupManagement(modalApi) { const groupModal = document.querySelector('[data-modal-root="group-create"]'); const groupForm = groupModal?.querySelector("[data-group-form]"); if (!groupModal || !groupForm) { return; } const titleEl = groupModal.querySelector(".modal-header h2"); const messageEl = groupModal.querySelector(".modal-message"); const idInput = groupForm.querySelector('input[name="id"]'); const nameInput = groupForm.querySelector('input[name="name"]'); const descriptionInput = groupForm.querySelector('input[name="description"]'); const permissionInputs = Array.from(groupForm.querySelectorAll('input[name="permissions"]')); const defaultState = { title: titleEl?.textContent || "그룹 추가", message: messageEl?.textContent || "", }; function syncPermissionVisuals() { permissionInputs.forEach(function (input) { const chip = input.closest(".permission-chip"); if (chip) { chip.classList.toggle("selected", Boolean(input.checked)); } }); } function resetGroupForm() { if (idInput) { idInput.value = ""; } if (nameInput) { nameInput.value = ""; } if (descriptionInput) { descriptionInput.value = ""; } permissionInputs.forEach(function (input) { input.checked = false; }); if (titleEl) { titleEl.textContent = defaultState.title; } if (messageEl) { messageEl.textContent = defaultState.message; } syncPermissionVisuals(); } function applyPermissions(permissions) { const selected = new Set(Array.isArray(permissions) ? permissions : []); permissionInputs.forEach(function (input) { input.checked = selected.has(input.value); }); syncPermissionVisuals(); } permissionInputs.forEach(function (input) { input.addEventListener("change", syncPermissionVisuals); }); document.querySelectorAll('[data-open-modal="group-create"]').forEach(function (button) { button.addEventListener("click", function () { resetGroupForm(); }); }); document.querySelectorAll("[data-group-edit]").forEach(function (button) { button.addEventListener("click", async function () { const id = button.getAttribute("data-group-edit"); if (!id) { return; } try { const response = await fetch("/api/groups/" + encodeURIComponent(id)); const payload = await parseJSONSafely(response); if (!response.ok || !payload.data) { throw new Error(payload.message || "그룹 정보를 불러오지 못했습니다."); } if (idInput) { idInput.value = String(payload.data.id || ""); } if (nameInput) { nameInput.value = payload.data.name || ""; } if (descriptionInput) { descriptionInput.value = payload.data.description || ""; } applyPermissions(payload.data.permissions); if (titleEl) { titleEl.textContent = "그룹 수정"; } if (messageEl) { messageEl.textContent = "사용자에게 연결된 그룹 권한을 수정합니다."; } modalApi.openNamedModal("group-create"); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "그룹 정보를 불러오지 못했습니다."); } }); }); document.querySelectorAll("[data-group-delete]").forEach(function (button) { button.addEventListener("click", async function () { const id = button.getAttribute("data-group-delete"); if (!id || !window.confirm("이 그룹을 삭제하시겠습니까?")) { return; } try { const response = await fetch("/api/groups/" + encodeURIComponent(id), { method: "DELETE", }); const payload = await parseJSONSafely(response); if (!response.ok) { throw new Error(payload.message || "그룹을 삭제하지 못했습니다."); } showToast(payload.message || "그룹을 삭제했습니다."); window.location.reload(); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "그룹을 삭제하지 못했습니다."); } }); }); groupForm.addEventListener("submit", async function (event) { event.preventDefault(); const groupID = idInput?.value.trim() || ""; const payload = { name: nameInput?.value.trim() || "", description: descriptionInput?.value.trim() || "", permissions: permissionInputs.filter(function (input) { return input.checked; }).map(function (input) { return input.value; }), }; const isEdit = groupID !== ""; const endpoint = isEdit ? "/api/groups/" + encodeURIComponent(groupID) : "/api/groups"; const method = isEdit ? "PUT" : "POST"; try { const response = await fetch(endpoint, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await parseJSONSafely(response); if (!response.ok) { throw new Error(result.message || "그룹 저장에 실패했습니다."); } modalApi.closeNamedModal("group-create"); showToast(result.message || "그룹을 저장했습니다."); window.location.reload(); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "그룹 저장에 실패했습니다."); } }); syncPermissionVisuals(); } function bindUserManagement(modalApi) { const userModal = document.querySelector('[data-modal-root="user-create"]'); const userForm = userModal?.querySelector("[data-user-form]"); if (!userModal || !userForm) { return; } const titleEl = userModal.querySelector(".modal-header h2"); const messageEl = userModal.querySelector(".modal-message"); const idInput = userForm.querySelector('input[name="id"]'); const usernameInput = userForm.querySelector('input[name="username"]'); const passwordInput = userForm.querySelector('input[name="password"]'); const groupInput = userForm.querySelector('select[name="group"]'); const enabledInput = userForm.querySelector('select[name="enabled"]'); const defaultState = { title: titleEl?.textContent || "사용자 추가", message: messageEl?.textContent || "", group: groupInput?.value || "administrator", enabled: enabledInput?.value || "true", }; function resetUserForm() { if (idInput) { idInput.value = ""; } if (usernameInput) { usernameInput.value = ""; } if (passwordInput) { passwordInput.value = ""; } if (groupInput) { groupInput.value = defaultState.group; } if (enabledInput) { enabledInput.value = defaultState.enabled; } if (titleEl) { titleEl.textContent = defaultState.title; } if (messageEl) { messageEl.textContent = defaultState.message; } } document.querySelectorAll('[data-open-modal="user-create"]').forEach(function (button) { button.addEventListener("click", function () { resetUserForm(); }); }); document.querySelectorAll("[data-user-edit]").forEach(function (button) { button.addEventListener("click", async function () { const id = button.getAttribute("data-user-edit"); if (!id) { return; } try { const response = await fetch("/api/users/" + encodeURIComponent(id)); const payload = await parseJSONSafely(response); if (!response.ok || !payload.data) { throw new Error(payload.message || "사용자 정보를 불러오지 못했습니다."); } if (idInput) { idInput.value = String(payload.data.id || ""); } if (usernameInput) { usernameInput.value = payload.data.username || ""; } if (passwordInput) { passwordInput.value = ""; } if (groupInput) { groupInput.value = payload.data.group || defaultState.group; } if (enabledInput) { enabledInput.value = String(payload.data.enabled !== false); } if (titleEl) { titleEl.textContent = "사용자 수정"; } if (messageEl) { messageEl.textContent = "비밀번호를 비워두면 기존 값이 유지됩니다."; } modalApi.openNamedModal("user-create"); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "사용자 정보를 불러오지 못했습니다."); } }); }); document.querySelectorAll("[data-user-delete]").forEach(function (button) { button.addEventListener("click", async function () { const id = button.getAttribute("data-user-delete"); if (!id || !window.confirm("이 사용자를 삭제하시겠습니까?")) { return; } try { const response = await fetch("/api/users/" + encodeURIComponent(id), { method: "DELETE", }); const payload = await parseJSONSafely(response); if (!response.ok) { throw new Error(payload.message || "사용자를 삭제하지 못했습니다."); } showToast(payload.message || "사용자를 삭제했습니다."); window.location.reload(); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "사용자를 삭제하지 못했습니다."); } }); }); userForm.addEventListener("submit", async function (event) { event.preventDefault(); const userID = idInput?.value.trim() || ""; const payload = { username: usernameInput?.value.trim() || "", password: passwordInput?.value || "", group: groupInput?.value || defaultState.group, enabled: (enabledInput?.value || "true") === "true", }; const isEdit = userID !== ""; const endpoint = isEdit ? "/api/users/" + encodeURIComponent(userID) : "/api/users"; const method = isEdit ? "PUT" : "POST"; try { const response = await fetch(endpoint, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const result = await parseJSONSafely(response); if (!response.ok) { throw new Error(result.message || "사용자 저장에 실패했습니다."); } modalApi.closeNamedModal("user-create"); showToast(result.message || "사용자를 저장했습니다."); window.location.reload(); } catch (error) { console.error(error); showToast(error instanceof Error ? error.message : "사용자 저장에 실패했습니다."); } }); } window.localStorage.removeItem(legacyHiddenTabsKey); renderWorkspaceTabs(); bindNavGroups(); bindSidebarDismiss(); const modalApi = bindModal(); bindMockActions(); bindPortSelection(); bindGroupManagement(modalApi); bindUserManagement(modalApi); })();