모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:

- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제
- API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector)
- 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
This commit is contained in:
Rinjae
2026-07-20 19:10:05 +09:00
parent ba00b80bfe
commit 43d915342e
22 changed files with 1400 additions and 1500 deletions
@@ -22,7 +22,7 @@
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<!-- Step 1: 앱 정보입력 (Completed) -->
<!-- Step 1: 앱 정보입력 -->
<div class="progress-step-item">
<div class="step-icon-wrapper">
<div class="step-icon">
@@ -64,676 +64,23 @@
</div>
</div>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span th:text="${error}"></span>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<!-- Form Content with Sidebar Layout -->
<div class="register-form-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- Sidebar Title -->
<div class="sidebar-title">API 서비스 목록</div>
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
<span class="menu-text">전체</span>
<span class="api-count"></span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
<span class="menu-text" th:text="${service.groupName}">서비스명</span>
<span class="api-count"></span>
</div>
</div>
</nav>
</aside>
<!-- Mobile Menu Toggle -->
<button class="apikey-mobile-toggle" id="mobileToggle" aria-label="메뉴 열기">
</button>
<!-- Mobile Overlay -->
<div class="apikey-mobile-overlay" id="mobileOverlay"></div>
<!-- Main Content -->
<main class="apikey-register-content">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="register-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Search Box with Select All -->
<div class="api-filter-header">
<div class="select-all-wrapper" id="selectAllWrapper" style="display: none;">
<label class="select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="select-all-checkbox visually-hidden">
<span class="custom-checkbox"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
</svg>
</div>
</div>
<!-- API Cards Grid -->
<div class="api-selection-card-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="loading-state" id="loadingState" style="grid-column: 1 / -1;">
<div class="loading-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state (initially hidden) -->
<div class="empty-api-state" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>왼쪽 메뉴에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
<!-- API cards will be dynamically loaded here -->
</div>
</form>
</main>
</div>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" id="btnPrevStep" class="btn-submit btn-secondary">
이전
</button>
<button type="submit" form="modifyStep2Form" class="btn-submit btn-primary">
<button type="submit" form="apiSelectorForm" class="btn-submit btn-primary">
저장
</button>
</div>
</section>
<!-- Floating Cart Button -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<span class="cart-label">선택된 API</span>
<span class="cart-badge" id="cartBadge">
<span class="cart-count">0</span>
<span class="cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal - Uses base modal component -->
<div class="modal" id="selectedApisModal" style="display: none;">
<div class="modal-backdrop" id="modalOverlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">선택된 API 목록</h3>
<button type="button" class="modal-close" id="modalCloseBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="selected-apis-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const form = document.getElementById('modifyStep2Form');
const searchInput = document.getElementById('apiSearch');
const sidebar = document.getElementById('apiSidebar');
const mobileToggle = document.getElementById('mobileToggle');
const mobileOverlay = document.getElementById('mobileOverlay');
const menuTitles = document.querySelectorAll('.menu-title');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체'; // Store current service name
let allApis = []; // Store loaded APIs
let allLoadedApis = []; // Store ALL APIs from server for modal display
let selectedApis = new Set(); // Track selected API IDs
// Restore selected APIs from session (for modification)
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load all APIs for modal display (used when APIs from different categories are selected)
function loadAllApisForModal() {
fetch('/apis/for_request')
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
console.log('Loaded', allLoadedApis.length, 'APIs for modal display');
})
.catch(error => {
console.error('Failed to load all APIs for modal:', error);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
// Show loading state
loadingState.style.display = 'block';
emptyState.style.display = 'none';
// Remove existing API cards
document.querySelectorAll('.api-selection-card').forEach(card => card.remove());
// Build URL with optional groupId filter
let url = '/apis/for_request';
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
return;
}
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
const card = createApiCard(api);
fragment.appendChild(card);
});
apiCardGrid.appendChild(fragment);
// Re-attach event listeners
attachCardEventListeners();
// Update modal list after cards are rendered
updateModalList();
}
// Create API card element
function createApiCard(api) {
const card = document.createElement('div');
card.className = 'api-selection-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="api-card-content">
<div class="api-card-header">
<span class="api-card-category">${api.service || '카테고리'}</span>
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="custom-checkbox"></span>
</div>
</div>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-card-icon">
${mainIconHtml}
</div>
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const checkboxes = document.querySelectorAll('.api-checkbox');
const apiCards = document.querySelectorAll('.api-selection-card');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
// Prevent double toggle if clicking directly on checkbox
if (e.target.classList.contains('api-checkbox')) {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.api-selection-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
// Update floating cart button
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
// Update select all checkbox state
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
// Hide select all when "전체" is selected
selectAllWrapper.style.display = 'none';
} else {
// Show select all with service name
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="empty-message">선택된 API가 없습니다.</p>';
return;
}
// Build list using selectedApis Set for consistency
selectedApis.forEach(function(apiId) {
// Try to find the checkbox in the DOM first
const checkbox = document.querySelector('.api-checkbox[value="' + apiId + '"]');
let apiName = apiId; // Default to ID if we can't find the name
if (checkbox) {
// If checkbox exists in DOM, get the name from the card
const card = checkbox.closest('.api-selection-card');
if (card) {
const nameElement = card.querySelector('.api-name');
if (nameElement) {
apiName = nameElement.textContent;
}
}
} else {
// If checkbox not in DOM (different category is showing),
// try to find the API in our loaded data
let api = allApis.find(a => a.apiId === apiId);
if (!api && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 'api-pill';
apiPill.innerHTML = `
<span class="api-pill-name">${apiName}</span>
<button type="button" class="api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
updateSelectedCount();
} else {
// If checkbox not found (maybe different category is showing),
// still remove from selection
selectedApis.delete(value);
updateSelectedCount();
}
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.api-selection-card');
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
// Check if matches search
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Sidebar category selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
// Remove active from all
menuTitles.forEach(t => t.classList.remove('active'));
// Add active to clicked
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
// Store service name for select all label
currentServiceName = this.textContent.trim().split('\n')[0].trim();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
// Close mobile menu if open
if (window.innerWidth <= 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
});
// Mobile menu toggle
function closeMobileMenu() {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
mobileToggle.innerHTML = '☰';
mobileToggle.setAttribute('aria-label', '메뉴 열기');
}
if (mobileToggle && sidebar && mobileOverlay) {
mobileToggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sidebar.classList.toggle('mobile-open');
mobileOverlay.classList.toggle('active');
if (sidebar.classList.contains('mobile-open')) {
this.innerHTML = '✕';
this.setAttribute('aria-label', '메뉴 닫기');
} else {
this.innerHTML = '☰';
this.setAttribute('aria-label', '메뉴 열기');
}
});
// Close menu on overlay click
mobileOverlay.addEventListener('click', function() {
closeMobileMenu();
});
}
// Close mobile menu on resize to desktop
window.addEventListener('resize', function() {
if (window.innerWidth > 768 && sidebar.classList.contains('mobile-open')) {
closeMobileMenu();
}
});
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
// Open modal
function openModal() {
selectedApisModal.style.display = 'block';
// Add show class to backdrop and modal for visibility
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// Close modal
function closeModal() {
// Remove show class first for transition
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
// Hide modal after transition
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = ''; // Restore scroll
}
// Floating cart button click
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
// Modal overlay click
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
// Modal close button click
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
// Modal cancel button click
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.api-selection-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// Previous step button - save current selections before going back
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
// Change form action to save endpoint
const originalAction = form.action;
form.action = '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
// API 선택 필수 검증
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
});
// Initialize
// Load all APIs for modal display FIRST (async)
loadAllApisForModal();
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
// Log restored selections for debugging
if (selectedApis.size > 0) {
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
}
});
</script>
</th:block>
</body>
</html>