- Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영

- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
This commit is contained in:
Rinjae
2026-07-20 19:53:54 +09:00
parent 9f4874fe44
commit 41391c8df0
21 changed files with 1107 additions and 1923 deletions
@@ -19,6 +19,7 @@
<!-- Content Area -->
<div class="app-management-content">
<!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
<div class="step2-wrap">
<!-- Title -->
@@ -27,7 +28,7 @@
<!-- Progress Steps Card -->
<div class="s1-progress-card">
<div class="s1-steps">
<!-- Step 1: 앱 정보입력 (completed) -->
<!-- Step 1: 앱 정보수정 (completed) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 입력폼 아이콘 -->
@@ -66,7 +67,7 @@
<div class="s1-step-line"></div>
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
<!-- Step 3: 앱 수정 완료 (rocket icon) -->
<div class="s1-step">
<div class="s1-step-circle">
<!-- 로켓 아이콘 -->
@@ -90,85 +91,19 @@
</div>
</div>
<!-- Category Carousel Tab Container -->
<div class="s2-category-carousel-container">
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="s2-category-list-wrapper" id="categoryListWrapper">
<div class="s2-category-list" id="categoryList">
<button type="button" class="s2-category-tab active" data-group="">
전체
</button>
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
서비스명
</button>
</div>
</div>
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<!-- API 선택 공용 모듈 -->
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
<!-- Main Content Area -->
<div class="s2-selection-container">
<main class="s2-content-area">
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="s2-form">
<!-- Hidden field for clientId -->
<input type="hidden" th:field="*{clientId}"/>
<!-- Header filter: Search and Select All -->
<div class="s2-filter-header">
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
<label class="s2-select-all-label">
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
<span class="s2-checkbox-custom"></span>
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="s2-result-count">
<strong id="apiResultCount">0</strong>
</div>
<div class="s2-search-box">
<input type="text" id="apiSearch" class="s2-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="s2-cards-grid" id="apiCardGrid">
<!-- Loading state -->
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
<div class="s2-spinner"></div>
<p>API 목록을 불러오는 중...</p>
</div>
<!-- Empty state -->
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
<div class="s2-empty-icon">📦</div>
<h3>API를 선택해주세요</h3>
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
</div>
</div>
</form>
</main>
</div>
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
<!-- Bottom Navigation Actions -->
<div class="s2-actions">
<button type="button" id="btnPrevStep" class="s2-btn-prev">
이전
</button>
<button type="submit" form="modifyStep2Form" class="s2-btn-save">
다음
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
저장
</button>
</div>
@@ -180,548 +115,7 @@
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
<section layout:fragment="pagePopups">
<!-- Floating Cart Button -->
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
<span class="s2-cart-label">선택된 API</span>
<span class="s2-cart-badge" id="cartBadge">
<span class="s2-cart-count">0</span>
<span class="s2-cart-unit"></span>
</span>
</button>
<!-- Selected APIs Modal -->
<div class="s2-modal" id="selectedApisModal" style="display: none;">
<div class="s2-modal-backdrop" id="modalOverlay"></div>
<div class="s2-modal-dialog">
<div class="s2-modal-header">
<h3 class="s2-modal-title">선택된 API 목록</h3>
<button type="button" class="s2-modal-close" id="modalCloseBtn"></button>
</div>
<div class="s2-modal-body">
<div class="s2-selected-list" id="modalSelectedList">
<!-- Dynamically populated -->
</div>
</div>
<div class="s2-modal-footer">
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
</div>
</div>
</div>
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
</section>
<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 menuTitles = document.querySelectorAll('.s2-category-tab');
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
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() {
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
fetch(baseUrl)
.then(response => response.json())
.then(apis => {
allLoadedApis = apis;
})
.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('.s2-api-card').forEach(card => card.remove());
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
let url = baseUrl;
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';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
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';
document.getElementById('apiResultCount').textContent = '0';
});
}
// 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 = 's2-api-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="s2-api-card-badge">
<span>${api.apiGroupName || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
// Listen to checkbox change event inside it
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-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('.s2-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('.s2-api-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('.s2-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="s2-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('.s2-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('.s2-api-card');
if (card) {
const nameElement = card.querySelector('.s2-api-card-title');
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 && allLoadedApis.length > 0) {
api = allLoadedApis.find(a => a.apiId === apiId);
}
if (api && api.apiName) {
apiName = api.apiName;
}
}
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
const checkbox = document.querySelector('.s2-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('.s2-api-card');
let visibleCount = 0;
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 = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
// Update select all checkbox state after search
updateSelectAllCheckboxState();
});
}
// Category tab 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();
// Load APIs for selected service
loadApis(groupId);
// Clear search when changing category
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// 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 = 'flex'; // .s2-modal은 flex 중앙정렬 → 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 === 'flex') {
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('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-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 (Thymeleaf contextPath safe)
form.action = /*[[@{/myapikey/modify/step2/save}]]*/ '/myapikey/modify/step2/save';
// Submit the form to save selections
form.submit();
});
}
// Form validation
form.addEventListener('submit', function(e) {
if (selectedApis.size === 0) {
e.preventDefault();
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
return false;
}
});
// Initialize
loadAllApisForModal();
updateSelectedCount(); // This will show count from restored session data
// Load all APIs automatically on page load (empty string = all APIs)
loadApis('');
});
</script>
</th:block>
</body>
</html>