diff --git a/.github/workflows/deploy-dev.yml.disabled b/.github/workflows/deploy-dev.yml.disabled deleted file mode 100644 index a8a8ecb8..00000000 --- a/.github/workflows/deploy-dev.yml.disabled +++ /dev/null @@ -1,64 +0,0 @@ -name: Java CI/CD with Gradle (Dev Server) - -on: - push: - branches: [ "dev" ] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test - - # // 1. JAR 파일만 전송 준비 (환경변수는 서버에서 직접 생성하는게 더 깔끔해) - - name: Prepare deployment files - run: | - mkdir -p deploy - cp build/libs/*-SNAPSHOT.jar deploy/ - - # // 2. JAR 파일 EC2로 전송 - - name: Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "deploy/*" - target: "~/dev-server" - strip_components: 1 - - # // 3. EC2 서버에서 실행 스크립트 - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - # 1. 기존 8081 프로세스 종료 - fuser -k 8081/tcp || true - - cd ~/dev-server - - # 2. .env 파일 생성 - cat <<'EOF' > .env - ${{ secrets.ENV_VARIABLES }} - EOF - - # 3. 환경 변수 로드 및 메모리 제한 걸어서 실행 - # // 1. set -a로 .env 로드, -Xmx256m으로 메모리 방어 - set -a; source .env; set +a - nohup java -Xmx256m -Dserver.port=8081 -jar *-SNAPSHOT.jar > dev-app.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy-main.yml.disabled b/.github/workflows/deploy-main.yml.disabled deleted file mode 100644 index ea37df10..00000000 --- a/.github/workflows/deploy-main.yml.disabled +++ /dev/null @@ -1,66 +0,0 @@ -name: Java CI/CD with Gradle (Main Server) - -on: - push: - branches: [ "main" ] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test - - # // 1. 전송용 폴더에 JAR 파일만 준비 (환경변수는 보안상 서버에서 직접 생성) - - name: Prepare deployment files - run: | - mkdir -p deploy - cp build/libs/*-SNAPSHOT.jar deploy/ - - # // 2. 메인 서버 폴더로 JAR 전송 - - name: Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "deploy/*" - target: "~/main-server" - strip_components: 1 - - # // 3. 운영 서버 실행 스크립트 - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - # 1. 기존 8080 프로세스 종료 - fuser -k 8080/tcp || true - - # 2. 운영 서버 폴더 이동 - cd ~/main-server - - # 3. .env 파일 생성 (운영 전용 Secrets 사용) - # // 1. EOF를 써서 특수문자 깨짐 없이 안전하게 저장 - cat <<'EOF' > .env - ${{ secrets.ENV_VARIABLES }} - EOF - - # 4. 환경 변수 로드 및 운영 서버 실행 - # // 2. 운영은 400MB 제한으로 안정성 확보 - set -a; source .env; set +a - nohup java -Xmx400m -Dserver.port=8080 -jar *-SNAPSHOT.jar > server.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1c10460..d4b0db9c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,9 +2,9 @@ name: Java CI for Picke on: push: - branches: [ "develop", "main" ] + branches: [ "dev", "main" ] pull_request: - branches: [ "develop", "main" ] + branches: [ "dev", "main" ] jobs: build: @@ -21,6 +21,9 @@ jobs: distribution: 'temurin' cache: gradle # 1. 빌드 속도를 획기적으로 줄이기 위해 그래들 캐시 적용 + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java index bfb1cd16..c434a9c2 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -1,10 +1,12 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; import com.swyp.picke.domain.admin.service.AdminNotificationService; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -27,6 +29,7 @@ public class AdminNotificationController { private final AdminNotificationService adminNotificationService; + private final NotificationDispatchService notificationDispatchService; @Operation(summary = "공지사항 작성") @PostMapping @@ -51,4 +54,13 @@ public ApiResponse getNotices( public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); } + + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") + @PostMapping("/test") + public ApiResponse sendTestPush( + @RequestBody @Valid AdminNotificationTestRequest request + ) { + notificationDispatchService.sendTestPush(request.userId(), request.title(), request.body()); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java new file mode 100644 index 00000000..3800db4e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationTestRequest( + @NotNull Long userId, + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java index ca52e36d..2c5148e3 100644 --- a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java +++ b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java @@ -5,9 +5,9 @@ import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.domain.notification.service.NotificationService; +import java.time.Clock; import java.time.LocalDate; import java.time.LocalTime; -import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,17 +24,16 @@ @RequiredArgsConstructor public class NotificationScheduleDispatcher { - private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); - private final NotificationScheduleRepository notificationScheduleRepository; private final NotificationService notificationService; private final NotificationDispatchService notificationDispatchService; + private final Clock clock; @Scheduled(cron = "0 * * * * *", zone = "Asia/Seoul") @Transactional public void dispatchDueSchedules() { - LocalTime now = LocalTime.now(SEOUL_ZONE); - LocalDate today = LocalDate.now(SEOUL_ZONE); + LocalTime now = LocalTime.now(clock); + LocalDate today = LocalDate.now(clock); List dueSchedules = notificationScheduleRepository.findAllByEnabledTrue().stream() .filter(schedule -> schedule.isDue(now, today)) diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java index 78c1f367..3a60177e 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java @@ -94,6 +94,17 @@ public void notifyAdminNotice(NotificationDetailCode detailCode, String title, S } } + /** + * 관리자가 특정 유저에게 알림 설정(ON/OFF) 체크 없이 즉시 테스트 푸시를 발송한다. + */ + public void sendTestPush(Long userId, String title, String body) { + Map data = Map.of("type", "TEST"); + + for (UserDevice device : userDeviceRepository.findAllByUserId(userId)) { + sendPush(device, title, body, data); + } + } + /** * 유저의 알림 ON/OFF 설정을 확인한다. 설정이 없는 경우(레거시 유저 등) 기본값은 true. */ diff --git a/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java index 0a51ead4..927d7746 100644 --- a/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java @@ -1,5 +1,7 @@ package com.swyp.picke.global.config; +import java.time.Clock; +import java.time.ZoneId; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -20,4 +22,9 @@ public ThreadPoolTaskScheduler taskScheduler() { scheduler.setThreadNamePrefix("credit-scheduler-"); return scheduler; } + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } } diff --git a/src/main/java/com/swyp/picke/global/infra/fcm/service/FcmPushService.java b/src/main/java/com/swyp/picke/global/infra/fcm/service/FcmPushService.java index 1f836b98..34601a31 100644 --- a/src/main/java/com/swyp/picke/global/infra/fcm/service/FcmPushService.java +++ b/src/main/java/com/swyp/picke/global/infra/fcm/service/FcmPushService.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.Map; @Slf4j @@ -23,9 +24,13 @@ public class FcmPushService { * iOS는 FCM을 거치지 않고 ApnsPushService가 APNs로 직접 발송한다. */ public void send(UserDevice device, String title, String body, Map data) { + Map payload = new HashMap<>(data); + payload.put("title", title); + payload.put("body", body); + Message message = Message.builder() .setToken(device.getFcmToken()) - .putAllData(data) + .putAllData(payload) .setAndroidConfig(AndroidConfig.builder() .setPriority(AndroidConfig.Priority.HIGH) .build()) diff --git a/src/main/resources/frontend/ts/admin/picke.ts b/src/main/resources/frontend/ts/admin/picke.ts deleted file mode 100644 index 16a91a0c..00000000 --- a/src/main/resources/frontend/ts/admin/picke.ts +++ /dev/null @@ -1,101 +0,0 @@ -type ContentType = 'battle' | 'quiz' | 'vote'; - -interface BaseContent { - type: ContentType; - title: string; - category?: string[]; - hint?: string; -} - -interface BattleContent extends BaseContent { - characterA: { name: string; position: string; desc: string }; - characterB: { name: string; position: string; desc: string }; - scripts: any[]; -} - -interface QuizContent extends BaseContent { - question: string; - answerO: string; - answerX: string; -} - -document.addEventListener("DOMContentLoaded", () => { - let currentTab: ContentType = 'battle'; - - const tabButtons = document.querySelectorAll('#tab-container button'); - const contentTitleInput = document.getElementById('content-title') as HTMLInputElement | null; - const previewTitle = document.getElementById('preview-title') as HTMLElement | null; - const submitBtn = document.getElementById('btn-submit') as HTMLButtonElement | null; - - tabButtons.forEach(btn => { - btn.addEventListener('click', function () { - const tabName = this.dataset.tab as ContentType; - if (tabName) switchTab(tabName); - }); - }); - - if (contentTitleInput) { - contentTitleInput.addEventListener('input', (e: Event) => { - const target = e.target as HTMLInputElement; - if (previewTitle) previewTitle.innerHTML = target.value || '제목 없음'; - }); - } - - if (submitBtn) { - submitBtn.addEventListener('click', submitContent); - } - - function switchTab(tabName: ContentType): void { - currentTab = tabName; - - tabButtons.forEach(btn => { - btn.className = btn.dataset.tab === tabName - ? "px-6 py-2 rounded-full text-sm font-bold bg-black text-white transition-all" - : "px-6 py-2 rounded-full text-sm font-bold text-gray-500 hover:text-black transition-all"; - }); - - ['battle', 'quiz', 'vote'].forEach(t => { - const form = document.getElementById(`form-${t}`) as HTMLElement | null; - if (form) { - form.classList.toggle('hidden', t !== tabName); - form.classList.toggle('block', t === tabName); - } - }); - - updatePreview(tabName); - } - - function updatePreview(tabName: ContentType): void { - const badge = document.getElementById('preview-badge') as HTMLElement | null; - const battleIntro = document.getElementById('preview-battle-intro') as HTMLElement | null; - const quizView = document.getElementById('preview-quiz-view') as HTMLElement | null; - - if (battleIntro) battleIntro.classList.add('hidden'); - if (quizView) quizView.classList.add('hidden'); - - if (badge) { - badge.innerText = `${tabName.toUpperCase()} MODE`; - const color = tabName === 'battle' ? 'purple' : tabName === 'quiz' ? 'blue' : 'green'; - badge.className = `px-2 py-1 bg-${color}-100 text-${color}-700 text-[10px] font-bold rounded`; - } - - if (tabName === 'battle' && battleIntro) battleIntro.classList.remove('hidden'); - if (tabName === 'quiz' && quizView) quizView.classList.remove('hidden'); - } - - function submitContent(): void { - const title = contentTitleInput?.value || ''; - if (!title.trim()) { - alert("제목을 입력해주세요."); - contentTitleInput?.focus(); - return; - } - - // 현재 탭에 따른 데이터 수집 - let payload: any = { type: currentTab, title: title }; - // TODO: 여기서 폼의 각 input 값을 payload에 담아줍니다. - - console.log("서버로 전송할 데이터:", payload); - alert(`[${currentTab.toUpperCase()}] 콘텐츠가 콘솔에 출력되었습니다. API 연동을 진행해주세요.`); - } -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/api/api-common.js b/src/main/resources/static/js/admin/api/api-common.js deleted file mode 100644 index 5596d2db..00000000 --- a/src/main/resources/static/js/admin/api/api-common.js +++ /dev/null @@ -1,135 +0,0 @@ -PickeData.existingUrls = { thumbnail: null, charA: null, charB: null }; -PickeData.scenarioId = null; - -window.applyThumbnailPreview = function(url) { - if (!url) return; - - const bgIds = ['thumbnail-preview-bg']; - const placeholderIds = ['thumbnail-placeholder']; - const previewIds = ['intro-bg-img']; - - bgIds.forEach((id) => { - const bg = document.getElementById(id); - if (!bg) return; - bg.style.backgroundImage = `url('${url}')`; - bg.style.setProperty('opacity', '1', 'important'); - bg.classList.remove('opacity-0'); - }); - - placeholderIds.forEach((id) => { - const placeholder = document.getElementById(id); - if (placeholder) { - placeholder.style.display = 'none'; - placeholder.classList.add('hidden'); - } - }); - - previewIds.forEach((id) => { - const target = document.getElementById(id); - if (target) target.style.backgroundImage = `url('${url}')`; - }); -}; - -window.setTargetDateInputs = function(dateValue) { - if (!dateValue) return; - ['battle-target-date', 'quiz-target-date', 'poll-target-date'].forEach((id) => { - const input = document.getElementById(id); - if (input) input.value = dateValue; - }); -}; - -window.uploadImageToServer = async function(file, category) { - if (!file) return null; - const formData = new FormData(); - formData.append('file', file); - formData.append('category', category); - - try { - const res = await fetch(PickeData.API.FILE_UPLOAD, { - method: 'POST', - headers: { 'Authorization': `Bearer ${PickeData.token}` }, - body: formData - }); - if (!res.ok) throw new Error(res.status); - const text = await res.text(); - try { return JSON.parse(text).result ?? JSON.parse(text).data ?? text; } - catch { return text; } - } catch (e) { - console.error("이미지 업로드 실패:", e); - return null; - } -}; - -window.uploadImageToLocalDraft = async function(file) { - if (!file) return null; - const formData = new FormData(); - formData.append('file', file); - - try { - const res = await fetch(PickeData.API.FILE_UPLOAD_LOCAL, { - method: 'POST', - headers: { 'Authorization': `Bearer ${PickeData.token}` }, - body: formData - }); - if (!res.ok) throw new Error(res.status); - const text = await res.text(); - try { return JSON.parse(text).result ?? JSON.parse(text).data ?? text; } - catch { return text; } - } catch (e) { - console.error("로컬 임시 이미지 업로드 실패:", e); - return null; - } -}; - -document.addEventListener("DOMContentLoaded", async () => { - function setupImageUpload(inputId, bgId, placeholderId, targetImgId, fileKey) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - PickeData.uploadedFiles[fileKey] = file; - const url = URL.createObjectURL(file); - PickeData.setPreviewImage(bgId, placeholderId, targetImgId, url); - if (window.updateChatPreview) window.updateChatPreview(); - }); - } - - function setupThumbnailUpload(inputId) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - PickeData.uploadedFiles.thumbnail = file; - const url = URL.createObjectURL(file); - window.applyThumbnailPreview(url); - }); - } - - function bindTargetDateInput(inputId) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const value = e.target.value; - if (!value) return; - PickeData.currentTargetDate = value; - window.setTargetDateInputs(value); - }); - } - - setupThumbnailUpload('thumbnail-upload'); - setupImageUpload('char-a-img-upload', 'char-a-img-bg', 'char-a-img-placeholder', 'intro-char-a-img', 'charA'); - setupImageUpload('char-b-img-upload', 'char-b-img-bg', 'char-b-img-placeholder', 'intro-char-b-img', 'charB'); - - bindTargetDateInput('battle-target-date'); - bindTargetDateInput('quiz-target-date'); - bindTargetDateInput('poll-target-date'); - - const today = new Date().toISOString().split('T')[0]; - if (!PickeData.currentTargetDate) PickeData.currentTargetDate = today; - window.setTargetDateInputs(PickeData.currentTargetDate); - - if (typeof window.fetchAllTags === 'function') await window.fetchAllTags(); - if (typeof window.loadContent === 'function') await window.loadContent(); -}); diff --git a/src/main/resources/static/js/admin/api/api-load.js b/src/main/resources/static/js/admin/api/api-load.js deleted file mode 100644 index 9d3e4e6a..00000000 --- a/src/main/resources/static/js/admin/api/api-load.js +++ /dev/null @@ -1,309 +0,0 @@ -window.loadContent = async function () { - if (!PickeData.isEditMode) return; - - const type = PickeData.currentTypeParam === 'VOTE' ? 'POLL' : PickeData.currentTypeParam; - const endpointByType = { - BATTLE: PickeData.API.BATTLE_GET, - QUIZ: PickeData.API.QUIZ_GET, - POLL: PickeData.API.POLL_GET - }; - - const formTargetByType = { - BATTLE: 'form-battle', - QUIZ: 'form-quiz', - POLL: 'form-vote' - }; - - const createEmptyScript = (speakerType = 'NARRATOR') => ({ speakerType, text: '' }); - - const renderScenarioScripts = (containerId, scripts, defaultSpeaker = 'NARRATOR') => { - const container = document.getElementById(containerId); - if (!container) return; - - container.innerHTML = ''; - const safeScripts = Array.isArray(scripts) && scripts.length > 0 - ? scripts - : [createEmptyScript(defaultSpeaker)]; - - safeScripts.forEach((script) => { - if (typeof window.addScriptBlock === 'function') { - window.addScriptBlock(containerId, script.speakerType || defaultSpeaker); - } else { - return; - } - - const blocks = container.querySelectorAll('.script-block'); - const block = blocks[blocks.length - 1]; - if (!block) return; - - const speakerSelect = block.querySelector('.speaker-select'); - if (speakerSelect) { - speakerSelect.value = script.speakerType || defaultSpeaker; - } - - const scriptText = block.querySelector('.script-text'); - if (scriptText) { - scriptText.value = script.text || ''; - scriptText.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - }; - - const applyScenarioToForm = (scenario) => { - if (!scenario) return; - - const nodes = Array.isArray(scenario.nodes) ? scenario.nodes : []; - const nodeByName = {}; - const nodeNameById = {}; - - nodes.forEach((node) => { - if (!node) return; - nodeByName[node.nodeName] = node; - if (node.nodeId != null) { - nodeNameById[node.nodeId] = node.nodeName; - } - }); - - const startNode = nodeByName.START || null; - const branchANode = nodeByName.BRANCH_A || null; - const branchBNode = nodeByName.BRANCH_B || null; - const closingNode = nodeByName.CLOSING || null; - const isInteractive = !!scenario.isInteractive; - - const branchContainer = document.getElementById('branch-container'); - const addBranchButton = document.getElementById('btn-add-branch'); - if (branchContainer) { - if (isInteractive) branchContainer.classList.remove('hidden'); - else branchContainer.classList.add('hidden'); - } - if (addBranchButton) { - if (isInteractive) addBranchButton.classList.add('hidden'); - else addBranchButton.classList.remove('hidden'); - } - - renderScenarioScripts('start-node-container', startNode?.scripts || [], 'NARRATOR'); - renderScenarioScripts('closing-node-container', closingNode?.scripts || [], 'NARRATOR'); - renderScenarioScripts('branch-a-node-container', branchANode?.scripts || [], 'A'); - renderScenarioScripts('branch-b-node-container', branchBNode?.scripts || [], 'B'); - - const branchAInput = document.getElementById('branch-a-label'); - const branchBInput = document.getElementById('branch-b-label'); - - const startOptions = Array.isArray(startNode?.interactiveOptions) ? startNode.interactiveOptions : []; - let branchALabel = ''; - let branchBLabel = ''; - - startOptions.forEach((option) => { - const targetNodeName = option?.nextNodeId != null ? nodeNameById[option.nextNodeId] : null; - if (targetNodeName === 'BRANCH_A') branchALabel = option.label || ''; - if (targetNodeName === 'BRANCH_B') branchBLabel = option.label || ''; - }); - - if (branchAInput) branchAInput.value = branchALabel; - if (branchBInput) branchBInput.value = branchBLabel; - - if (window.updateChatPreview) window.updateChatPreview(); - }; - - try { - const getter = endpointByType[type] || PickeData.API.BATTLE_GET; - const res = await fetch(getter(PickeData.currentContentId), { headers: PickeData.getAuthHeaders() }); - if (!res.ok) return; - - const json = await res.json(); - const data = json.result || json.data || json; - const toDatetimeLocal = (value) => value ? String(value).slice(0, 16) : ''; - - document.querySelector(`[data-target="${formTargetByType[type]}"]`)?.click(); - PickeData.currentContentType = type; - - Object.keys(PickeData.selections).forEach((key) => { - PickeData.selections[key] = []; - }); - - PickeData.currentTargetDate = data.targetDate || PickeData.currentTargetDate; - PickeData.currentStatus = data.status || 'PENDING'; - if (window.setTargetDateInputs) window.setTargetDateInputs(PickeData.currentTargetDate); - - if (type === 'BATTLE') { - PickeData.setValue('content-title', data.title || ''); - PickeData.setValue('content-summary', data.summary || ''); - PickeData.setValue('content-desc', data.description || ''); - PickeData.setValue('battle-audio-duration', data.audioDuration ?? ''); - PickeData.setValue('battle-status', data.status || 'PENDING'); - PickeData.setValue('battle-publish-at', toDatetimeLocal(data.publishAt)); - PickeData.setValue('battle-thumbnail-url', data.thumbnailUrl || ''); - - if (data.thumbnailUrl) { - PickeData.existingUrls.thumbnail = data.thumbnailUrl; - if (window.applyThumbnailPreview) window.applyThumbnailPreview(data.thumbnailUrl); - } - - PickeData.selections.CATEGORY = (data.tags || []).map((tag) => tag.tagId || tag.id); - - const options = data.options || []; - const optionA = options.find((option) => option.label === 'A'); - const optionB = options.find((option) => option.label === 'B'); - - if (optionA) { - PickeData.setValue('char-a-title', optionA.title || ''); - PickeData.setValue('char-a-stance', optionA.stance || ''); - PickeData.setValue('char-a-rep', optionA.representative || ''); - PickeData.setValue('char-a-display-order', optionA.displayOrder ?? 1); - PickeData.setValue('char-a-image-url', optionA.imageUrl || ''); - if (optionA.imageUrl) { - PickeData.existingUrls.charA = optionA.imageUrl; - PickeData.setPreviewImage('char-a-img-bg', 'char-a-img-placeholder', 'intro-char-a-img', optionA.imageUrl); - } - PickeData.selections.BATTLE_A_PHILOSOPHER = (optionA.tags || []) - .filter((tag) => tag.type === 'PHILOSOPHER') - .map((tag) => tag.tagId || tag.id); - PickeData.selections.BATTLE_A_VALUE = (optionA.tags || []) - .filter((tag) => tag.type === 'VALUE') - .map((tag) => tag.tagId || tag.id); - } - - if (optionB) { - PickeData.setValue('char-b-title', optionB.title || ''); - PickeData.setValue('char-b-stance', optionB.stance || ''); - PickeData.setValue('char-b-rep', optionB.representative || ''); - PickeData.setValue('char-b-display-order', optionB.displayOrder ?? 2); - PickeData.setValue('char-b-image-url', optionB.imageUrl || ''); - if (optionB.imageUrl) { - PickeData.existingUrls.charB = optionB.imageUrl; - PickeData.setPreviewImage('char-b-img-bg', 'char-b-img-placeholder', 'intro-char-b-img', optionB.imageUrl); - } - PickeData.selections.BATTLE_B_PHILOSOPHER = (optionB.tags || []) - .filter((tag) => tag.type === 'PHILOSOPHER') - .map((tag) => tag.tagId || tag.id); - PickeData.selections.BATTLE_B_VALUE = (optionB.tags || []) - .filter((tag) => tag.type === 'VALUE') - .map((tag) => tag.tagId || tag.id); - } - - try { - const scenRes = await fetch(`/api/v1/admin/battles/${PickeData.currentContentId}/scenario`, { headers: PickeData.getAuthHeaders() }); - if (scenRes.ok) { - const scenJson = await scenRes.json(); - const scenario = scenJson.result || scenJson.data; - if (scenario) { - const voiceSettings = scenario.voiceSettings || {}; - PickeData.setValue('tts-voice-narrator', voiceSettings.NARRATOR || ''); - PickeData.setValue('tts-voice-a', voiceSettings.A || ''); - PickeData.setValue('tts-voice-b', voiceSettings.B || ''); - PickeData.setValue('tts-voice-user', voiceSettings.USER || ''); - PickeData.scenarioId = scenario.scenarioId || scenario.id || null; - applyScenarioToForm(scenario); - } - } - } catch (e) { - console.error('시나리오를 불러오지 못했습니다:', e); - } - } - - if (type === 'QUIZ') { - PickeData.setValue('quiz-title', data.title || ''); - PickeData.setValue('quiz-status', data.status || 'PENDING'); - PickeData.setValue('quiz-publish-at', toDatetimeLocal(data.publishAt)); - - const options = data.options || []; - const optionA = options.find((option) => option.label === 'A'); - const optionB = options.find((option) => option.label === 'B'); - - if (optionA) { - PickeData.setValue('quiz-option-a-title', optionA.text || ''); - PickeData.setValue('quiz-option-a-detail', optionA.detailText || ''); - PickeData.setValue('quiz-option-a-display-order', optionA.displayOrder ?? 1); - if (optionA.isCorrect) document.getElementById('quiz-answer-a').checked = true; - } - - if (optionB) { - PickeData.setValue('quiz-option-b-title', optionB.text || ''); - PickeData.setValue('quiz-option-b-detail', optionB.detailText || ''); - PickeData.setValue('quiz-option-b-display-order', optionB.displayOrder ?? 2); - if (optionB.isCorrect) document.getElementById('quiz-answer-b').checked = true; - } - } - - if (type === 'POLL') { - PickeData.setValue('poll-title-prefix', data.titlePrefix || ''); - PickeData.setValue('poll-title-suffix', data.titleSuffix || ''); - PickeData.setValue('poll-status', data.status || 'PENDING'); - PickeData.setValue('poll-publish-at', toDatetimeLocal(data.publishAt)); - - const optionTargetByLabel = { - A: { titleId: 'poll-option-1-title', orderId: 'poll-option-1-display-order' }, - B: { titleId: 'poll-option-2-title', orderId: 'poll-option-2-display-order' }, - C: { titleId: 'poll-option-3-title', orderId: 'poll-option-3-display-order' }, - D: { titleId: 'poll-option-4-title', orderId: 'poll-option-4-display-order' } - }; - - (data.options || []).forEach((option) => { - const mapping = optionTargetByLabel[option.label]; - if (!mapping) return; - PickeData.setValue(mapping.titleId, option.title || ''); - PickeData.setValue(mapping.orderId, option.displayOrder ?? null); - }); - } - - if (window.refreshFormBadges) window.refreshFormBadges(); - if (window.updatePreviewTags) window.updatePreviewTags(); - - document.querySelectorAll('textarea, input').forEach((el) => { - el.dispatchEvent(new Event('input', { bubbles: true })); - }); - - if (window.updateChatPreview) window.updateChatPreview(); - if (window.updateButtonStates) window.updateButtonStates(data.status); - } catch (e) { - console.error('콘텐츠를 불러오는 중 오류가 발생했습니다:', e); - } -}; - -window.updateButtonStates = function (currentStatus) { - const btnPending = document.getElementById('btn-save-pending'); - const btnScheduled = document.getElementById('btn-save-scheduled'); - const btnPublish = document.getElementById('btn-save-publish'); - const btnRepublish = document.getElementById('btn-republish-audio'); - - if (PickeData.isEditMode && btnPublish) { - if (currentStatus === 'PUBLISHED') { - btnPublish.innerText = '수정 저장 (텍스트만)'; - btnPublish.onclick = () => window.saveContent('EDIT'); - } else { - btnPublish.innerText = '발행'; - btnPublish.onclick = () => window.saveContent('PUBLISHED'); - } - } - - if (currentStatus === 'PUBLISHED') { - if (btnPending) { - btnPending.disabled = true; - btnPending.classList.add('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnScheduled) { - btnScheduled.disabled = true; - btnScheduled.classList.add('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnRepublish && PickeData.currentContentType === 'BATTLE') { - btnRepublish.classList.remove('hidden'); - } - } else { - if (btnRepublish) btnRepublish.classList.add('hidden'); - if (btnPending) { - btnPending.disabled = false; - btnPending.classList.remove('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnScheduled) { - btnScheduled.disabled = false; - btnScheduled.classList.remove('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - } -}; - -window.confirmRepublish = function () { - const isConfirmed = confirm('시나리오 오디오를 다시 생성할까요? TTS 사용량이 증가할 수 있습니다.'); - if (isConfirmed) { - window.saveContent('PUBLISH'); - } -}; diff --git a/src/main/resources/static/js/admin/api/api-save.js b/src/main/resources/static/js/admin/api/api-save.js deleted file mode 100644 index 909fffb3..00000000 --- a/src/main/resources/static/js/admin/api/api-save.js +++ /dev/null @@ -1,372 +0,0 @@ -window.saveContent = async (action) => { - const loader = document.getElementById('global-loader'); - const loaderText = document.getElementById('loader-text'); - - const statusFromAction = action === 'PENDING' || action === 'SCHEDULED' ? 'PENDING' : 'PUBLISHED'; - - if (loader) { - if (loaderText) { - if (action === 'PUBLISHED' || action === 'PUBLISH') loaderText.innerText = '콘텐츠를 발행하는 중입니다...'; - else if (action === 'SCHEDULED') loaderText.innerText = '콘텐츠를 예약 저장 중입니다...'; - else if (action === 'EDIT') loaderText.innerText = '콘텐츠를 수정하는 중입니다...'; - else loaderText.innerText = '임시 저장 중입니다...'; - } - loader.classList.remove('hidden'); - loader.classList.add('flex'); - } - - const toUrlString = (urlObj) => { - if (!urlObj) return null; - if (typeof urlObj === 'string') return urlObj; - return urlObj.s3Key || urlObj.presignedUrl || String(urlObj); - }; - - const asIntOrNull = (value) => { - if (value == null || value === '') return null; - const parsed = Number(value); - return Number.isNaN(parsed) ? null : parsed; - }; - - const setHiddenImageValue = (id, value) => { - const input = document.getElementById(id); - if (input) input.value = value || ''; - }; - - const getTargetDate = (type) => { - const inputIdByType = { - BATTLE: 'battle-target-date', - QUIZ: 'quiz-target-date', - POLL: 'poll-target-date' - }; - return document.getElementById(inputIdByType[type])?.value || PickeData.currentTargetDate || new Date().toISOString().split('T')[0]; - }; - - const getPublishAt = (type) => { - const inputIdByType = { - BATTLE: 'battle-publish-at', - QUIZ: 'quiz-publish-at', - POLL: 'poll-publish-at' - }; - const value = document.getElementById(inputIdByType[type])?.value; - return value ? value : null; - }; - - const getStatus = (type) => { - if (action === 'PENDING' || action === 'SCHEDULED') return 'PENDING'; - if (action === 'PUBLISHED' || action === 'PUBLISH') return 'PUBLISHED'; - - const statusInputByType = { - BATTLE: 'battle-status', - QUIZ: 'quiz-status', - POLL: 'poll-status' - }; - return document.getElementById(statusInputByType[type])?.value || statusFromAction; - }; - - try { - const currentType = PickeData.currentContentType; - const previousStatus = PickeData.currentStatus; - const targetDate = getTargetDate(currentType); - const publishAt = getPublishAt(currentType); - const resolvedStatus = getStatus(currentType); - const hasNewBattleImageUploads = currentType === 'BATTLE' - && !!(PickeData.uploadedFiles.thumbnail || PickeData.uploadedFiles.charA || PickeData.uploadedFiles.charB); - const shouldUploadAssets = action === 'PUBLISHED' || action === 'PUBLISH' || (action === 'EDIT' && hasNewBattleImageUploads); - const shouldUploadLocalDraft = action === 'PENDING' || action === 'SCHEDULED'; - - PickeData.currentTargetDate = targetDate; - - if (action === 'SCHEDULED' && !publishAt) { - throw new Error('예약 발행 시각을 입력해 주세요.'); - } - - let thumbnailUrl = PickeData.existingUrls.thumbnail; - let charAUrl = PickeData.existingUrls.charA; - let charBUrl = PickeData.existingUrls.charB; - - if (currentType === 'BATTLE') { - const uploadByAction = async (file, category) => { - if (!file) return null; - if (shouldUploadAssets) return window.uploadImageToServer(file, category); - if (shouldUploadLocalDraft) return window.uploadImageToLocalDraft(file); - return null; - }; - - if (PickeData.uploadedFiles.thumbnail) { - thumbnailUrl = await uploadByAction(PickeData.uploadedFiles.thumbnail, 'BATTLE'); - } - if (PickeData.uploadedFiles.charA) { - charAUrl = await uploadByAction(PickeData.uploadedFiles.charA, 'PHILOSOPHER'); - } - if (PickeData.uploadedFiles.charB) { - charBUrl = await uploadByAction(PickeData.uploadedFiles.charB, 'PHILOSOPHER'); - } - } - - thumbnailUrl = toUrlString(thumbnailUrl); - charAUrl = toUrlString(charAUrl); - charBUrl = toUrlString(charBUrl); - - PickeData.existingUrls.thumbnail = thumbnailUrl || null; - PickeData.existingUrls.charA = charAUrl || null; - PickeData.existingUrls.charB = charBUrl || null; - - setHiddenImageValue('battle-thumbnail-url', thumbnailUrl); - setHiddenImageValue('char-a-image-url', charAUrl); - setHiddenImageValue('char-b-image-url', charBUrl); - - let payload = null; - let requestUrl = ''; - - if (currentType === 'BATTLE') { - payload = { - status: resolvedStatus, - title: document.getElementById('content-title')?.value || '', - summary: document.getElementById('content-summary')?.value || '', - description: document.getElementById('content-desc')?.value || '', - thumbnailUrl: thumbnailUrl || document.getElementById('battle-thumbnail-url')?.value || null, - targetDate, - publishAt, - audioDuration: asIntOrNull(document.getElementById('battle-audio-duration')?.value), - tagIds: PickeData.selections.CATEGORY || [], - options: [ - { - title: document.getElementById('char-a-title')?.value || '', - stance: document.getElementById('char-a-stance')?.value || '', - representative: document.getElementById('char-a-rep')?.value || '', - imageUrl: charAUrl || document.getElementById('char-a-image-url')?.value || null, - displayOrder: asIntOrNull(document.getElementById('char-a-display-order')?.value) || 1, - tagIds: [ - ...(PickeData.selections.BATTLE_A_PHILOSOPHER || []), - ...(PickeData.selections.BATTLE_A_VALUE || []) - ] - }, - { - title: document.getElementById('char-b-title')?.value || '', - stance: document.getElementById('char-b-stance')?.value || '', - representative: document.getElementById('char-b-rep')?.value || '', - imageUrl: charBUrl || document.getElementById('char-b-image-url')?.value || null, - displayOrder: asIntOrNull(document.getElementById('char-b-display-order')?.value) || 2, - tagIds: [ - ...(PickeData.selections.BATTLE_B_PHILOSOPHER || []), - ...(PickeData.selections.BATTLE_B_VALUE || []) - ] - } - ] - }; - requestUrl = PickeData.isEditMode ? PickeData.API.BATTLE_UPDATE(PickeData.currentContentId) : PickeData.API.BATTLE_CREATE; - } else if (currentType === 'QUIZ') { - payload = { - title: document.getElementById('quiz-title')?.value || '', - targetDate, - publishAt, - status: resolvedStatus, - options: [ - { - label: 'A', - text: document.getElementById('quiz-option-a-title')?.value || '', - detailText: document.getElementById('quiz-option-a-detail')?.value || '', - isCorrect: document.getElementById('quiz-answer-a')?.checked || false, - displayOrder: asIntOrNull(document.getElementById('quiz-option-a-display-order')?.value) || 1 - }, - { - label: 'B', - text: document.getElementById('quiz-option-b-title')?.value || '', - detailText: document.getElementById('quiz-option-b-detail')?.value || '', - isCorrect: document.getElementById('quiz-answer-b')?.checked || false, - displayOrder: asIntOrNull(document.getElementById('quiz-option-b-display-order')?.value) || 2 - } - ] - }; - requestUrl = PickeData.isEditMode ? PickeData.API.QUIZ_UPDATE(PickeData.currentContentId) : PickeData.API.QUIZ_CREATE; - } else { - const pollOptions = [ - { label: 'A', titleId: 'poll-option-1-title', orderId: 'poll-option-1-display-order' }, - { label: 'B', titleId: 'poll-option-2-title', orderId: 'poll-option-2-display-order' }, - { label: 'C', titleId: 'poll-option-3-title', orderId: 'poll-option-3-display-order' }, - { label: 'D', titleId: 'poll-option-4-title', orderId: 'poll-option-4-display-order' } - ] - .map((option, index) => ({ - label: option.label, - title: document.getElementById(option.titleId)?.value || '', - displayOrder: asIntOrNull(document.getElementById(option.orderId)?.value) || (index + 1) - })) - .filter((option) => option.title.trim().length > 0); - - payload = { - titlePrefix: document.getElementById('poll-title-prefix')?.value || '', - titleSuffix: document.getElementById('poll-title-suffix')?.value || '', - targetDate, - publishAt, - status: resolvedStatus, - options: pollOptions - }; - requestUrl = PickeData.isEditMode ? PickeData.API.POLL_UPDATE(PickeData.currentContentId) : PickeData.API.POLL_CREATE; - } - - const saveRes = await fetch(requestUrl, { - method: PickeData.isEditMode ? 'PATCH' : 'POST', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify(payload) - }); - if (!saveRes.ok) throw new Error('콘텐츠 저장에 실패했습니다.'); - - const saved = await saveRes.json(); - const result = saved.result || saved.data || {}; - const savedId = result.battleId || result.quizId || result.pollId || result.id || PickeData.currentContentId; - - if (!PickeData.isEditMode) { - PickeData.currentContentId = savedId; - PickeData.isEditMode = true; - } - - if (currentType === 'BATTLE') { - const isInteractive = !document.getElementById('branch-container')?.classList.contains('hidden'); - - const extractScripts = (containerId) => { - const scripts = []; - document.querySelectorAll(`#${containerId} .script-block`).forEach((block) => { - const speakerSelect = block.querySelector('.speaker-select'); - const scriptTextArea = block.querySelector('.script-text'); - if (!speakerSelect || !scriptTextArea) return; - - const speakerType = speakerSelect.value; - let speakerName = 'NARRATOR'; - if (speakerType === 'A') speakerName = document.getElementById('char-a-rep')?.value || 'A'; - if (speakerType === 'B') speakerName = document.getElementById('char-b-rep')?.value || 'B'; - scripts.push({ speakerType, speakerName, text: scriptTextArea.value }); - }); - return scripts; - }; - - const startOptions = []; - if (isInteractive) { - startOptions.push({ label: document.getElementById('branch-a-label')?.value || 'A', nextNodeName: 'BRANCH_A' }); - startOptions.push({ label: document.getElementById('branch-b-label')?.value || 'B', nextNodeName: 'BRANCH_B' }); - } - - const nodes = [ - { - nodeName: 'START', - isStartNode: true, - autoNextNode: isInteractive ? null : 'CLOSING', - scripts: extractScripts('start-node-container'), - interactiveOptions: startOptions - } - ]; - - if (isInteractive) { - nodes.push({ nodeName: 'BRANCH_A', isStartNode: false, autoNextNode: 'CLOSING', scripts: extractScripts('branch-a-node-container'), interactiveOptions: [] }); - nodes.push({ nodeName: 'BRANCH_B', isStartNode: false, autoNextNode: 'CLOSING', scripts: extractScripts('branch-b-node-container'), interactiveOptions: [] }); - } - - nodes.push({ nodeName: 'CLOSING', isStartNode: false, autoNextNode: null, scripts: extractScripts('closing-node-container'), interactiveOptions: [] }); - - const voiceSettings = {}; - const voiceInputMap = { - NARRATOR: 'tts-voice-narrator', - A: 'tts-voice-a', - B: 'tts-voice-b', - USER: 'tts-voice-user' - }; - - Object.entries(voiceInputMap).forEach(([speakerType, inputId]) => { - const value = document.getElementById(inputId)?.value?.trim(); - if (value) voiceSettings[speakerType] = value; - }); - - if (action === 'PUBLISHED' || action === 'PUBLISH') { - const requiredSpeakers = new Set(); - nodes.forEach((node) => { - (node.scripts || []).forEach((script) => { - if (script.speakerType) requiredSpeakers.add(script.speakerType); - }); - }); - - const missingVoiceSpeakers = Array.from(requiredSpeakers).filter((speakerType) => !voiceSettings[speakerType]); - if (missingVoiceSpeakers.length > 0) { - throw new Error(`다음 화자의 Fish Audio reference_id가 없습니다: ${missingVoiceSpeakers.join(', ')}`); - } - } - - const scenarioPayload = { - battleId: savedId, - isInteractive, - nodes, - status: resolvedStatus, - voiceSettings - }; - - const scenarioExisted = !!PickeData.scenarioId; - const scenarioMethod = scenarioExisted ? 'PUT' : 'POST'; - const scenarioUrl = scenarioExisted ? `/api/v1/admin/scenarios/${PickeData.scenarioId}` : '/api/v1/admin/scenarios'; - - const scenarioRes = await fetch(scenarioUrl, { - method: scenarioMethod, - headers: PickeData.getAuthHeaders(), - body: JSON.stringify(scenarioPayload) - }); - if (!scenarioRes.ok) throw new Error('시나리오 저장에 실패했습니다.'); - - const scenarioData = await scenarioRes.json(); - if (!scenarioExisted) { - const scenarioResult = scenarioData.result || scenarioData.data || {}; - PickeData.scenarioId = scenarioResult.scenarioId || scenarioResult.id || null; - } - - if (scenarioExisted && PickeData.scenarioId) { - const shouldPatchScenarioStatus = - action === 'PUBLISHED' - || action === 'PUBLISH' - || previousStatus !== resolvedStatus; - if (shouldPatchScenarioStatus) { - const statusRes = await fetch(`/api/v1/admin/scenarios/${PickeData.scenarioId}`, { - method: 'PATCH', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ status: resolvedStatus }) - }); - if (!statusRes.ok) throw new Error('시나리오 상태 업데이트에 실패했습니다.'); - } - } - } - - PickeData.currentStatus = resolvedStatus; - - if (loader) { - loader.classList.add('hidden'); - loader.classList.remove('flex'); - } - - const modal = document.getElementById('custom-modal'); - if (!modal) { - window.location.href = '/api/v1/admin/picke/list'; - return; - } - - document.getElementById('custom-modal-title').innerText = '완료'; - document.getElementById('custom-modal-message').innerText = action === 'PENDING' - ? '임시 저장되었습니다.' - : action === 'SCHEDULED' - ? '예약 저장되었습니다.' - : action === 'EDIT' - ? '수정이 완료되었습니다.' - : '발행이 완료되었습니다.'; - - modal.classList.remove('hidden'); - setTimeout(() => { - modal.classList.add('opacity-100'); - modal.classList.remove('opacity-0'); - }, 10); - - document.getElementById('custom-modal-confirm').onclick = () => { - window.location.href = '/api/v1/admin/picke/list'; - }; - } catch (e) { - if (loader) { - loader.classList.add('hidden'); - loader.classList.remove('flex'); - } - console.error('저장 중 오류:', e); - alert(`저장에 실패했습니다: ${e.message}`); - } -}; diff --git a/src/main/resources/static/js/admin/chat/chat-audio.js b/src/main/resources/static/js/admin/chat/chat-audio.js deleted file mode 100644 index 6112fdc4..00000000 --- a/src/main/resources/static/js/admin/chat/chat-audio.js +++ /dev/null @@ -1,48 +0,0 @@ -function _getAudio() { return document.getElementById('preview-audio'); } - -window.toggleAudio = function () { - const a = _getAudio(); - if (!a) return; - if (!a.src || a.src === window.location.href) { _openAudioPicker(); return; } - a.paused ? a.play() : a.pause(); -}; - -function _updatePlayIcon() { - const a = _getAudio(); - const icon = document.getElementById('audio-play-icon'); - if (!icon) return; - icon.setAttribute('d', (a && !a.paused) ? 'M6 19h4V5H6v14zm8-14v14h4V5h-4z' : 'M8 5v14l11-7z'); -} - -// 파일 선택 팝업 -function _openAudioPicker() { - let picker = document.getElementById('audio-file-picker') || document.createElement('input'); - picker.type = 'file'; picker.accept = 'audio/*'; picker.id = 'audio-file-picker'; - picker.style.display = 'none'; - if (!document.getElementById('audio-file-picker')) document.body.appendChild(picker); - - picker.onchange = (e) => { - const file = e.target.files[0]; - if (file) { - const a = _getAudio(); - a.src = URL.createObjectURL(file); - a.play(); - } - }; - picker.click(); -} - -// 이벤트 리스너 등록 -document.addEventListener('DOMContentLoaded', () => { - const a = _getAudio(); - if (!a) return; - - a.addEventListener('timeupdate', () => { - const pct = a.duration ? (a.currentTime / a.duration * 100) : 0; - if (document.getElementById('audio-progress-fill')) document.getElementById('audio-progress-fill').style.width = pct + '%'; - if (document.getElementById('audio-current-time')) document.getElementById('audio-current-time').textContent = Math.floor(a.currentTime / 60) + ":" + Math.floor(a.currentTime % 60).toString().padStart(2, '0'); - }); - - a.addEventListener('play', _updatePlayIcon); - a.addEventListener('pause', _updatePlayIcon); -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/chat/chat-editor.js b/src/main/resources/static/js/admin/chat/chat-editor.js deleted file mode 100644 index 1cb56fdb..00000000 --- a/src/main/resources/static/js/admin/chat/chat-editor.js +++ /dev/null @@ -1,404 +0,0 @@ -// 대본 입력창 자동 높이 -const resizeScriptTextarea = (textarea) => { - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.overflowY = 'hidden'; - textarea.style.height = `${textarea.scrollHeight}px`; -}; - -const bindScriptTextareaAutosize = (textarea) => { - if (!textarea) return; - textarea.addEventListener('input', () => { - resizeScriptTextarea(textarea); - if (window.updateChatPreview) window.updateChatPreview(); - }); - resizeScriptTextarea(textarea); -}; - -const initScriptTextareaAutosize = () => { - document.querySelectorAll('.script-text').forEach(bindScriptTextareaAutosize); -}; - -window.addScriptBlock = (containerId, speaker) => { - const block = document.createElement('div'); - block.className = 'flex items-start gap-4 script-block bg-white border border-gray-100 p-4 rounded-2xl shadow-sm mb-3 group'; - block.innerHTML = ` - -
- - -
- - `; - - document.getElementById(containerId)?.appendChild(block); - - const ta = block.querySelector('textarea'); - bindScriptTextareaAutosize(ta); -}; - -// 감정 태그 삽입 -document.addEventListener('change', (e) => { - if (!e.target.classList.contains('emotion-insert-btn')) return; - const textarea = e.target.closest('.script-block')?.querySelector('.script-text'); - if (e.target.value && textarea) { - const tag = `[${e.target.value}]`; - const start = textarea.selectionStart; - textarea.value = textarea.value.substring(0, start) + tag + textarea.value.substring(textarea.selectionEnd); - e.target.value = ''; - if (window.updateChatPreview) window.updateChatPreview(); - textarea.focus(); - textarea.selectionEnd = start + tag.length; - } -}); - -// 분기 열기/닫기 -window.addBranchBlock = () => { - document.getElementById('branch-container')?.classList.remove('hidden'); - document.getElementById('btn-add-branch')?.classList.add('hidden'); -}; - -window.removeBranchBlock = () => { - document.getElementById('branch-container')?.classList.add('hidden'); - document.getElementById('btn-add-branch')?.classList.remove('hidden'); - if (window.updateChatPreview) window.updateChatPreview(); -}; - -// 채팅 미리보기 업데이트 -window.updateChatPreview = function () { - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - chatContainer.innerHTML = ''; - - const nameA = document.getElementById('char-a-rep')?.value || '화자 A'; - const nameB = document.getElementById('char-b-rep')?.value || '화자 B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 섹션별 대본 렌더링 - const renderBlocks = (containerSelector, sectionTitle = null) => { - const blocks = document.querySelectorAll(`${containerSelector} .script-block`); - if (blocks.length > 0 && sectionTitle) { - chatContainer.innerHTML += `
${sectionTitle}
`; - } - - blocks.forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - - // [태그]와 html 태그 제거 - let text = ta.value.replace(/\[.*?\]/g, '').replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - - if (type === 'NARRATOR') { - chatContainer.innerHTML += ` -
-
-

