앱 관리

This commit is contained in:
현성필
2025-11-01 11:49:11 +09:00
parent 6841c9236d
commit 563a3f054c
46 changed files with 9740 additions and 2062 deletions
@@ -0,0 +1,718 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_base_layout}">
<body>
<th:block layout:fragment="contentFragment">
<!-- Progress Indicator -->
<div class="register-progress">
<div class="progress-steps">
<div class="progress-step completed">
<div class="step-number"></div>
<div class="step-label">기본 정보</div>
</div>
<div class="progress-line filled"></div>
<div class="progress-step active">
<div class="step-number">2</div>
<div class="step-label">API 선택</div>
</div>
<div class="progress-line"></div>
<div class="progress-step">
<div class="step-number">3</div>
<div class="step-label">완료</div>
</div>
</div>
</div>
<!-- Header -->
<div class="register-header">
<h1>API Key 수정</h1>
<h2>Step 2: API 선택</h2>
<p class="header-description">
사용할 API를 선택해주세요. 여러 개의 API를 선택할 수 있습니다.
</p>
<!-- Error Message -->
<div th:if="${error}" class="alert alert-error" style="margin-top: 20px;">
<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>
</div>
<!-- Main Container with Sidebar Layout -->
<section class="apikey-register-container with-sidebar">
<!-- Sidebar Navigation -->
<aside class="apikey-register-sidebar" id="apiSidebar">
<nav class="apikey-sidebar-nav">
<!-- All APIs -->
<div class="menu-section">
<div class="menu-title active" data-group="">
전체
<span class="api-count" id="totalApiCount">0</span>
</div>
</div>
<!-- Service Categories -->
<div class="menu-section" th:each="service : ${apiServices}">
<div class="menu-title"
th:data-group="${service.id}">
[[${service.groupName}]]
<span class="api-count" th:text="${service.apiGroupApiList.size()}">0</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">
<span id="selectAllText">전체 선택</span>
</label>
</div>
<div class="api-search-box">
<input type="text"
id="apiSearch"
class="search-input"
placeholder="API 검색...">
<span class="search-icon">🔍</span>
</div>
</div>
<!-- Result Count -->
<div class="api-result-count">
<strong id="visibleCount">0</strong>
</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 Actions -->
<div class="form-actions">
<!-- Save and go back button -->
<button type="submit" formaction="/myapikey/modify/step2/save" class="btn-secondary" id="btnPrevStep">
<span class="icon"></span>
이전 단계
</button>
<button type="submit" class="btn-primary" id="btnNext">
저장
<span class="icon"></span>
</button>
</div>
</form>
</main>
</section>
<!-- Floating Cart Button -->
<button type="button" class="floating-cart-btn" id="floatingCartBtn" style="display: none;">
<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">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
<span class="cart-text">선택된 API:</span>
<span class="cart-badge" id="cartBadge">0</span>
</button>
<!-- Selected APIs Modal -->
<div class="selected-apis-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 visibleCount = document.getElementById('visibleCount');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const totalApiCount = document.getElementById('totalApiCount');
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';
visibleCount.textContent = '0';
return;
}
renderApiCards(apis);
visibleCount.textContent = apis.length;
// Update total count
if (!groupId || groupId === '') {
totalApiCount.textContent = apis.length;
}
// Update select all UI
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';
});
}
// Update select all UI visibility and label
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 (checked/indeterminate/unchecked)
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;
}
}
// 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';
// Note: AJAX response doesn't have apiGroupId, use apiGroupName or service for grouping
card.setAttribute('data-group', api.apiGroupName || '');
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');
}
card.innerHTML = `
<div class="checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="api-checkbox"
${isSelected ? 'checked' : ''}>
</div>
<label class="api-card-content" for="api-${api.apiId}">
<div class="api-card-header">
<span class="api-card-badge">${api.service || '카테고리'}</span>
<span class="api-method">${api.apiMethod || 'GET'}</span>
</div>
<h3 class="api-name">${api.apiName || 'API 이름'}</h3>
<p class="api-description">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="api-endpoint">
<code>${api.apiUrl || '/api/v1/example'}</code>
</div>
</label>
`;
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();
updateSelectAllCheckboxState();
});
});
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
if (e.target.classList.contains('api-checkbox') || e.target.tagName === 'LABEL') {
return;
}
const checkbox = card.querySelector('.api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
updateSelectAllCheckboxState();
}
});
});
}
// 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 cartBadge = document.getElementById('cartBadge');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartBadge.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
// Update modal list
updateModalList();
}
// 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
// This ensures we show all selected APIs regardless of current filter/view
console.log('updateModalList: Building modal for', selectedApis.size, 'selected APIs');
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>
`;
console.log('Adding pill for:', apiId, '→', apiName);
modalSelectedList.appendChild(apiPill);
});
// Add remove handlers to newly created elements
modalSelectedList.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();
let visibleCountNum = 0;
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 = '';
visibleCountNum++;
} else {
card.style.display = 'none';
}
});
visibleCount.textContent = visibleCountNum;
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 current service name (extract text before the count)
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 handler
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();
});
}
// Form validation
form.addEventListener('submit', function(e) {
// Allow submission even without selected APIs (user might want to remove all)
// But show confirmation
if (selectedApis.size === 0) {
if (!confirm('API를 선택하지 않으셨습니다. 계속하시겠습니까?')) {
e.preventDefault();
return false;
}
}
});
// Initialize
// Load all APIs for modal display FIRST (async)
loadAllApisForModal();
// Load all APIs automatically on page load
loadApis('');
// Update count after APIs are loaded (this will show count from restored session data)
updateSelectedCount();
// 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>