You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

818 lines
25 KiB

4 weeks ago
(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);
})();