${text}

-
-
`; - } else if (type === 'A') { - chatContainer.innerHTML += ` -
-
-
-

${nameA}

-
- ${text} -
-
-
`; - } else if (type === 'B') { - chatContainer.innerHTML += ` -
-
-
-

${nameB}

-
- ${text} -
-
-
`; - } - }); - }; - - // 1. 시작 대본 - renderBlocks('#start-node-container'); - - // 2. 분기 버튼 업데이트 - const branchContainer = document.getElementById('branch-container'); - const branchChoiceUI = document.getElementById('preview-branch-choice'); - - if (branchContainer && !branchContainer.classList.contains('hidden')) { - if (branchChoiceUI) branchChoiceUI.classList.remove('hidden'); - - const labelA = document.getElementById('branch-a-label')?.value || 'A 선택지를 입력하세요'; - const labelB = document.getElementById('branch-b-label')?.value || 'B 선택지를 입력하세요'; - - const btnA = document.getElementById('branch-btn-a'); - const btnB = document.getElementById('branch-btn-b'); - - if (btnA) btnA.innerHTML = labelA.replace(/\n/g, '
'); - if (btnB) btnB.innerHTML = labelB.replace(/\n/g, '
'); - - renderBlocks('#branch-a-node-container', 'OPTION A PATH'); - renderBlocks('#branch-b-node-container', 'OPTION B PATH'); - } else { - if (branchChoiceUI) branchChoiceUI.classList.add('hidden'); - } - - // 3. 클로징 대본 - renderBlocks('#closing-node-container'); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 분기 버튼 텍스트 입력 시 즉시 미리보기 반영 -document.addEventListener('input', (e) => { - if (e.target.id === 'branch-a-label' || e.target.id === 'branch-b-label') { - if (window.updateChatPreview) window.updateChatPreview(); - } -}); - -// 분기 선택 (사용자 미리보기용) -window.selectBranch = function (branch) { - const branchChoice = document.getElementById('preview-branch-choice'); - if (branchChoice) branchChoice.classList.add('hidden'); - - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - - const nameA = document.getElementById('char-a-rep')?.value || '화자 A'; - const nameB = document.getElementById('char-b-rep')?.value || '화자 B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 선택 배너 - const choiceLabel = branch === 'A' - ? (document.getElementById('branch-a-label')?.value || 'A 선택') - : (document.getElementById('branch-b-label')?.value || 'B 선택'); - chatContainer.innerHTML += `
"${choiceLabel}" 선택
`; - - // 선택된 분기 대본 - const nodeId = branch === 'A' ? 'branch-a-node-container' : 'branch-b-node-container'; - document.querySelectorAll(`#${nodeId} .script-block`).forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - let text = ta.value.replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - - // 클로징 대본 - document.querySelectorAll('#closing-node-container .script-block').forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - let text = ta.value.replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 말풍선 HTML 생성 -function _buildBubble(type, text, nameA, nameB, charAImg, charBImg) { - if (type === 'NARRATOR') { - return `

