인증키 수정 화면 스타일 적용
This commit is contained in:
@@ -1,89 +1,120 @@
|
||||
<!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}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>앱관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<!-- Title Bar -->
|
||||
<div class="register-title-bar">
|
||||
<h2 class="register-title">앱 수정</h2>
|
||||
<span class="register-subtitle">서비스 중인 앱 정보를 수정합니다.</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step active">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-label">기본 정보</div>
|
||||
<!-- Step 1: 앱 정보수정 (Active) -->
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보수정" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">앱 정보수정</div>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="step-number">2</div>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: API 선택 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
<div class="progress-line"></div>
|
||||
<div class="progress-step">
|
||||
<div class="step-number">3</div>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 완료 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="register-header">
|
||||
<h1>API Key 수정</h1>
|
||||
<h2>Step 1: 기본 정보 수정</h2>
|
||||
<p class="header-description">
|
||||
앱의 기본 정보를 수정해주세요. 변경 사항은 승인 후 적용됩니다.
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Form Content -->
|
||||
<div class="register-content">
|
||||
<div class="register-form-container">
|
||||
<form id="modifyStep1Form" method="post" th:action="@{/myapikey/modify/step1}" th:object="${apiKeyModification}" class="register-form" enctype="multipart/form-data">
|
||||
|
||||
<!-- Hidden field for clientId -->
|
||||
<input type="hidden" th:field="*{clientId}"/>
|
||||
|
||||
<div class="form-card">
|
||||
|
||||
<!-- App Icon -->
|
||||
<div class="form-group">
|
||||
<label for="appIcon" class="form-label required">
|
||||
앱 아이콘
|
||||
<span class="required-mark">*</span>
|
||||
</label>
|
||||
<div class="icon-upload-wrapper">
|
||||
<div class="icon-preview" id="iconPreview">
|
||||
<div class="icon-placeholder"
|
||||
th:if="${apiKeyModification.appIconData == null and apiKeyModification.appIconFileId == null}">
|
||||
<!-- App Icon -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">앱 아이콘</span>
|
||||
<span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<div class="icon-upload-box">
|
||||
<div class="icon-preview-area" id="iconPreview">
|
||||
<div class="icon-placeholder" id="iconPlaceholder"
|
||||
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: none;' : 'display: flex;'}">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
<span>이미지 선택</span>
|
||||
</div>
|
||||
<!-- Show image from file service if available -->
|
||||
<img id="iconPreviewImage"
|
||||
th:if="${apiKeyModification.appIconFileId != null}"
|
||||
th:src="@{/file/download(fileSn=1,fileId=${apiKeyModification.appIconFileId})}"
|
||||
alt="Icon Preview">
|
||||
<!-- Show image from data if available (newly uploaded) -->
|
||||
alt="Icon Preview"
|
||||
style="display: block;">
|
||||
<!-- Show image from data if available -->
|
||||
<img id="iconPreviewImage"
|
||||
th:if="${apiKeyModification.appIconData != null and apiKeyModification.appIconFileId == null}"
|
||||
th:src="'data:' + ${apiKeyModification.appIconContentType} + ';base64,' + ${T(java.util.Base64).getEncoder().encodeToString(apiKeyModification.appIconData)}"
|
||||
alt="Icon Preview">
|
||||
alt="Icon Preview"
|
||||
style="display: block;">
|
||||
<!-- JS Empty Image Target -->
|
||||
<img id="iconPreviewImageJS" style="display: none;" alt="Icon Preview">
|
||||
|
||||
<button type="button" class="btn-remove-icon" id="btnRemoveIcon"
|
||||
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: block;' : 'display: none;'}"
|
||||
th:style="${(apiKeyModification.appIconData != null or apiKeyModification.appIconFileId != null) ? 'display: flex;' : 'display: none;'}"
|
||||
onclick="removeAppIcon()" title="아이콘 삭제">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
@@ -91,82 +122,97 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-upload-info">
|
||||
<p class="upload-title">앱 아이콘 이미지 파일을 업로드하세요</p>
|
||||
<p class="upload-hint">권장사이즈는 <strong>128X128픽셀</strong>이며 <strong>JPG, PNG, GIF</strong>파일만 등록할 수 있습니다.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-upload" onclick="document.getElementById('appIcon').click()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
이미지 파일 업로드
|
||||
</button>
|
||||
<input type="file"
|
||||
id="appIcon"
|
||||
name="appIcon"
|
||||
class="icon-input"
|
||||
accept="image/png,image/jpeg,image/jpg">
|
||||
<button type="button" class="btn-icon-select" onclick="document.getElementById('appIcon').click()">
|
||||
파일 선택
|
||||
</button>
|
||||
accept="image/png,image/jpeg,image/jpg,image/gif"
|
||||
style="display: none;">
|
||||
</div>
|
||||
<small class="form-hint">PNG, JPG 형식 (최대 2MB, 권장 크기: 512x512px)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Name -->
|
||||
<div class="form-group">
|
||||
<label for="appName" class="form-label required">
|
||||
앱 이름
|
||||
<span class="required-mark">*</span>
|
||||
</label>
|
||||
<!-- App Name -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">앱 이름</span>
|
||||
<span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text"
|
||||
id="appName"
|
||||
th:field="*{appName}"
|
||||
class="form-control"
|
||||
placeholder="예: My Application"
|
||||
maxlength="50"
|
||||
required>
|
||||
<small class="form-hint">앱을 구별할 수 있는 고유한 이름을 입력해주세요. (최대 50자)</small>
|
||||
class="form-input"
|
||||
placeholder="앱 이름을 입력하세요."
|
||||
required
|
||||
maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Description -->
|
||||
<div class="form-group">
|
||||
<label for="appDescription" class="form-label">앱 설명</label>
|
||||
<!-- App Description -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper">
|
||||
<span class="form-label-text">앱 설명</span>
|
||||
<span class="required-badge">필수</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<textarea id="appDescription"
|
||||
th:field="*{appDescription}"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="앱의 주요 기능과 용도를 간단히 설명해주세요."
|
||||
class="form-textarea"
|
||||
placeholder="앱 설명을 입력하세요."
|
||||
rows="5"
|
||||
required
|
||||
maxlength="500"></textarea>
|
||||
<small class="form-hint">앱의 용도와 주요 기능을 설명해주세요. (최대 500자)</small>
|
||||
<small class="char-counter-wrapper">
|
||||
<span class="char-counter">0 / 500</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Callback URL -->
|
||||
<div class="form-group">
|
||||
<label for="callbackUrl" class="form-label">Callback URL</label>
|
||||
<input type="url"
|
||||
<!-- Callback URL -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">Call Back URL</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<input type="text"
|
||||
id="callbackUrl"
|
||||
th:field="*{callbackUrl}"
|
||||
class="form-control"
|
||||
placeholder="https://example.com/callback">
|
||||
<small class="form-hint">OAuth 2.0 인증 시 사용할 콜백 URL을 입력해주세요.</small>
|
||||
class="form-input"
|
||||
placeholder="URL을 입력해 주세요.">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Whitelist -->
|
||||
<div class="form-group">
|
||||
<label for="ipWhitelist" class="form-label">
|
||||
IP 화이트리스트
|
||||
</label>
|
||||
<div class="ip-input-group">
|
||||
<!-- IP Whitelist -->
|
||||
<div class="form-row">
|
||||
<div class="form-label-wrapper label-offset">
|
||||
<span class="form-label-text">화이트리스트</span>
|
||||
</div>
|
||||
<div class="form-field-wrapper">
|
||||
<div class="ip-input-row">
|
||||
<input type="text"
|
||||
id="ipWhitelistInput"
|
||||
class="form-input"
|
||||
placeholder="예: 192.168.1.1 또는 10.0.0.0/24">
|
||||
class="form-input ip-input"
|
||||
placeholder="서버 IP (111.111.111.111)">
|
||||
<button type="button" class="btn-add-ip" onclick="addIpAddress()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
추가
|
||||
+ 추가
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-hint">허용할 IP 주소 또는 CIDR 대역을 입력하세요. (선택사항)</small>
|
||||
|
||||
<!-- IP List Display -->
|
||||
<div class="ip-list" id="ipList" style="display: none;">
|
||||
<div class="ip-list-header">
|
||||
<span class="ip-count">등록된 IP: <strong id="ipCount">0</strong>개</span>
|
||||
</div>
|
||||
<div class="ip-items" id="ipItems">
|
||||
<!-- Dynamically added IP items -->
|
||||
</div>
|
||||
@@ -175,47 +221,46 @@
|
||||
<!-- Hidden input to store IP list -->
|
||||
<input type="hidden" id="ipWhitelist" name="ipWhitelist" value="">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-cancel">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
다음 단계
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<a th:href="@{/myapikey/modify/cancel(clientId=${apiKeyModification.clientId})}" class="btn btn-submit btn-secondary">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit" form="modifyStep1Form" class="btn-primary btn-submit">
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
// IP address list management
|
||||
let ipAddresses = [];
|
||||
|
||||
// Remove app icon
|
||||
function removeAppIcon() {
|
||||
const appIconInput = document.getElementById('appIcon');
|
||||
const iconPreviewImage = document.getElementById('iconPreviewImage');
|
||||
const iconPlaceholder = document.querySelector('.icon-placeholder');
|
||||
const iconPreviewImages = document.querySelectorAll('img[id^="iconPreviewImage"]'); // Select all preview images
|
||||
const iconPlaceholder = document.getElementById('iconPlaceholder');
|
||||
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
|
||||
|
||||
// Clear file input
|
||||
appIconInput.value = '';
|
||||
|
||||
// Hide preview image and show placeholder
|
||||
iconPreviewImage.style.display = 'none';
|
||||
iconPreviewImage.src = '';
|
||||
// Hide all preview images
|
||||
iconPreviewImages.forEach(img => {
|
||||
img.style.display = 'none';
|
||||
if (img.id === 'iconPreviewImageJS') img.src = '';
|
||||
});
|
||||
|
||||
// Show placeholder
|
||||
iconPlaceholder.style.display = 'flex';
|
||||
|
||||
// Hide delete button
|
||||
@@ -229,20 +274,20 @@
|
||||
|
||||
// Validate IP format
|
||||
if (!ipValue) {
|
||||
alert('IP 주소를 입력해주세요.');
|
||||
customPopups.showAlert('IP 주소를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic IP validation (supports IP and CIDR notation)
|
||||
const ipPattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
|
||||
if (!ipPattern.test(ipValue)) {
|
||||
alert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
|
||||
customPopups.showAlert('올바른 IP 주소 형식이 아닙니다.\n예: 192.168.1.1 또는 10.0.0.0/24');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (ipAddresses.includes(ipValue)) {
|
||||
alert('이미 추가된 IP 주소입니다.');
|
||||
customPopups.showAlert('이미 추가된 IP 주소입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,12 +315,8 @@
|
||||
function updateIpList() {
|
||||
const ipList = document.getElementById('ipList');
|
||||
const ipItems = document.getElementById('ipItems');
|
||||
const ipCount = document.getElementById('ipCount');
|
||||
const ipWhitelistHidden = document.getElementById('ipWhitelist');
|
||||
|
||||
// Update count
|
||||
ipCount.textContent = ipAddresses.length;
|
||||
|
||||
// Show/hide list container
|
||||
if (ipAddresses.length > 0) {
|
||||
ipList.style.display = 'block';
|
||||
@@ -285,12 +326,9 @@
|
||||
ipAddresses.forEach(function(ip) {
|
||||
itemsHTML += `
|
||||
<div class="ip-item">
|
||||
<span class="ip-address">${ip}</span>
|
||||
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')" title="제거">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<div class="ip-display">${ip}</div>
|
||||
<button type="button" class="btn-remove-ip" onclick="removeIpAddress('${ip}')">
|
||||
- 제거
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -306,19 +344,17 @@
|
||||
ipWhitelistHidden.value = ipAddresses.join(',');
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize with existing icon data
|
||||
var hasExistingIcon = /*[[${apiKeyModification.appIconData != null}]]*/ false;
|
||||
var hasExistingIconFile = /*[[${apiKeyModification.appIconFileId != null}]]*/ false;
|
||||
|
||||
// If we have existing icon data, it's already shown by Thymeleaf
|
||||
// But we need to ensure the UI state is correct
|
||||
if (hasExistingIcon || hasExistingIconFile) {
|
||||
$('.icon-placeholder').hide();
|
||||
$('#btnRemoveIcon').show();
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('modifyStep1Form');
|
||||
const appDescTextarea = document.getElementById('appDescription');
|
||||
const charCounter = document.querySelector('.char-counter');
|
||||
const appIconInput = document.getElementById('appIcon');
|
||||
const iconPreviewImageJS = document.getElementById('iconPreviewImageJS');
|
||||
const iconPlaceholder = document.getElementById('iconPlaceholder');
|
||||
const ipWhitelistInput = document.getElementById('ipWhitelistInput');
|
||||
|
||||
// Restore existing IP whitelist
|
||||
/*<![CDATA[*/
|
||||
const sessionIpWhitelist = /*[[${apiKeyModification.ipWhitelistAsString}]]*/ '';
|
||||
if (sessionIpWhitelist && sessionIpWhitelist.trim() !== '') {
|
||||
const ips = sessionIpWhitelist.split(',');
|
||||
@@ -330,46 +366,110 @@
|
||||
});
|
||||
updateIpList();
|
||||
}
|
||||
/*]]>*/
|
||||
|
||||
// File upload preview
|
||||
$('#appIcon').on('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
|
||||
const iconPreviewImage = document.getElementById('iconPreviewImage');
|
||||
const iconPlaceholder = document.querySelector('.icon-placeholder');
|
||||
// Initialize character counter
|
||||
if (appDescTextarea && charCounter) {
|
||||
const currentLength = appDescTextarea.value.length;
|
||||
const maxLength = appDescTextarea.getAttribute('maxlength');
|
||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||
|
||||
if (file) {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('파일 크기는 2MB를 초과할 수 없습니다.');
|
||||
$(this).val('');
|
||||
return;
|
||||
appDescTextarea.addEventListener('input', function() {
|
||||
const currentLength = this.value.length;
|
||||
const maxLength = this.getAttribute('maxlength');
|
||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||
|
||||
if (currentLength > maxLength * 0.9) {
|
||||
charCounter.style.color = '#FF6B6B';
|
||||
} else {
|
||||
charCounter.style.color = '#94A3B8';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
iconPreviewImage.src = e.target.result;
|
||||
iconPreviewImage.style.display = 'block';
|
||||
iconPlaceholder.style.display = 'none';
|
||||
btnRemoveIcon.style.display = 'flex';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// App icon preview
|
||||
if (appIconInput) {
|
||||
appIconInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
const btnRemoveIcon = document.getElementById('btnRemoveIcon');
|
||||
|
||||
if (file) {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
customPopups.showAlert('파일 크기는 2MB를 초과할 수 없습니다.');
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
customPopups.showAlert('PNG, JPG, GIF 형식만 업로드 가능합니다.');
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
// Hide other previews
|
||||
document.querySelectorAll('img[id^="iconPreviewImage"]').forEach(img => img.style.display = 'none');
|
||||
|
||||
iconPreviewImageJS.src = event.target.result;
|
||||
iconPreviewImageJS.style.display = 'block';
|
||||
iconPlaceholder.style.display = 'none';
|
||||
btnRemoveIcon.style.display = 'flex';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// IP input Enter key
|
||||
if (ipWhitelistInput) {
|
||||
ipWhitelistInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addIpAddress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
const appName = document.getElementById('appName').value.trim();
|
||||
const appDescription = document.getElementById('appDescription').value.trim();
|
||||
const callbackUrl = document.getElementById('callbackUrl').value.trim();
|
||||
|
||||
if (document.getElementById('iconPlaceholder').style.display !== 'none') {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 아이콘을 등록해주세요.');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// IP whitelist input - Enter key handler
|
||||
$('#ipWhitelistInput').on('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (!appName) {
|
||||
e.preventDefault();
|
||||
addIpAddress();
|
||||
customPopups.showAlert('앱 이름을 입력해주세요.');
|
||||
document.getElementById('appName').focus();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission - ensure IP list is updated
|
||||
$('#modifyStep1Form').on('submit', function(e) {
|
||||
// IP whitelist is already updated via updateIpList()
|
||||
if (!appDescription) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('앱 설명을 입력해주세요.');
|
||||
document.getElementById('appDescription').focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (callbackUrl && callbackUrl.trim() !== '') {
|
||||
try {
|
||||
new URL(callbackUrl);
|
||||
} catch (error) {
|
||||
e.preventDefault();
|
||||
customPopups.showAlert('올바른 URL 형식이 아닙니다.');
|
||||
document.getElementById('callbackUrl').focus();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
/*]]>*/
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
|
||||
@@ -1,41 +1,71 @@
|
||||
<!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}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>앱관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<!-- 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>
|
||||
<!-- Title Bar -->
|
||||
<div class="register-title-bar">
|
||||
<h2 class="register-title">앱 수정</h2>
|
||||
<span class="register-subtitle">API를 이용하여 서비스할 앱 정보를 수정해주세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<!-- Step 1: 앱 정보입력 (Completed) -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">앱 정보입력</div>
|
||||
</div>
|
||||
|
||||
<!-- Dots between Step 1 and 2 -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: API 선택 (Active) -->
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
|
||||
<!-- Dots between Step 2 and 3 -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 앱 수정 완료 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">앱 수정 완료</div>
|
||||
</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;">
|
||||
<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>
|
||||
@@ -43,120 +73,116 @@
|
||||
</svg>
|
||||
<span th:text="${error}"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Container with Sidebar Layout -->
|
||||
<section class="apikey-register-container with-sidebar">
|
||||
<!-- 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">
|
||||
<!-- All APIs -->
|
||||
<div class="menu-section">
|
||||
<div class="menu-title active" data-group="">
|
||||
전체
|
||||
<span class="api-count" id="totalApiCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="apikey-register-sidebar" id="apiSidebar">
|
||||
<nav class="apikey-sidebar-nav">
|
||||
<!-- Sidebar Title -->
|
||||
<div class="sidebar-title">API 서비스 목록</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 will be dynamically loaded here -->
|
||||
<!-- 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>
|
||||
|
||||
</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>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<!-- Save and go back button -->
|
||||
<button type="submit" formaction="/myapikey/modify/step2/save" class="btn btn-secondary" id="btnPrevStep">
|
||||
<span class="icon">←</span>
|
||||
이전 단계
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnNext">
|
||||
저장
|
||||
<span class="icon">→</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- API cards will be dynamically loaded here -->
|
||||
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Floating Cart Button -->
|
||||
<!-- Floating Cart Button (Figma Design) -->
|
||||
<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>
|
||||
<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 -->
|
||||
<div class="selected-apis-modal" id="selectedApisModal" style="display: none;">
|
||||
<!-- 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">
|
||||
@@ -190,11 +216,9 @@
|
||||
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
|
||||
@@ -238,80 +262,24 @@
|
||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(apis => {
|
||||
allApis = apis;
|
||||
loadingState.style.display = 'none';
|
||||
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 = '다시 시도해주세요.';
|
||||
if (apis.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
@@ -336,8 +304,7 @@
|
||||
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-group', api.apiGroupId || '');
|
||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||
card.setAttribute('data-api-id', api.apiId);
|
||||
@@ -347,29 +314,32 @@
|
||||
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>
|
||||
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>`;
|
||||
|
||||
<label class="api-card-content" for="api-${api.apiId}">
|
||||
card.innerHTML = `
|
||||
<div class="api-card-content">
|
||||
<div class="api-card-header">
|
||||
<span class="api-card-badge">${api.service || '카테고리'}</span>
|
||||
<span class="api-method">${api.apiMethod || 'GET'}</span>
|
||||
<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-endpoint">
|
||||
<code>${api.apiUrl || '/api/v1/example'}</code>
|
||||
<div class="api-card-icon">
|
||||
${mainIconHtml}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
@@ -384,13 +354,13 @@
|
||||
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') {
|
||||
// Prevent double toggle if clicking directly on checkbox
|
||||
if (e.target.classList.contains('api-checkbox')) {
|
||||
return;
|
||||
}
|
||||
const checkbox = card.querySelector('.api-checkbox');
|
||||
@@ -398,7 +368,6 @@
|
||||
checkbox.checked = !checkbox.checked;
|
||||
updateCardSelection(checkbox);
|
||||
updateSelectedCount();
|
||||
updateSelectAllCheckboxState();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -420,17 +389,63 @@
|
||||
function updateSelectedCount() {
|
||||
// Update floating cart button
|
||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||
const cartBadge = document.getElementById('cartBadge');
|
||||
const cartCount = document.querySelector('.cart-count');
|
||||
|
||||
if (selectedApis.size > 0) {
|
||||
floatingCartBtn.style.display = 'flex';
|
||||
cartBadge.textContent = selectedApis.size;
|
||||
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
|
||||
@@ -444,8 +459,6 @@
|
||||
}
|
||||
|
||||
// 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 + '"]');
|
||||
@@ -483,12 +496,11 @@
|
||||
</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) {
|
||||
// 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 + '"]');
|
||||
@@ -510,7 +522,6 @@
|
||||
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) {
|
||||
@@ -522,13 +533,12 @@
|
||||
|
||||
if (matchesSearch) {
|
||||
card.style.display = '';
|
||||
visibleCountNum++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
visibleCount.textContent = visibleCountNum;
|
||||
// Update select all checkbox state after search
|
||||
updateSelectAllCheckboxState();
|
||||
});
|
||||
}
|
||||
@@ -546,7 +556,7 @@
|
||||
const groupId = this.getAttribute('data-group');
|
||||
currentFilter = groupId;
|
||||
|
||||
// Store current service name (extract text before the count)
|
||||
// Store service name for select all label
|
||||
currentServiceName = this.textContent.trim().split('\n')[0].trim();
|
||||
|
||||
// Load APIs for selected service
|
||||
@@ -665,13 +675,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Select all checkbox event handler
|
||||
// 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');
|
||||
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');
|
||||
@@ -685,6 +694,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Allow submission even without selected APIs (user might want to remove all)
|
||||
@@ -701,11 +725,10 @@
|
||||
// Load all APIs for modal display FIRST (async)
|
||||
loadAllApisForModal();
|
||||
|
||||
// Load all APIs automatically on page load
|
||||
loadApis('');
|
||||
updateSelectedCount(); // This will show count from restored session data
|
||||
|
||||
// Update count after APIs are loaded (this will show count from restored session data)
|
||||
updateSelectedCount();
|
||||
// Load all APIs automatically on page load (empty string = all APIs)
|
||||
loadApis('');
|
||||
|
||||
// Log restored selections for debugging
|
||||
if (selectedApis.size > 0) {
|
||||
|
||||
@@ -1,86 +1,116 @@
|
||||
<!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}">
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/kjbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||
<h1>앱관리</h1>
|
||||
</div>
|
||||
</section>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="apikey-register-container">
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<!-- Title Bar -->
|
||||
<div class="register-title-bar">
|
||||
<h2 class="register-title">앱 수정</h2>
|
||||
<span class="register-subtitle">API를 이용하여 서비스할 앱 정보를 수정해주세요.</span>
|
||||
</div>
|
||||
|
||||
<div class="register-progress">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step completed">
|
||||
<div class="step-number">✓</div>
|
||||
<div class="step-label">기본 정보</div>
|
||||
<!-- Step 1: 앱 정보입력 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보입력" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">앱 정보입력</div>
|
||||
</div>
|
||||
<div class="progress-line filled"></div>
|
||||
<div class="progress-step completed">
|
||||
<div class="step-number">✓</div>
|
||||
|
||||
<!-- Dots between Step 1 and 2 -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: API 선택 -->
|
||||
<div class="progress-step-item">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">API 선택</div>
|
||||
</div>
|
||||
<div class="progress-line filled"></div>
|
||||
<div class="progress-step active completed">
|
||||
<div class="step-number">✓</div>
|
||||
<div class="step-label">완료</div>
|
||||
|
||||
<!-- Dots between Step 2 and 3 -->
|
||||
<div class="step-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 앱 수정 완료 (Active) -->
|
||||
<div class="progress-step-item active">
|
||||
<div class="step-icon-wrapper">
|
||||
<div class="step-icon">
|
||||
<img th:src="@{/img/apikey_step3.png}" alt="앱 수정 완료" width="36" height="36">
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">앱 수정 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Result -->
|
||||
<div class="register-result success" th:if="${modificationComplete}">
|
||||
|
||||
<!-- Success Icon Animation -->
|
||||
<div class="result-icon-wrapper">
|
||||
<div class="result-icon success-icon">
|
||||
<svg class="checkmark" viewBox="0 0 52 52">
|
||||
<circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none"/>
|
||||
<path class="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
<img th:src="@{/img/img_app_complete.png}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div class="result-header">
|
||||
<h1>API Key 수정 요청 완료!</h1>
|
||||
<h1>앱 수정 요청이 완료되었습니다.</h1>
|
||||
<p class="result-description">
|
||||
API Key 수정 요청이 성공적으로 접수되었습니다. 관리자 승인 후 변경 사항이 적용됩니다.
|
||||
담당자 승인 후 변경 사항이 적용됩니다. [앱 정보]화면에서 수정 내역을 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Result Content -->
|
||||
<!-- API Key Display -->
|
||||
<div class="result-content">
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="result-actions">
|
||||
<a th:href="@{/myapikey}" class="btn btn-secondary">
|
||||
<span class="icon">📋</span>
|
||||
API Key 목록으로
|
||||
</a>
|
||||
<a th:href="@{/myapikey/api_key_request/history}" class="btn btn-primary">
|
||||
<span class="icon">📄</span>
|
||||
요청 내역 확인
|
||||
<a href="/myapikey" class="btn btn-primary">
|
||||
인증키 관리
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Result (if needed) -->
|
||||
<div class="register-result error" th:unless="${modificationComplete}">
|
||||
<div class="register-result error" th:unless="${modificationComplete}" style="display: none;">
|
||||
<div class="result-icon-wrapper">
|
||||
<div class="result-icon error-icon">
|
||||
<span>❌</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-header">
|
||||
<h1>잘못된 접근입니다</h1>
|
||||
<h1>앱 수정 실패</h1>
|
||||
<p class="result-description">
|
||||
수정 프로세스를 완료하지 않았거나 잘못된 경로로 접근하셨습니다.
|
||||
앱 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<a th:href="@{/myapikey}" class="btn btn-primary">
|
||||
목록으로 돌아가기
|
||||
<a href="/myapikey" class="btn btn-secondary">
|
||||
다시 시도
|
||||
</a>
|
||||
<a href="/myapikey" class="btn btn-primary">
|
||||
목록으로
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,64 +119,6 @@
|
||||
</th:block>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Success animation
|
||||
setTimeout(function() {
|
||||
const successIcon = document.querySelector('.success-icon');
|
||||
if (successIcon) {
|
||||
successIcon.classList.add('animate');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Success checkmark animation */
|
||||
.checkmark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
stroke-width: 2;
|
||||
stroke: #6BCF7F;
|
||||
stroke-miterlimit: 10;
|
||||
animation: rotate 1s ease-in-out;
|
||||
}
|
||||
|
||||
.checkmark-circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
stroke-width: 2;
|
||||
stroke-miterlimit: 10;
|
||||
stroke: #6BCF7F;
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
|
||||
.checkmark-check {
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</th:block>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -122,86 +122,6 @@
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Load data from sessionStorage
|
||||
const appName = sessionStorage.getItem('registration_appName');
|
||||
const selectedApis = JSON.parse(sessionStorage.getItem('registration_selectedApis') || '[]');
|
||||
|
||||
// Update display with actual data
|
||||
if (appName) {
|
||||
document.getElementById('resultAppName').textContent = appName;
|
||||
}
|
||||
|
||||
// Copy to clipboard functionality
|
||||
document.querySelectorAll('.btn-copy').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-copy');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
const textToCopy = targetElement.textContent;
|
||||
|
||||
// Create temporary input
|
||||
const tempInput = document.createElement('input');
|
||||
tempInput.value = textToCopy;
|
||||
document.body.appendChild(tempInput);
|
||||
tempInput.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
|
||||
// Visual feedback
|
||||
const originalText = this.innerHTML;
|
||||
this.innerHTML = '<span class="copy-icon">✓</span> 복사됨';
|
||||
this.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
this.innerHTML = originalText;
|
||||
this.classList.remove('copied');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
alert('복사에 실패했습니다. 수동으로 복사해주세요.');
|
||||
}
|
||||
|
||||
document.body.removeChild(tempInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle visibility for secret key
|
||||
document.querySelectorAll('.btn-toggle-visibility').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement.classList.contains('blurred')) {
|
||||
targetElement.classList.remove('blurred');
|
||||
this.innerHTML = '<span class="visibility-icon">🙈</span>';
|
||||
} else {
|
||||
targetElement.classList.add('blurred');
|
||||
this.innerHTML = '<span class="visibility-icon">👁️</span>';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download key information
|
||||
document.getElementById('downloadKeyBtn')?.addEventListener('click', function() {
|
||||
const keyInfo = {
|
||||
appName: document.getElementById('resultAppName').textContent,
|
||||
clientId: document.getElementById('clientId').textContent,
|
||||
clientSecret: document.getElementById('clientSecret').textContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
selectedApis: selectedApis
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(keyInfo, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = 'apikey_' + keyInfo.appName.replace(/\s+/g, '_') + '.json';
|
||||
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
});
|
||||
|
||||
// Clear session storage after successful registration
|
||||
if (/*[[${registrationComplete}]]*/ false) {
|
||||
sessionStorage.removeItem('registration_appName');
|
||||
@@ -210,73 +130,8 @@
|
||||
sessionStorage.removeItem('registration_environment');
|
||||
sessionStorage.removeItem('registration_selectedApis');
|
||||
}
|
||||
|
||||
// Success animation
|
||||
setTimeout(function() {
|
||||
const successIcon = document.querySelector('.success-icon');
|
||||
if (successIcon) {
|
||||
successIcon.classList.add('animate');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Blur effect for secret key */
|
||||
.key-value.blurred {
|
||||
filter: blur(5px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Copy button feedback */
|
||||
.btn-copy.copied {
|
||||
background-color: #6BCF7F !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Success checkmark animation */
|
||||
.checkmark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
stroke-width: 2;
|
||||
stroke: #6BCF7F;
|
||||
stroke-miterlimit: 10;
|
||||
animation: rotate 1s ease-in-out;
|
||||
}
|
||||
|
||||
.checkmark-circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
stroke-width: 2;
|
||||
stroke-miterlimit: 10;
|
||||
stroke: #6BCF7F;
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
|
||||
.checkmark-check {
|
||||
transform-origin: 50% 50%;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user