${text}

`; - } - if (type === 'A') { - return ` -
-
-
-

${nameA}

-
${text}
-
-
`; - } - if (type === 'B') { - return ` -
-
-
-

${nameB}

-
${text}
-
-
`; - } - return ''; -} - -// 오디오 플레이어 -function _getAudio() { - return document.getElementById('preview-audio'); -} - -function _formatTime(secs) { - if (isNaN(secs) || !isFinite(secs)) return '0:00'; - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -function _updatePlayIcon() { - const a = _getAudio(); - const icon = document.getElementById('audio-play-icon'); - if (!icon) return; - icon.setAttribute('d', (a && !a.paused) ? 'M6 19h4V5H6v14zm8-14v14h4V5h-4z' : 'M8 5v14l11-7z'); -} - -window.toggleAudio = function () { - const a = _getAudio(); - if (!a) return; - if (!a.src || a.src === window.location.href) { - _openAudioPicker(); - return; - } - a.paused ? a.play() : a.pause(); -}; - -window.seekRelative = function (seconds) { - const a = _getAudio(); - if (!a || !a.duration) return; - a.currentTime = Math.max(0, Math.min(a.duration, a.currentTime + seconds)); -}; - -window.seekAudio = function (event) { - const a = _getAudio(); - const bar = document.getElementById('audio-progress-bar'); - if (!a || !a.duration || !bar) return; - const rect = bar.getBoundingClientRect(); - a.currentTime = ((event.clientX - rect.left) / rect.width) * a.duration; -}; - -function _openAudioPicker() { - let picker = document.getElementById('audio-file-picker'); - if (!picker) { - picker = document.createElement('input'); - picker.type = 'file'; - picker.accept = 'audio/*'; - picker.id = 'audio-file-picker'; - picker.style.display = 'none'; - document.body.appendChild(picker); - picker.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - const a = _getAudio(); - if (!a) return; - a.src = URL.createObjectURL(file); - a.load(); - a.play(); - }); - } - picker.click(); -} - -document.addEventListener('DOMContentLoaded', () => { - initScriptTextareaAutosize(); - - const a = _getAudio(); - if (!a) return; - - a.addEventListener('timeupdate', () => { - const pct = a.duration ? (a.currentTime / a.duration * 100) : 0; - const fill = document.getElementById('audio-progress-fill'); - const thumb = document.getElementById('audio-progress-thumb'); - const cur = document.getElementById('audio-current-time'); - if (fill) fill.style.width = pct + '%'; - if (thumb) thumb.style.left = pct + '%'; - if (cur) cur.textContent = _formatTime(a.currentTime); - }); - - a.addEventListener('durationchange', () => { - const total = document.getElementById('audio-total-time'); - if (total) total.textContent = _formatTime(a.duration); - }); - - a.addEventListener('play', _updatePlayIcon); - a.addEventListener('pause', _updatePlayIcon); - a.addEventListener('ended', _updatePlayIcon); - - // 드래그 앤 드롭으로 오디오 파일 로드 - const playerArea = document.querySelector('#preview-battle-chat .h-\\[100px\\]'); - if (playerArea) { - playerArea.addEventListener('dragover', e => e.preventDefault()); - playerArea.addEventListener('drop', e => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (!file || !file.type.startsWith('audio/')) return; - a.src = URL.createObjectURL(file); - a.load(); - a.play(); - }); - } -}); - -// 미리보기 화면 전환 -window.switchToChatView = function () { - document.getElementById('preview-battle-intro')?.classList.add('hidden'); - document.getElementById('preview-battle-chat')?.classList.remove('hidden'); - if (window.updateChatPreview) window.updateChatPreview(); -}; - -window.switchToIntroView = function () { - document.getElementById('preview-battle-chat')?.classList.add('hidden'); - document.getElementById('preview-battle-intro')?.classList.remove('hidden'); - const a = _getAudio(); - if (a) { - a.pause(); - a.currentTime = 0; - _updatePlayIcon(); - } -}; - -window.resetChatPreview = function () { - if (window.updateChatPreview) window.updateChatPreview(); - const a = _getAudio(); - if (a) { - a.pause(); - a.currentTime = 0; - _updatePlayIcon(); - } -}; diff --git a/src/main/resources/static/js/admin/chat/chat-preview.js b/src/main/resources/static/js/admin/chat/chat-preview.js deleted file mode 100644 index f8ff26f9..00000000 --- a/src/main/resources/static/js/admin/chat/chat-preview.js +++ /dev/null @@ -1,119 +0,0 @@ -window.updateChatPreview = function () { - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - chatContainer.innerHTML = ''; - - const nameA = document.getElementById('char-a-rep')?.value || 'A'; - const nameB = document.getElementById('char-b-rep')?.value || 'B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 섹션별 대사 렌더링 헬퍼 - const renderBlocks = (containerSelector, sectionTitle = null) => { - const blocks = document.querySelectorAll(`${containerSelector} .script-block`); - - if (blocks.length > 0 && sectionTitle) { - chatContainer.innerHTML += `
${sectionTitle}
`; - } - - blocks.forEach(block => { - // 화자 타입 가져오기 (A, B, NARRATOR) - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - - // [태그] 숨김 처리 및 줄바꿈 변환 - let text = ta.value.replace(/\[.*?\]/g, '').replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - - // 화자 타입에 따라 말풍선 생성 - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - }; - - // 1. 시작 노드 (오프닝) - renderBlocks('#start-node-container'); - - // 2. 분기점 처리 - const branchContainer = document.getElementById('branch-container'); - const branchChoiceUI = document.getElementById('preview-branch-choice'); - - if (branchContainer && !branchContainer.classList.contains('hidden')) { - if (branchChoiceUI) branchChoiceUI.classList.remove('hidden'); - - const labelA = document.getElementById('branch-a-label')?.value || 'A 선택지'; - const labelB = document.getElementById('branch-b-label')?.value || 'B 선택지'; - - if (document.getElementById('branch-btn-a')) document.getElementById('branch-btn-a').innerHTML = labelA.replace(/\n/g, '
'); - if (document.getElementById('branch-btn-b')) document.getElementById('branch-btn-b').innerHTML = labelB.replace(/\n/g, '
'); - - renderBlocks('#branch-a-node-container', 'OPTION A PATH'); - renderBlocks('#branch-b-node-container', 'OPTION B PATH'); - } else { - if (branchChoiceUI) branchChoiceUI.classList.add('hidden'); - } - - // 3. 클로징 노드 - renderBlocks('#closing-node-container'); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 말풍선 HTML 생성 헬퍼 -function _buildBubble(type, text, nameA, nameB, charAImg, charBImg) { - if (type === 'NARRATOR') { - return ` -
-
-

${text}

-
-
`; - } - - const isA = (type === 'A'); - const name = isA ? nameA : nameB; - const img = isA ? charAImg : charBImg; - const alignClass = isA ? '' : 'flex-row-reverse'; - const textAlign = isA ? '' : 'items-end'; - const namePadding = isA ? 'pl-1' : 'pr-1'; - const bubbleClass = isA - ? 'bg-white border-[#EBEBEB] text-gray-800 rounded-tl-none' - : 'bg-[#FDFBF9] border-[#EBE2D5] text-[#5A4A35] rounded-tr-none'; - - return ` -
-
-
-

${name}

-
- ${text} -
-
-
`; -} - -// 화면 전환 핸들러 -window.switchToChatView = function () { - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - if (intro && chat) { - intro.classList.add('hidden'); - chat.classList.remove('hidden'); - window.updateChatPreview(); - } -}; - -window.switchToIntroView = function () { - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - if (intro && chat) { - chat.classList.add('hidden'); - intro.classList.remove('hidden'); - } -}; - -window.resetChatPreview = function () { - window.updateChatPreview(); -}; \ No newline at end of file diff --git a/src/main/resources/static/js/admin/core.js b/src/main/resources/static/js/admin/core.js deleted file mode 100644 index 8b04a42e..00000000 --- a/src/main/resources/static/js/admin/core.js +++ /dev/null @@ -1,71 +0,0 @@ -const token = localStorage.getItem("adminToken"); -if (!token) { - alert("로그인이 필요합니다."); - window.location.replace("/api/v1/admin/login"); -} - -const urlParams = new URLSearchParams(window.location.search); - -window.PickeData = { - currentContentId: urlParams.get('id'), - currentTypeParam: (() => { - const type = (urlParams.get('type') || 'BATTLE').toUpperCase(); - return type === 'VOTE' ? 'POLL' : type; - })(), - isEditMode: !!urlParams.get('id'), - token, - currentContentType: 'BATTLE', - currentTargetDate: null, - currentStatus: null, - allTags: [], - selections: { - CATEGORY: [], - BATTLE_A_PHILOSOPHER: [], - BATTLE_A_VALUE: [], - BATTLE_B_PHILOSOPHER: [], - BATTLE_B_VALUE: [] - }, - currentTagTarget: 'CATEGORY', - tempSelections: [], - uploadedFiles: { thumbnail: null, charA: null, charB: null }, - editingTagId: null, - - API: { - TAGS: '/api/v1/tags', - TAG_CREATE: '/api/v1/admin/tags', - TAG_UPDATE: (id) => `/api/v1/admin/tags/${id}`, - TAG_DELETE: (id) => `/api/v1/admin/tags/${id}`, - BATTLE_CREATE: '/api/v1/admin/battles', - BATTLE_UPDATE: (id) => `/api/v1/admin/battles/${id}`, - BATTLE_GET: (id) => `/api/v1/admin/battles/${id}`, - QUIZ_CREATE: '/api/v1/admin/quizzes', - QUIZ_UPDATE: (id) => `/api/v1/admin/quizzes/${id}`, - QUIZ_GET: (id) => `/api/v1/admin/quizzes/${id}`, - POLL_CREATE: '/api/v1/admin/polls', - POLL_UPDATE: (id) => `/api/v1/admin/polls/${id}`, - POLL_GET: (id) => `/api/v1/admin/polls/${id}`, - FILE_UPLOAD: '/api/v1/files/upload', - FILE_UPLOAD_LOCAL: '/api/v1/files/upload/local' - }, - - getAuthHeaders: () => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${window.PickeData.token}` - }), - - setValue: (id, value) => { - const el = document.getElementById(id); - if (el && value != null) el.value = value; - }, - - setPreviewImage: (bgId, placeholderId, targetImgId, url) => { - const bg = document.getElementById(bgId); - if (bg) { - bg.style.backgroundImage = `url(${url})`; - bg.style.opacity = '1'; - } - document.getElementById(placeholderId)?.classList.add('hidden'); - const target = document.getElementById(targetImgId); - if (target) target.style.backgroundImage = `url(${url})`; - } -}; diff --git a/src/main/resources/static/js/admin/notice/notice.js b/src/main/resources/static/js/admin/notice/notice.js deleted file mode 100644 index 821eb370..00000000 --- a/src/main/resources/static/js/admin/notice/notice.js +++ /dev/null @@ -1,161 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const token = localStorage.getItem("adminToken"); - if (!token) { - alert("로그인이 필요합니다."); - window.location.replace("/api/v1/admin/login"); - return; - } - - const api = { - list: (page = 0, size = 20, category = "ALL") => { - const params = new URLSearchParams({ page: String(page), size: String(size) }); - if (category && category !== "ALL") { - params.set("category", category); - } - return `/api/v1/admin/notices?${params.toString()}`; - }, - create: "/api/v1/admin/notices" - }; - - const categoryLabelMap = { - ALL: "전체", - CONTENT: "콘텐츠", - NOTICE: "공지사항", - EVENT: "이벤트" - }; - - const tbody = document.getElementById("notice-list-tbody"); - const form = document.getElementById("notice-form"); - const refreshButton = document.getElementById("refresh-notice-list"); - const createCategoryButtons = Array.from(document.querySelectorAll(".create-category-btn")); - const filterCategoryButtons = Array.from(document.querySelectorAll(".filter-category-btn")); - - let currentCreateCategory = "NOTICE"; - let currentFilterCategory = "ALL"; - - const authHeaders = () => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }); - - const renderDate = (dateTime) => { - if (!dateTime) return "-"; - return new Date(dateTime).toLocaleString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }); - }; - - const renderCategory = (category) => categoryLabelMap[category] || category; - - const applyButtonState = (buttons, activeValue, dataKey) => { - buttons.forEach((button) => { - const value = button.dataset[dataKey]; - const isActive = value === activeValue; - button.classList.toggle("border-black", isActive); - button.classList.toggle("bg-black", isActive); - button.classList.toggle("text-white", isActive); - button.classList.toggle("border-gray-200", !isActive); - button.classList.toggle("bg-gray-50", !isActive); - button.classList.toggle("text-gray-600", !isActive); - }); - }; - - const renderRows = (items) => { - tbody.innerHTML = ""; - if (!items || items.length === 0) { - tbody.innerHTML = `등록된 공지가 없습니다.`; - return; - } - - items.forEach((item) => { - const tr = document.createElement("tr"); - tr.className = "hover:bg-gray-50"; - tr.innerHTML = ` - ${item.notificationId} - - - ${renderCategory(item.category)} - - - ${item.title || "-"} - ${renderDate(item.createdAt)} - `; - tbody.appendChild(tr); - }); - }; - - const loadNotices = async () => { - tbody.innerHTML = `불러오는 중...`; - try { - const res = await fetch(api.list(0, 20, currentFilterCategory), { headers: authHeaders() }); - if (res.status === 401 || res.status === 403) { - alert("세션이 만료되었습니다. 다시 로그인해 주세요."); - window.location.replace("/api/v1/admin/login"); - return; - } - if (!res.ok) throw new Error(`공지 목록 조회 실패 (HTTP ${res.status})`); - const json = await res.json(); - const data = json.result || json.data || {}; - renderRows(data.items || []); - } catch (e) { - console.error("공지 목록 조회 오류:", e); - tbody.innerHTML = `공지 목록 로드에 실패했습니다.`; - } - }; - - createCategoryButtons.forEach((button) => { - button.addEventListener("click", () => { - currentCreateCategory = button.dataset.createCategory || "NOTICE"; - applyButtonState(createCategoryButtons, currentCreateCategory, "createCategory"); - }); - }); - - filterCategoryButtons.forEach((button) => { - button.addEventListener("click", async () => { - currentFilterCategory = button.dataset.filterCategory || "ALL"; - applyButtonState(filterCategoryButtons, currentFilterCategory, "filterCategory"); - await loadNotices(); - }); - }); - - form?.addEventListener("submit", async (event) => { - event.preventDefault(); - - const title = document.getElementById("notice-title")?.value?.trim() || ""; - const body = document.getElementById("notice-body")?.value?.trim() || ""; - - if (!title || !body) { - alert("제목과 내용을 입력해 주세요."); - return; - } - - const payload = { category: currentCreateCategory, title, body }; - - try { - const res = await fetch(api.create, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(payload) - }); - if (!res.ok) throw new Error(`공지 저장 실패 (HTTP ${res.status})`); - - document.getElementById("notice-title").value = ""; - document.getElementById("notice-body").value = ""; - alert("공지사항이 등록되었습니다."); - await loadNotices(); - } catch (e) { - console.error("공지 저장 오류:", e); - alert("공지 저장에 실패했습니다."); - } - }); - - refreshButton?.addEventListener("click", loadNotices); - - applyButtonState(createCategoryButtons, currentCreateCategory, "createCategory"); - applyButtonState(filterCategoryButtons, currentFilterCategory, "filterCategory"); - loadNotices(); -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/tag/tags-controller.js b/src/main/resources/static/js/admin/tag/tags-controller.js deleted file mode 100644 index 2a6259c3..00000000 --- a/src/main/resources/static/js/admin/tag/tags-controller.js +++ /dev/null @@ -1,92 +0,0 @@ -window.fetchAllTags = async function () { - try { - const res = await fetch(PickeData.API.TAGS, { headers: PickeData.getAuthHeaders() }); - if (!res.ok) throw new Error(res.status); - - const json = await res.json(); - PickeData.allTags = json.result?.items ?? json.data?.items ?? []; - window.renderTagModalList(); - } catch (e) { - console.error('태그 목록 조회 실패:', e); - } -}; - -window.submitNewTag = async () => { - const nameInput = document.getElementById('tag-name-input'); - const typeSelect = document.getElementById('tag-type-select'); - if (!nameInput || !typeSelect) return; - - const name = nameInput.value.trim(); - const type = typeSelect.value; - - if (!name) { - alert('태그 이름을 입력해 주세요.'); - nameInput.focus(); - return; - } - - if (PickeData.editingTagId) { - try { - const res = await fetch(PickeData.API.TAG_UPDATE(PickeData.editingTagId), { - method: 'PATCH', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ name, type }) - }); - - if (res.ok) { - await window.fetchAllTags(); - window.refreshFormBadges(); - if (window.updatePreviewTags) window.updatePreviewTags(); - document.getElementById('tag-modal')?.classList.add('hidden'); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); - } - } catch (e) { - alert('태그 수정 중 오류가 발생했습니다.'); - } - return; - } - - try { - const res = await fetch(PickeData.API.TAG_CREATE, { - method: 'POST', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ name, type }) - }); - - if (res.ok) { - await window.fetchAllTags(); - document.getElementById('tag-modal')?.classList.add('hidden'); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); - nameInput.value = ''; - } - } catch (e) { - alert('태그 생성 중 오류가 발생했습니다.'); - } -}; - -window.deleteTag = async function (tagId) { - if (!confirm('태그를 삭제하시겠습니까? 사용 중인 태그는 삭제되지 않을 수 있습니다.')) return; - - try { - const res = await fetch(PickeData.API.TAG_DELETE(tagId), { - method: 'DELETE', - headers: PickeData.getAuthHeaders() - }); - - if (res.ok) { - Object.keys(PickeData.selections).forEach((target) => { - if (PickeData.selections[target].includes(tagId)) { - window.removeTag(target, tagId); - } - }); - PickeData.tempSelections = PickeData.tempSelections.filter((id) => id !== tagId); - await window.fetchAllTags(); - return; - } - - const err = await res.json(); - alert(err.message || '삭제할 수 없는 태그입니다.'); - } catch (e) { - console.error('태그 삭제 실패:', e); - } -}; diff --git a/src/main/resources/static/js/admin/tag/tags-ui.js b/src/main/resources/static/js/admin/tag/tags-ui.js deleted file mode 100644 index 53be1b52..00000000 --- a/src/main/resources/static/js/admin/tag/tags-ui.js +++ /dev/null @@ -1,177 +0,0 @@ -window.TagTargetConfig = { - CATEGORY: { - containerIds: ['basic-tags-container'], - allowedTypes: ['CATEGORY'], - preview: true - }, - BATTLE_A_PHILOSOPHER: { - containerIds: ['battle-a-philosopher-tags-container'], - allowedTypes: ['PHILOSOPHER'] - }, - BATTLE_A_VALUE: { - containerIds: ['battle-a-value-tags-container'], - allowedTypes: ['VALUE'] - }, - BATTLE_B_PHILOSOPHER: { - containerIds: ['battle-b-philosopher-tags-container'], - allowedTypes: ['PHILOSOPHER'] - }, - BATTLE_B_VALUE: { - containerIds: ['battle-b-value-tags-container'], - allowedTypes: ['VALUE'] - } -}; - -window.openTagSelectModal = (target) => { - if (!PickeData.selections[target]) return; - PickeData.currentTagTarget = target; - PickeData.tempSelections = [...PickeData.selections[target]]; - const searchInput = document.getElementById('tag-search-input'); - if (searchInput) searchInput.value = ''; - window.renderTagModalList(); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); -}; - -window.closeTagSelectModal = () => { - document.getElementById('tag-select-modal')?.classList.add('hidden'); -}; - -window.toggleTagSelection = (tagId) => { - PickeData.tempSelections = PickeData.tempSelections.includes(tagId) - ? PickeData.tempSelections.filter((id) => id !== tagId) - : [...PickeData.tempSelections, tagId]; - window.renderTagModalList(document.getElementById('tag-search-input')?.value || ''); -}; - -window.renderTagModalList = function (searchQuery = '') { - const container = document.getElementById('tag-list-container'); - if (!container) return; - - const currentConfig = window.TagTargetConfig[PickeData.currentTagTarget] || window.TagTargetConfig.CATEGORY; - const allowedTypes = currentConfig.allowedTypes || []; - - container.innerHTML = ''; - const filtered = PickeData.allTags.filter((tag) => { - const name = String(tag.name || ''); - const matchedName = name.includes(searchQuery); - const matchedType = allowedTypes.length === 0 || allowedTypes.includes(tag.type); - return matchedName && matchedType; - }); - - const groups = [ - { title: 'CATEGORY', type: 'CATEGORY' }, - { title: 'PHILOSOPHER', type: 'PHILOSOPHER' }, - { title: 'VALUE', type: 'VALUE' } - ]; - - groups - .filter((group) => allowedTypes.length === 0 || allowedTypes.includes(group.type)) - .forEach((group) => { - const tags = filtered.filter((tag) => tag.type === group.type); - if (!tags.length) return; - - let html = `

${group.title}

`; - tags.forEach((tag) => { - const tagId = tag.tagId || tag.id; - const selected = PickeData.tempSelections.includes(tagId); - html += `
- - -
`; - }); - container.innerHTML += `${html}
`; - }); -}; - -window.confirmTagSelection = () => { - const target = PickeData.currentTagTarget; - PickeData.selections[target] = [...PickeData.tempSelections]; - window.refreshFormBadges(target); - - if (window.TagTargetConfig[target]?.preview && window.updatePreviewTags) { - window.updatePreviewTags(); - } - - window.closeTagSelectModal(); -}; - -window.refreshFormBadges = (specificTarget = null) => { - const targets = specificTarget ? [specificTarget] : Object.keys(window.TagTargetConfig); - - targets.forEach((target) => { - const config = window.TagTargetConfig[target]; - if (!config) return; - - config.containerIds.forEach((containerId) => { - const container = document.getElementById(containerId); - if (!container) return; - - container.querySelectorAll('.tag-badge').forEach((el) => el.remove()); - - (PickeData.selections[target] || []).forEach((tagId) => { - const tag = PickeData.allTags.find((item) => (item.tagId || item.id) === tagId); - if (!tag) return; - - const badge = document.createElement('div'); - badge.className = 'tag-badge group relative inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-600 border border-gray-200 rounded-full text-[10px] font-bold mr-2 mb-2 transition-all hover:bg-gray-200'; - badge.innerHTML = `#${tag.name}×`; - container.insertBefore(badge, container.lastElementChild); - }); - }); - }); -}; - -window.removeTag = function (target, tagId) { - if (!PickeData.selections[target]) return; - - PickeData.selections[target] = PickeData.selections[target].filter((id) => id !== tagId); - window.refreshFormBadges(target); - - if (window.TagTargetConfig[target]?.preview && window.updatePreviewTags) { - window.updatePreviewTags(); - } -}; - -window.openTagCreateModal = () => { - PickeData.editingTagId = null; - document.querySelector('#tag-modal h2').innerText = '새 태그 생성'; - document.getElementById('tag-name-input').value = ''; - document.getElementById('tag-type-select').value = 'CATEGORY'; - document.getElementById('tag-select-modal')?.classList.add('hidden'); - document.getElementById('tag-modal')?.classList.remove('hidden'); -}; - -window.updateTagName = function (tagId) { - const tag = PickeData.allTags.find((item) => item.tagId === tagId || item.id === tagId); - if (!tag) return; - - PickeData.editingTagId = tagId; - document.querySelector('#tag-modal h2').innerText = '태그 수정'; - document.getElementById('tag-name-input').value = tag.name; - document.getElementById('tag-type-select').value = tag.type; - document.getElementById('tag-select-modal')?.classList.add('hidden'); - document.getElementById('tag-modal')?.classList.remove('hidden'); -}; - -window.updatePreviewTags = function () { - const box = document.getElementById('preview-tags'); - if (!box) return; - - box.innerHTML = (PickeData.selections.CATEGORY || []) - .map((tagId) => { - const tag = PickeData.allTags.find((item) => (item.tagId || item.id) === tagId); - return tag - ? `#${tag.name}` - : ''; - }) - .join(''); -}; - -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('tag-search-input')?.addEventListener('input', (e) => { - window.renderTagModalList(e.target.value.trim()); - }); -}); diff --git a/src/main/resources/static/js/admin/ui/ui-interaction.js b/src/main/resources/static/js/admin/ui/ui-interaction.js deleted file mode 100644 index 9846ee9e..00000000 --- a/src/main/resources/static/js/admin/ui/ui-interaction.js +++ /dev/null @@ -1,150 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - - // 섹션 클릭 시 인트로 ↔ 채팅 화면 자동 전환 - document.addEventListener('focusin', (e) => { - // 배틀 타입일 때만 작동 - if (PickeData.currentContentType !== 'BATTLE') return; - - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - const statusBar = document.getElementById('status-bar'); - - if (!intro || !chat) return; - - // 1. 기본정보나 인물 정보 섹션을 클릭하면 -> 인트로 미리보기 - if (e.target.closest('#section-basic') || e.target.closest('#section-chars')) { - intro.classList.remove('hidden'); - chat.classList.add('hidden'); - // 상태바 글자색 흰색으로 (배경이 어두우므로) - if (statusBar) { - statusBar.classList.remove('text-black'); - statusBar.classList.add('text-white'); - } - } - // 2. 대본(시나리오) 섹션을 클릭하면 -> 채팅 미리보기 - else if (e.target.closest('#section-script')) { - intro.classList.add('hidden'); - chat.classList.remove('hidden'); - // 상태바 글자색 검정색으로 (배경이 밝으므로) - if (statusBar) { - statusBar.classList.remove('text-white'); - statusBar.classList.add('text-black'); - } - // 채팅 내용 최신화 - if (window.updateChatPreview) window.updateChatPreview(); - } - }); - - // 상단 타입 토글 (배틀 / 퀴즈 / 투표 탭 전환) - const toggleBtns = document.querySelectorAll('.type-toggle'); - toggleBtns.forEach(btn => { - btn.addEventListener('click', (e) => { - const target = e.currentTarget; - const targetId = target.dataset.target; - - toggleBtns.forEach(b => { - b.classList.remove('active', 'bg-white', 'text-black', 'shadow-sm'); - b.classList.add('text-gray-500'); - }); - target.classList.remove('text-gray-500'); - target.classList.add('active', 'bg-white', 'text-black', 'shadow-sm'); - - document.querySelectorAll('.content-form').forEach(form => form.classList.add('hidden')); - document.getElementById(targetId)?.classList.remove('hidden'); - - document.querySelectorAll('[id^="preview-wrapper-"]').forEach(pw => pw.classList.add('hidden')); - const typeKey = targetId.replace('form-', ''); - document.getElementById(`preview-wrapper-${typeKey}`)?.classList.remove('hidden'); - - const contentTypeByForm = { battle: 'BATTLE', quiz: 'QUIZ', vote: 'POLL' }; - PickeData.currentContentType = contentTypeByForm[typeKey] || typeKey.toUpperCase(); - - // Update status bar type indicator - const statusType = document.getElementById('status-type'); - if (statusType) { - statusType.textContent = PickeData.currentContentType; - } - - // Show BRANCH MODE only for battle - const branchMode = document.getElementById('branch-mode-indicator'); - if (branchMode) { - branchMode.classList.toggle('hidden', typeKey !== 'battle'); - } - }); - }); - - // 배틀 미리보기 카드 선택 인터랙션 - const cardA = document.getElementById('pv-battle-card-a'); - const cardB = document.getElementById('pv-battle-card-b'); - - const resetBattleSelection = () => { - [cardA, cardB].forEach(c => { - if (!c) return; - c.classList.replace('border-[#7C4A3A]', 'border-[#E5E0D8]'); - c.classList.remove('bg-orange-50'); - }); - } - - cardA?.addEventListener('click', () => { - resetBattleSelection(); - cardA.classList.replace('border-[#E5E0D8]', 'border-[#7C4A3A]'); - cardA.classList.add('bg-orange-50'); - }); - - cardB?.addEventListener('click', () => { - resetBattleSelection(); - cardB.classList.replace('border-[#E5E0D8]', 'border-[#7C4A3A]'); - cardB.classList.add('bg-orange-50'); - }); - - // 시계 업데이트 - const _updateClock = () => { - const el = document.getElementById('status-time'); - if (el) { - const now = new Date(); - el.textContent = `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`; - } - }; - _updateClock(); - setInterval(_updateClock, 30000); - - // Initialize status type - const statusType = document.getElementById('status-type'); - if (statusType) { - statusType.textContent = PickeData.currentContentType || 'BATTLE'; - } - - // Initialize BRANCH MODE visibility (show for battle) - const branchMode = document.getElementById('branch-mode-indicator'); - if (branchMode) { - branchMode.classList.toggle('hidden', (PickeData.currentContentType || 'BATTLE') !== 'BATTLE'); - } - - // Dirty Flag: 스크립트 블록 수정 추적 (이벤트 위임) - document.getElementById('section-script')?.addEventListener('input', (e) => { - if (e.target.classList.contains('script-text')) { - const scriptBlock = e.target.closest('.script-block'); - if (scriptBlock) { - const modFlag = scriptBlock.querySelector('.mod-flag'); - if (modFlag) { - modFlag.value = 'true'; - } - // 시각적 피드백: 테두리 색상 변경 - scriptBlock.classList.add('border-blue-300'); - } - } - }); -}); - -// 전역 투표 옵션 선택 함수 -window.selectVoteOption = function (btn) { - document.querySelectorAll('.vote-option-btn').forEach(b => { - b.classList.remove('border-[#D4B886]', 'bg-[#FDFBF7]'); - b.classList.add('border-[#EBE2D5]', 'bg-[#FAFAFA]'); - }); - btn.classList.add('border-[#D4B886]', 'bg-[#FDFBF7]'); - const optId = btn.getAttribute('data-opt-id'); - const optText = document.getElementById(`pv-vote-opt${optId}`)?.textContent || ''; - const blank = document.getElementById('pv-vote-blank'); - if (blank) blank.textContent = optText || '?'; -}; \ No newline at end of file diff --git a/src/main/resources/static/js/admin/ui/ui-sync.js b/src/main/resources/static/js/admin/ui/ui-sync.js deleted file mode 100644 index f2bd2509..00000000 --- a/src/main/resources/static/js/admin/ui/ui-sync.js +++ /dev/null @@ -1,49 +0,0 @@ -document.addEventListener('input', (e) => { - const id = e.target.id; - const val = e.target.value; - const fv = val.replace(/\n/g, '
'); - - if (e.target.tagName.toLowerCase() === 'textarea') { - e.target.style.height = 'auto'; - e.target.style.height = `${e.target.scrollHeight}px`; - } - - const set = (elId, content, html = false) => { - const el = document.getElementById(elId); - if (!el) return; - if (html) el.innerHTML = content; - else el.innerText = content; - }; - - if (id === 'content-title') { - set('preview-title-intro', fv || '제목', true); - set('preview-title-chat', val || '설명'); - } - if (id === 'content-desc') set('preview-desc', fv || '설명', true); - - if (id === 'char-a-title') set('preview-char-a-title', val || '주장'); - if (id === 'char-a-rep') set('preview-char-a-rep', val || '철학자'); - if (id === 'char-a-stance') set('preview-char-a-stance', fv, true); - - if (id === 'char-b-title') set('preview-char-b-title', val || '주장'); - if (id === 'char-b-rep') set('preview-char-b-rep', val || '철학자'); - if (id === 'char-b-stance') set('preview-char-b-stance', fv, true); - - if (id === 'quiz-title') set('pv-quiz-q', fv || '퀴즈 제목', true); - if (id === 'quiz-option-a-title') set('pv-quiz-o-text', val || '참여문학'); - if (id === 'quiz-option-a-detail') set('pv-quiz-o-desc', val || '참여문학은 좋습니다.'); - if (id === 'quiz-option-b-title') set('pv-quiz-x-text', val || '순수문학'); - if (id === 'quiz-option-b-detail') set('pv-quiz-x-desc', val || '순수문학은 좋습니다.'); - - if (id === 'poll-title-prefix') set('pv-vote-prefix', val || '나에게 예술이란'); - if (id === 'poll-title-suffix') set('pv-vote-suffix', val || ' 하는 행위이다.'); - if (id.startsWith('poll-option-') && id.endsWith('-title')) { - const num = id.split('-')[2]; - set(`pv-vote-opt${num}`, val || `Option ${num}`); - set(`pv-bar-label-${num}`, val || `Option ${num}`); - } - - if (id === 'branch-a-label' || id === 'branch-b-label' || id === 'char-a-rep' || id === 'char-b-rep') { - if (window.updateChatPreview) window.updateChatPreview(); - } -}); \ No newline at end of file diff --git a/src/main/resources/templates/admin/admin-login.html b/src/main/resources/templates/admin/admin-login.html deleted file mode 100644 index 60994273..00000000 --- a/src/main/resources/templates/admin/admin-login.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - Pické Admin - 로그인 - - - - - - - - - -
-
-

당신의 생각을

-

Pické

-
- - -
- - - - \ No newline at end of file diff --git a/src/main/resources/templates/admin/admin-notice.html b/src/main/resources/templates/admin/admin-notice.html deleted file mode 100644 index 3399da0a..00000000 --- a/src/main/resources/templates/admin/admin-notice.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Picke Admin - 공지사항 - - - - - - -
- -
-
-

공지사항 작성

-

저장하면 사용자 알림으로 노출됩니다.

- -
-
- -
- - - -
-
- -
- - -
- -
- - -
- - -
-
- -
-
-

최근 공지

- -
- -
-
- - - - -
-
- -
- - - - - - - - - - - - -
ID카테고리제목작성일
불러오는 중...
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/resources/templates/admin/components/form-battle.html b/src/main/resources/templates/admin/components/form-battle.html deleted file mode 100644 index e69cc775..00000000 --- a/src/main/resources/templates/admin/components/form-battle.html +++ /dev/null @@ -1,192 +0,0 @@ -
- -
-
-

1 기본 정보

- BATTLE -
- -
-
- - -
- -
- -
- -
-
- -
- - -
- -
- - -
- -
- - - -
- -
- - -
- -
- - -
-
-
- -
-
-

2 배틀 선택지

- OPTIONS -
- -
- - -
-

선택지 A

- - - - - - - -
- -
- -
- -
-
- -
-

선택지 B

- - - - - - - - -
- -
- -
- -
-
-
-
- -
-
-

3 시나리오 대본

- SCRIPT -
- -
-

TTS 목소리 설정

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
- -
-

START

- -
- -
-
- -
- - -
- - - -
-
- -
-

CLOSING

- -
- -
-
-
-
diff --git a/src/main/resources/templates/admin/components/form-quiz.html b/src/main/resources/templates/admin/components/form-quiz.html deleted file mode 100644 index 26b299de..00000000 --- a/src/main/resources/templates/admin/components/form-quiz.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
-
-

1 퀴즈 등록

- QUIZ -
- -
-
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- -
- - -
-
- - -
-
- - -
-
- -
- -
- - -
-
- - -
-
- - -
-
-
-
-
-
-
- diff --git a/src/main/resources/templates/admin/components/form-vote.html b/src/main/resources/templates/admin/components/form-vote.html deleted file mode 100644 index 26d3bbe2..00000000 --- a/src/main/resources/templates/admin/components/form-vote.html +++ /dev/null @@ -1,62 +0,0 @@ -
-
-
-

1 투표 등록

- POLL -
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
-
-
-
-
-
- diff --git a/src/main/resources/templates/admin/fragments/basic-info.html b/src/main/resources/templates/admin/fragments/basic-info.html deleted file mode 100644 index 88e2691d..00000000 --- a/src/main/resources/templates/admin/fragments/basic-info.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
-

1 기본정보

- BASIC INFO -
-
-
- - -
-
-
\ No newline at end of file diff --git a/src/main/resources/templates/admin/fragments/header.html b/src/main/resources/templates/admin/fragments/header.html deleted file mode 100644 index 1a59f4c5..00000000 --- a/src/main/resources/templates/admin/fragments/header.html +++ /dev/null @@ -1,16 +0,0 @@ -
-
-
- Picke - Admin -
- -
- -
- ADMIN -
-
diff --git a/src/main/resources/templates/admin/fragments/preview.html b/src/main/resources/templates/admin/fragments/preview.html deleted file mode 100644 index 58c99276..00000000 --- a/src/main/resources/templates/admin/fragments/preview.html +++ /dev/null @@ -1,202 +0,0 @@ -
- -
- 실시간 미리보기 - -
- -
-
- -
- 9:41 -
- - - -
-
- -
- -
-
-
- -
-
- - -
- -
-
-

제목을 입력해주세요

-

콘텐츠에 대한 배경 설명 또는 힌트가 이곳에 표시됩니다.

- -
-
-
-

주장

-

철학자

-
- -
VS
- -
-
-

주장

-

철학자

-
-
- - -
-
-
- - -
- - - - - -
-
-
\ No newline at end of file diff --git a/src/main/resources/templates/admin/picke-create.html b/src/main/resources/templates/admin/picke-create.html deleted file mode 100644 index 1795ff9a..00000000 --- a/src/main/resources/templates/admin/picke-create.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - Pické Admin - 콘텐츠 등록 - - - - - - - -
- -
- -
-
- -
- - - -
- -
-
-
- - - - - -
-
- -
-
-
- -
- -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/templates/admin/picke-list.html b/src/main/resources/templates/admin/picke-list.html deleted file mode 100644 index 5da6ee50..00000000 --- a/src/main/resources/templates/admin/picke-list.html +++ /dev/null @@ -1,419 +0,0 @@ - - - - - - Picke Admin - 콘텐츠 관리 - - - - - - - -
- -
-
-
-

콘텐츠 관리

-

배틀, 퀴즈, 투표 콘텐츠를 확인하고 수정할 수 있습니다.

-
- -
- -
- - - - -
- -
- - - - - -
- -
- - - - - - - - - - - - - - - - - -
ID유형제목상태예약 발행생성일관리
-
-
- 데이터를 불러오는 중입니다... -
-
-
- -
-
- - - - diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java index 6ef1d455..a51cf36c 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java @@ -99,7 +99,7 @@ void createBattle_persistsAllMappedFields() throws Exception { LocalDate targetDate = LocalDate.now(); Tag category = createTag("battle-category", TagType.CATEGORY); - Tag philosopher = createTag("battle-philosopher", TagType.PHILOSOPHER); + Tag philosopher = createTag("소크라테스", TagType.PHILOSOPHER); Tag value = createTag("battle-value", TagType.VALUE); Map payload = Map.of( @@ -395,6 +395,9 @@ private User createAdminUser() { } private Tag createTag(String prefix, TagType type) { + if (type == TagType.PHILOSOPHER) { + return tagRepository.save(Tag.builder().name(prefix).type(type).build()); + } String normalizedPrefix = prefix.length() > 10 ? prefix.substring(0, 10) : prefix; return tagRepository.save( Tag.builder() diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java index d06b4681..17e314a3 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java @@ -26,9 +26,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItem; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -98,21 +96,14 @@ void admin_can_create_and_list_notices() throws Exception { } @Test - @DisplayName("admin notice page form flow persists notice and user can fetch it") - void admin_notice_page_form_flow_persists_notice_and_user_can_fetch_it() throws Exception { + @DisplayName("admin notice creation flow persists notice and user can fetch it") + void admin_notice_creation_flow_persists_notice_and_user_can_fetch_it() throws Exception { String adminToken = createAdminToken(); String userToken = createUserToken(); String marker = UUID.randomUUID().toString().substring(0, 8); String title = "ui-notice-" + marker; String body = "ui-body-" + marker; - mockMvc.perform(get("/api/v1/admin/picke/notice")) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("id=\"notice-form\""))) - .andExpect(content().string(containsString("id=\"notice-title\""))) - .andExpect(content().string(containsString("id=\"notice-body\""))) - .andExpect(content().string(containsString("/js/admin/notice/notice.js"))); - Map payload = Map.of( "category", "NOTICE", "title", title, diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java new file mode 100644 index 00000000..cf7d7d77 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java @@ -0,0 +1,119 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminNotificationTestPushIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserDeviceRepository userDeviceRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @MockitoBean + private FcmPushService fcmPushService; + + @MockitoBean + private ApnsPushService apnsPushService; + + @Test + @DisplayName("admin can send a test push to a specific user's registered device") + void admin_can_send_test_push_to_user_device() throws Exception { + String adminToken = createAdminToken(); + + User targetUser = userRepository.save( + User.builder() + .userTag("target-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("target") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build() + ); + + UserDevice device = userDeviceRepository.save( + UserDevice.builder() + .user(targetUser) + .fcmToken("test-token-" + UUID.randomUUID()) + .platform(DevicePlatform.ANDROID) + .build() + ); + + Map payload = Map.of( + "userId", targetUser.getId(), + "title", "테스트 알림", + "body", "테스트 발송 본문" + ); + + mockMvc.perform(post("/api/v1/admin/notices/test") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + ArgumentCaptor deviceCaptor = ArgumentCaptor.forClass(UserDevice.class); + verify(fcmPushService).send(deviceCaptor.capture(), eq("테스트 알림"), eq("테스트 발송 본문"), any()); + assertThat(deviceCaptor.getValue().getId()).isEqualTo(device.getId()); + } + + private String createAdminToken() { + User admin = userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("adm") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + return jwtProvider.createAccessToken(admin.getId(), UserRole.ADMIN.name()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java index 53b664e7..da0ef1f0 100644 --- a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java @@ -60,8 +60,8 @@ void propose_Success() { // then // 제안 저장 메서드가 호출되었는지 확인 verify(battleProposalRepository, times(1)).save(any()); - // 크레딧 차감(-30) 로직이 호출되었는지 확인 - verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-30), any()); + // 크레딧 차감(-100) 로직이 호출되었는지 확인 + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-100), any()); } @Test diff --git a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java index 34628809..a4460e89 100644 --- a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java @@ -12,18 +12,22 @@ import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.domain.notification.service.NotificationService; +import java.time.Clock; import java.time.LocalTime; +import java.time.ZoneId; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class NotificationScheduleDispatcherTest { + private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); + @Mock private NotificationScheduleRepository notificationScheduleRepository; @@ -33,13 +37,21 @@ class NotificationScheduleDispatcherTest { @Mock private NotificationDispatchService notificationDispatchService; - @InjectMocks private NotificationScheduleDispatcher notificationScheduleDispatcher; + @BeforeEach + void setUp() { + Clock fixedClock = Clock.fixed( + LocalTime.of(9, 0).atDate(java.time.LocalDate.now(SEOUL_ZONE)).atZone(SEOUL_ZONE).toInstant(), + SEOUL_ZONE); + notificationScheduleDispatcher = new NotificationScheduleDispatcher( + notificationScheduleRepository, notificationService, notificationDispatchService, fixedClock); + } + @Test @DisplayName("현재 시각과 일치하는 활성 예약만 발송하고 발송일을 기록한다") void dispatchDueSchedules_sendsOnlyMatchingEnabledSchedules() { - LocalTime now = LocalTime.now(); + LocalTime now = LocalTime.of(9, 0); NotificationSchedule due = NotificationSchedule.builder() .title("오늘의 질문") .subtitle("지금 확인해보세요") diff --git a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java index bd207d58..db84c0db 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -41,6 +41,12 @@ class CreditServiceTest { @Mock private UserService userService; + @Mock + private com.swyp.picke.domain.notification.service.NotificationService notificationService; + + @Mock + private com.swyp.picke.domain.battle.repository.BattleRepository battleRepository; + @InjectMocks private CreditService creditService; diff --git a/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java b/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java new file mode 100644 index 00000000..3b0f8188 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java @@ -0,0 +1,59 @@ +package com.swyp.picke.global.infra.fcm.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FcmPushServiceTest { + + @Mock + private FirebaseMessaging firebaseMessaging; + + @Test + void send_includes_title_and_body_in_data_payload() throws Exception { + FcmPushService fcmPushService = new FcmPushService(firebaseMessaging); + UserDevice device = buildDevice(); + + fcmPushService.send(device, "제목", "본문", Map.of("type", "TEST")); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(firebaseMessaging).send(messageCaptor.capture()); + + Map data = extractData(messageCaptor.getValue()); + assertThat(data).containsEntry("title", "제목"); + assertThat(data).containsEntry("body", "본문"); + assertThat(data).containsEntry("type", "TEST"); + } + + @SuppressWarnings("unchecked") + private Map extractData(Message message) throws Exception { + Field dataField = Message.class.getDeclaredField("data"); + dataField.setAccessible(true); + return (Map) dataField.get(message); + } + + private UserDevice buildDevice() throws Exception { + UserDevice device = UserDevice.builder() + .fcmToken("token") + .platform(DevicePlatform.ANDROID) + .build(); + Field idField = device.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(device, 1L); + return device; + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8a951d1f..437dfdd2 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -25,6 +25,17 @@ spring: credentials: location: file:/tmp/dummy.json +firebase: + credentials: + location: src/test/resources/firebase-test-credentials.json + +admob: + app-id: dummy + reward: + unit-id: + ios: dummy + android: dummy + oauth: kakao: client-id: dummy @@ -32,6 +43,11 @@ oauth: google: client-id: dummy client-secret: dummy + apple: + team-id: dummy + client-id: dummy + key-id: dummy + private-key: dummy openai: api-key: dummy diff --git a/src/test/resources/firebase-test-credentials.json b/src/test/resources/firebase-test-credentials.json new file mode 100644 index 00000000..7400b1ba --- /dev/null +++ b/src/test/resources/firebase-test-credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "picke-test-project", + "private_key_id": "dummykeyid1234567890", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDC8cIyqMc1Aey1\n9QVQ4QmSiWpiOPy4pFN09XmprAuZZDWbFaKD724NhbXgdtcEHNewYAWWpN3FsyBh\nh9YZPHXHa0dfdSsEKaKDxrIv/7YX8M/kRbDMSH35yDZZTIoxZnJLNZm+Zs2sB7L0\nYtzbT1ctnHV2r+zlVC29S2OV7/LY5YRbMM7fhvwu+ww+Ri6GY3Nx5dhvi/yzgeT8\njLw4hGx9uSMBZnNmpxp+hGL/wo2LSD4FQ0PrAqJTkLu90tky51XFmUGVmGqnWpRE\nuLw4C83x1xIyNayU5pR9oDTdLh+CGkVb8QOuJS95tLjqo3hx4CiQICXA+Ul8282R\nqIyrvFFbAgMBAAECggEAFLGvSMtr4i+jHim1d8F7z6dwuJ6ODVe8WEUati1CSfU+\nT4k7aEAJcbwI85wJ9TDOoLWAwl4cALmkLVZLHwCxDAtSV0rL1zRIQS7diYTeoqn4\nl6XiP71OSi67vj0GynmyllNJT9H/8Uwb7h90jH9epMPgIEpKnomSFW8kUi1XnTiI\nPCJJBzodYbSLFLpApum22E76Zjj4CDbKUnRZVYF/0GxesXhGCVlpgVi7XsuWLKvd\n7ScH9d2VxEkrvlj71Mwt2kV7791oCWIiPD/iy1i6WE3/H1YJj6LnGWPZUppvvXsH\nPfJbZNVyXMFrGJP9Ql+bAT1C0zo1z6Mudp7V8Ai3iQKBgQD/InZT5sGnLJasSEH1\njfhiGX4yyQ7Uw5Eikx6I2QQGzwxNBAu5wII8O+TryNQPu2lsBzIcvyL5LNHyNfPn\nQwTk7DWiWxZi18GBJen7IoVVDGL+bubrKSAGroNYVpsYJvJDZxV2wpXHig3wUXmP\n4Gz/FDKFHh7GAFvD0Jk+HQfpvQKBgQDDmwg6OeWnlujRGm8IGOWcu7pOEKLTxlQ9\ndB0kxRGWlslsWZtcFj/YmjExr0w3OZaHLrb+vBmFGDZqK19AUt6Nm7RGPUq6P+XG\nT1bK+Vsi3NSLYRbdLaT3s42n/8Hb+ZPx4HyQTGq/nfgciJCdvtzncDeDsgNIpxyk\n+SDNSu+89wKBgBZmdjEjn3kIByqVJYVjs50ZU+UtlenESefZNuMY+quGXjQc2NK0\nPjr/nze8aDIBaF4du56egXmTH9O+PO3fCnz26Daa/Los60Zlh8eO3ln7Pm3MWuXm\ntHMhu1J0OCXEtZyJXm8Q4omka1jgLmYddDRpF45seJM10Ni+ZdX4QouZAoGAGgCZ\n72OS69xbxrBE4kas/1DVS1taydwrhp/Q3/pyhBo3XHfs9yjeA+U7dOdgslatc/r5\nyJMosVCuqx5o4xwhCaIRLOUo8elcmigh2YmcW94PQxf8+hn/PA5aXmLZWmyrBhRZ\nerUt25scSG6/Crk8lGeOeatIVHgijquveJrlk7ECgYEAxQeYN0uzLBneootOeqLs\nGQ7ubWqJpgm0IeZST9rUA2OBjk4AwHBEC3tyayH4XEbTG259dIUYCe6xp3pjbpyH\nl/d7pFsau+D9yC72yeN+g2bZUKt7yUfmLIUDODXNF+WH9a+xFFDgL7r/zHNI0NDc\niXuwmsuc6msGwp7959s8nD0=\n-----END PRIVATE KEY-----\n", + "client_email": "test-fcm@picke-test-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-fcm%40picke-test-project.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} \ No newline at end of file