From 6b465cc31710f9ca2b48d58b5525f92e75b124e4 Mon Sep 17 00:00:00 2001 From: jhan0121 Date: Tue, 3 Feb 2026 20:24:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/popup.css | 179 ++++++++++++++++++++++ public/popup.html | 42 ++++++ src/__tests__/api.test.js | 133 ++++++++++++++++- src/api.js | 64 +++++++- src/constants.js | 29 +++- src/duration-utils.js | 102 +++++++++++++ src/handlers/auth.js | 91 ++++++++++++ src/handlers/cycle.js | 188 ++++++++++++++++++++++++ src/{handlers.js => handlers/device.js} | 130 ++-------------- src/handlers/index.js | 26 ++++ src/handlers/url.js | 51 +++++++ src/popup.js | 35 ++++- src/ui.js | 153 ------------------- src/ui/cycle.js | 115 +++++++++++++++ src/ui/elements.js | 95 ++++++++++++ src/ui/error-handler.js | 28 ++++ src/ui/index.js | 29 ++++ src/ui/messages.js | 37 +++++ src/ui/modal.js | 120 +++++++++++++++ src/ui/views.js | 52 +++++++ 20 files changed, 1416 insertions(+), 283 deletions(-) create mode 100644 src/duration-utils.js create mode 100644 src/handlers/auth.js create mode 100644 src/handlers/cycle.js rename src/{handlers.js => handlers/device.js} (51%) create mode 100644 src/handlers/index.js create mode 100644 src/handlers/url.js delete mode 100644 src/ui.js create mode 100644 src/ui/cycle.js create mode 100644 src/ui/elements.js create mode 100644 src/ui/error-handler.js create mode 100644 src/ui/index.js create mode 100644 src/ui/messages.js create mode 100644 src/ui/modal.js create mode 100644 src/ui/views.js diff --git a/public/popup.css b/public/popup.css index 986e745..57a2253 100644 --- a/public/popup.css +++ b/public/popup.css @@ -331,3 +331,182 @@ body { .confirm-buttons .btn { flex: 1; } + +/* 주기 선택 드롭다운 */ +.cycle-select { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + background-color: #fff; + cursor: pointer; + transition: border-color 0.2s; +} + +.cycle-select:focus { + outline: none; + border-color: #3498db; +} + +/* 주기 목록 */ +.cycle-list { + list-style: none; + margin-top: 12px; + margin-bottom: 12px; +} + +.cycle-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + margin-bottom: 8px; + background-color: #f8f9fa; + border-radius: 6px; +} + +.cycle-item-empty { + padding: 12px; + text-align: center; + color: #95a5a6; + font-size: 12px; +} + +.cycle-info { + flex: 1; + min-width: 0; +} + +.cycle-title { + font-weight: 500; + font-size: 13px; + margin-bottom: 2px; +} + +.cycle-durations { + font-size: 11px; + color: #7f8c8d; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cycle-actions { + display: flex; + gap: 4px; + margin-left: 8px; +} + +.btn-small { + padding: 6px 10px; + font-size: 12px; + width: auto; +} + +/* 모달 */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.modal-content { + background: #fff; + padding: 20px; + border-radius: 8px; + width: 290px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-content h3 { + margin-bottom: 16px; + font-size: 16px; + color: #2c3e50; +} + +/* Duration 입력 그룹 */ +.duration-input-group { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.duration-value { + width: 60px; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + text-align: center; +} + +.duration-value:focus { + outline: none; + border-color: #3498db; +} + +.duration-unit { + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background-color: #fff; + cursor: pointer; +} + +.duration-unit:focus { + outline: none; + border-color: #3498db; +} + +.btn-remove-duration { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 4px; + background-color: #e74c3c; + color: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-remove-duration:hover { + background-color: #c0392b; +} + +/* 폼 버튼 영역 */ +.form-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.form-actions .btn { + flex: 1; +} + +/* 주기 섹션 */ +#cycle-section { + margin-top: 12px; + margin-bottom: 12px; +} + +#cycle-section h3 { + font-size: 14px; + margin-bottom: 8px; + color: #2c3e50; +} diff --git a/public/popup.html b/public/popup.html index 60f7628..ad77867 100644 --- a/public/popup.html +++ b/public/popup.html @@ -38,6 +38,14 @@

Recycle Study

+ +
+ + +
+ @@ -52,6 +60,16 @@

Recycle Study


+ + + + + + + + + diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js index 7ba2ebe..b9f6e55 100644 --- a/src/__tests__/api.test.js +++ b/src/__tests__/api.test.js @@ -1,5 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { registerDevice, getDevices, deleteDevice, saveReviewUrl } from '../api.js'; +import { + registerDevice, + getDevices, + deleteDevice, + saveReviewUrl, + getCycleOptions, + createCustomCycle, + updateCustomCycle, + deleteCustomCycle +} from '../api.js'; import { ERROR_CODES } from '../constants.js'; // Mock chrome.runtime.sendMessage @@ -79,13 +88,14 @@ describe('api.js', () => { }); describe('saveReviewUrl', () => { - it('식별자를 헤더에 포함하여 URL 저장 요청을 보낸다', async () => { + it('식별자와 주기를 포함하여 URL 저장 요청을 보낸다', async () => { const identifier = 'my-device-id'; const targetUrl = 'https://example.com'; - const mockResponse = { success: true, data: { url: targetUrl } }; + const cycle = { type: 'DEFAULT', code: 'EBBINGHAUS' }; + const mockResponse = { success: true, data: { url: targetUrl, scheduledAts: [] } }; sendMessageMock.mockResolvedValue(mockResponse); - await saveReviewUrl(identifier, targetUrl); + await saveReviewUrl(identifier, targetUrl, cycle); expect(sendMessageMock).toHaveBeenCalledWith({ type: 'API_REQUEST', @@ -93,7 +103,120 @@ describe('api.js', () => { endpoint: '/api/v1/reviews', method: 'POST', headers: { 'X-Device-Id': identifier }, - body: { targetUrl } + body: { targetUrl, cycle } + } + }); + }); + + it('커스텀 주기로 URL 저장 요청을 보낸다', async () => { + const identifier = 'my-device-id'; + const targetUrl = 'https://example.com'; + const cycle = { type: 'CUSTOM', id: 1 }; + const mockResponse = { success: true, data: { url: targetUrl, scheduledAts: [] } }; + sendMessageMock.mockResolvedValue(mockResponse); + + await saveReviewUrl(identifier, targetUrl, cycle); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/reviews', + method: 'POST', + headers: { 'X-Device-Id': identifier }, + body: { targetUrl, cycle } + } + }); + }); + }); + + describe('getCycleOptions', () => { + it('주기 옵션 목록을 조회한다', async () => { + const identifier = 'my-device-id'; + const mockResponse = { + success: true, + data: { + defaultOptions: [{ code: 'EBBINGHAUS', title: '에빙하우스' }], + customOptions: [] + } + }; + sendMessageMock.mockResolvedValue(mockResponse); + + const result = await getCycleOptions(identifier); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/cycles/custom', + method: 'GET', + headers: { 'X-Device-Id': identifier } + } + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('createCustomCycle', () => { + it('커스텀 주기를 생성한다', async () => { + const identifier = 'my-device-id'; + const title = '나의 주기'; + const durations = ['PT10M', 'PT1H', 'P1D']; + const mockResponse = { success: true, data: { id: 1, title, durations } }; + sendMessageMock.mockResolvedValue(mockResponse); + + const result = await createCustomCycle(identifier, title, durations); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/cycles/custom', + method: 'POST', + headers: { 'X-Device-Id': identifier }, + body: { title, durations } + } + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('updateCustomCycle', () => { + it('커스텀 주기를 수정한다', async () => { + const identifier = 'my-device-id'; + const id = 1; + const title = '수정된 주기'; + const durations = ['PT30M', 'P2D']; + const mockResponse = { success: true, data: { id, title, durations } }; + sendMessageMock.mockResolvedValue(mockResponse); + + const result = await updateCustomCycle(identifier, id, title, durations); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/cycles/custom/1', + method: 'PUT', + headers: { 'X-Device-Id': identifier }, + body: { title, durations } + } + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('deleteCustomCycle', () => { + it('커스텀 주기를 삭제한다', async () => { + const identifier = 'my-device-id'; + const id = 1; + const mockResponse = { success: true, data: null }; + sendMessageMock.mockResolvedValue(mockResponse); + + await deleteCustomCycle(identifier, id); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/cycles/custom/1', + method: 'DELETE', + headers: { 'X-Device-Id': identifier } } }); }); diff --git a/src/api.js b/src/api.js index 8da19cd..5660700 100644 --- a/src/api.js +++ b/src/api.js @@ -78,13 +78,73 @@ export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifi * 복습 URL 저장 * @param {string} identifier - 디바이스 식별자 * @param {string} targetUrl - 저장할 URL + * @param {Object} cycle - 복습 주기 { type: "DEFAULT", code } 또는 { type: "CUSTOM", id } * @returns {Promise} { url, scheduledAts } */ -export async function saveReviewUrl(identifier, targetUrl) { +export async function saveReviewUrl(identifier, targetUrl, cycle) { return await sendApiRequest({ endpoint: '/api/v1/reviews', method: 'POST', headers: { 'X-Device-Id': identifier }, - body: { targetUrl } + body: { targetUrl, cycle } + }); +} + +/** + * 복습 주기 옵션 조회 + * @param {string} identifier - 디바이스 식별자 + * @returns {Promise} { defaultOptions, customOptions } + */ +export async function getCycleOptions(identifier) { + return await sendApiRequest({ + endpoint: '/api/v1/cycles/custom', + method: 'GET', + headers: { 'X-Device-Id': identifier } + }); +} + +/** + * 커스텀 주기 생성 + * @param {string} identifier - 디바이스 식별자 + * @param {string} title - 주기 이름 + * @param {string[]} durations - ISO 8601 Duration 배열 + * @returns {Promise} { id, title, durations } + */ +export async function createCustomCycle(identifier, title, durations) { + return await sendApiRequest({ + endpoint: '/api/v1/cycles/custom', + method: 'POST', + headers: { 'X-Device-Id': identifier }, + body: { title, durations } + }); +} + +/** + * 커스텀 주기 수정 + * @param {string} identifier - 디바이스 식별자 + * @param {number} id - 주기 ID + * @param {string} title - 주기 이름 + * @param {string[]} durations - ISO 8601 Duration 배열 + * @returns {Promise} { id, title, durations } + */ +export async function updateCustomCycle(identifier, id, title, durations) { + return await sendApiRequest({ + endpoint: `/api/v1/cycles/custom/${id}`, + method: 'PUT', + headers: { 'X-Device-Id': identifier }, + body: { title, durations } + }); +} + +/** + * 커스텀 주기 삭제 + * @param {string} identifier - 디바이스 식별자 + * @param {number} id - 주기 ID + */ +export async function deleteCustomCycle(identifier, id) { + return await sendApiRequest({ + endpoint: `/api/v1/cycles/custom/${id}`, + method: 'DELETE', + headers: { 'X-Device-Id': identifier } }); } diff --git a/src/constants.js b/src/constants.js index 13a535f..a0626f3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,7 +1,11 @@ /** * 상수 정의 * - * 에러 코드 및 자동 로그아웃이 필요한 에러 목록을 정의한다. + * 에러 코드, UI 설정값, Duration 관련 상수를 정의한다. + */ + +/** + * 에러 코드 */ export const ERROR_CODES = { // 로그아웃이 필요한 에러 @@ -23,3 +27,26 @@ export const LOGOUT_REQUIRED_ERRORS = [ ERROR_CODES.NOT_FOUND, ERROR_CODES.INVALID_STORAGE ]; + +/** + * UI 관련 상수 + */ +export const UI_CONSTANTS = { + MESSAGE_DISPLAY_DURATION_MS: 3000, // 메시지 표시 시간 (3초) + DEVICE_ID_DISPLAY_LENGTH: 20 // 디바이스 ID 표시 길이 +}; + +/** + * Duration 관련 상수 + */ +export const DURATION_CONSTANTS = { + VALIDATION_STEP_MINUTES: 10, // 분 단위 검증 기준 (10분) + DEFAULT_DURATION: 'PT10M', // 기본 Duration 값 + DEFAULT_VALUE: 10, // 기본 숫자 값 + DEFAULT_UNIT: 'M' // 기본 단위 (분) +}; + +/** + * ISO 8601 Duration 파싱용 정규식 + */ +export const DURATION_REGEX = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?$/; diff --git a/src/duration-utils.js b/src/duration-utils.js new file mode 100644 index 0000000..c22c78d --- /dev/null +++ b/src/duration-utils.js @@ -0,0 +1,102 @@ +/** + * Duration 유틸리티 + * + * ISO 8601 Duration 형식의 파싱, 변환, 검증을 담당한다. + */ + +import { DURATION_REGEX, DURATION_CONSTANTS } from './constants.js'; + +/** + * ISO 8601 Duration을 파싱하여 일/시간/분 값 반환 + * @param {string} duration - ISO 8601 Duration (예: 'P1D', 'PT1H', 'PT10M') + * @returns {{ days: number, hours: number, minutes: number }} + */ +export function parseDuration(duration) { + const match = duration.match(DURATION_REGEX); + if (!match) { + return { days: 0, hours: 0, minutes: 0 }; + } + + return { + days: parseInt(match[1]) || 0, + hours: parseInt(match[2]) || 0, + minutes: parseInt(match[3]) || 0 + }; +} + +/** + * ISO 8601 Duration을 읽기 쉬운 텍스트로 변환 + * @param {string} duration - ISO 8601 Duration + * @returns {string} 예: '1일', '2시간', '10분', '1일 2시간' + */ +export function formatDurationToText(duration) { + const { days, hours, minutes } = parseDuration(duration); + + const parts = []; + if (days > 0) parts.push(`${days}일`); + if (hours > 0) parts.push(`${hours}시간`); + if (minutes > 0) parts.push(`${minutes}분`); + + return parts.join(' ') || duration; +} + +/** + * Duration 배열을 표시용 문자열로 변환 + * @param {string[]} durations - ISO 8601 Duration 배열 + * @returns {string} 예: '10분 → 1시간 → 1일' + */ +export function formatDurationsForDisplay(durations) { + if (!durations || durations.length === 0) return ''; + return durations.map(d => formatDurationToText(d)).join(' → '); +} + +/** + * ISO 8601 Duration을 값과 단위로 변환 (폼 입력용) + * @param {string} duration - ISO 8601 Duration + * @returns {{ value: number, unit: string }} unit: 'D' | 'H' | 'M' + */ +export function parseDurationToValueUnit(duration) { + const { days, hours, minutes } = parseDuration(duration); + + if (days > 0) return { value: days, unit: 'D' }; + if (hours > 0) return { value: hours, unit: 'H' }; + return { + value: minutes || DURATION_CONSTANTS.DEFAULT_VALUE, + unit: DURATION_CONSTANTS.DEFAULT_UNIT + }; +} + +/** + * 값과 단위를 ISO 8601 Duration으로 변환 + * @param {number} value - 숫자 값 + * @param {string} unit - 단위 ('D' | 'H' | 'M') + * @returns {string} ISO 8601 Duration + */ +export function formatValueUnitToDuration(value, unit) { + if (unit === 'D') return `P${value}D`; + if (unit === 'H') return `PT${value}H`; + return `PT${value}M`; +} + +/** + * Duration 배열 검증 (10분 단위) + * @param {string[]} durations - ISO 8601 Duration 배열 + * @returns {{ valid: boolean, message: string }} + */ +export function validateDurations(durations) { + if (durations.length === 0) { + return { valid: false, message: '최소 1개의 복습 간격이 필요합니다.' }; + } + + for (const d of durations) { + const match = d.match(/^PT(\d+)M$/); + if (match) { + const minutes = parseInt(match[1]); + if (minutes % DURATION_CONSTANTS.VALIDATION_STEP_MINUTES !== 0) { + return { valid: false, message: '분 단위는 10분 단위로 입력해주세요.' }; + } + } + } + + return { valid: true, message: '' }; +} diff --git a/src/handlers/auth.js b/src/handlers/auth.js new file mode 100644 index 0000000..5034a9d --- /dev/null +++ b/src/handlers/auth.js @@ -0,0 +1,91 @@ +/** + * 인증 관련 핸들러 + * + * 디바이스 등록, 인증 확인, 리셋 처리를 담당한다. + */ + +import { STORAGE_KEYS } from '../config.js'; +import { ERROR_CODES } from '../constants.js'; +import { registerDevice, getDevices } from '../api.js'; +import { setStorageData, clearStorage, validateStorageForAuth } from '../storage.js'; +import { + elements, + showLoading, + hideLoading, + showMessage, + showView, + handleApiError +} from '../ui/index.js'; +import { isValidEmail } from '../utils.js'; + +/** + * 디바이스 등록 버튼 클릭 핸들러 + */ +export async function handleRegister() { + const email = elements.emailInput.value.trim(); + + if (!email) { + showMessage('이메일을 입력해주세요.', 'error'); + return; + } + + if (!isValidEmail(email)) { + showMessage('유효한 이메일 형식이 아닙니다.', 'error'); + return; + } + + try { + showLoading(); + const result = await registerDevice(email); + + await setStorageData({ + [STORAGE_KEYS.EMAIL]: result.email, + [STORAGE_KEYS.IDENTIFIER]: result.identifier, + [STORAGE_KEYS.IS_AUTHENTICATED]: false + }); + + elements.emailDisplay.textContent = result.email; + showView('pending'); + showMessage('이메일로 인증 링크가 전송되었습니다.', 'success'); + } catch (error) { + showMessage(error.message, 'error'); + } finally { + hideLoading(); + } +} + +/** + * 인증 확인 버튼 클릭 핸들러 + */ +export async function handleCheckAuth() { + try { + showLoading(); + const storageData = await validateStorageForAuth(); + const result = await getDevices(storageData.email, storageData.identifier); + + await setStorageData({ + [STORAGE_KEYS.IS_AUTHENTICATED]: true + }); + + elements.userEmail.textContent = result.email; + showView('main'); + showMessage('인증이 완료되었습니다!', 'success'); + } catch (error) { + if (error.code === ERROR_CODES.UNAUTHORIZED) { + showMessage('아직 인증이 완료되지 않았습니다.', 'info'); + } else { + await handleApiError(error); + } + } finally { + hideLoading(); + } +} + +/** + * 다른 이메일로 등록 버튼 클릭 핸들러 + */ +export async function handleReset() { + await clearStorage(); + elements.emailInput.value = ''; + showView('login'); +} diff --git a/src/handlers/cycle.js b/src/handlers/cycle.js new file mode 100644 index 0000000..e415d23 --- /dev/null +++ b/src/handlers/cycle.js @@ -0,0 +1,188 @@ +/** + * 복습 주기 관련 핸들러 + * + * 주기 옵션 로드, 생성, 수정, 삭제 처리를 담당한다. + */ + +import { + getCycleOptions, + createCustomCycle, + updateCustomCycle, + deleteCustomCycle +} from '../api.js'; +import { validateStorageForAuth } from '../storage.js'; +import { + elements, + showLoading, + hideLoading, + showMessage, + handleApiError, + renderCycleSelect, + renderCycleList, + showCycleModal, + hideCycleModal, + getEditingCycleId, + addDurationInput, + getDurationsFromForm +} from '../ui/index.js'; +import { validateDurations } from '../duration-utils.js'; + +// 현재 로드된 주기 옵션 캐시 +let cachedCycleOptions = { defaultOptions: [], customOptions: [] }; + +/** + * 캐시된 주기 옵션 반환 + * @returns {{ defaultOptions: Array, customOptions: Array }} + */ +export function getCachedCycleOptions() { + return cachedCycleOptions; +} + +/** + * 주기 옵션 로드 핸들러 + */ +export async function handleLoadCycleOptions() { + try { + const storageData = await validateStorageForAuth(); + const result = await getCycleOptions(storageData.identifier); + + cachedCycleOptions = { + defaultOptions: result.defaultOptions || [], + customOptions: result.customOptions || [] + }; + + renderCycleSelect(cachedCycleOptions.defaultOptions, cachedCycleOptions.customOptions); + } catch (error) { + console.warn('주기 옵션 로드 실패:', error); + // 에러 시에도 기본 빈 상태로 렌더링 + renderCycleSelect([], []); + } +} + +/** + * 주기 관리 섹션 토글 핸들러 + */ +export async function handleShowCycleManagement() { + const isVisible = !elements.cycleSection.classList.contains('hidden'); + + if (isVisible) { + elements.cycleSection.classList.add('hidden'); + return; + } + + try { + showLoading(); + const storageData = await validateStorageForAuth(); + const result = await getCycleOptions(storageData.identifier); + + cachedCycleOptions = { + defaultOptions: result.defaultOptions || [], + customOptions: result.customOptions || [] + }; + + renderCycleList(cachedCycleOptions.customOptions); + elements.cycleSection.classList.remove('hidden'); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} + +/** + * 커스텀 주기 저장 핸들러 (생성/수정) + * @param {Event} e - 폼 제출 이벤트 + */ +export async function handleSaveCycle(e) { + e.preventDefault(); + + const title = elements.cycleTitleInput.value.trim(); + if (!title) { + showMessage('주기 이름을 입력해주세요.', 'error'); + return; + } + + const durations = getDurationsFromForm(); + const validation = validateDurations(durations); + if (!validation.valid) { + showMessage(validation.message, 'error'); + return; + } + + try { + showLoading(); + const storageData = await validateStorageForAuth(); + const editingId = getEditingCycleId(); + + if (editingId) { + // 수정 + await updateCustomCycle(storageData.identifier, editingId, title, durations); + showMessage('주기가 수정되었습니다.', 'success'); + } else { + // 생성 + await createCustomCycle(storageData.identifier, title, durations); + showMessage('주기가 생성되었습니다.', 'success'); + } + + hideCycleModal(); + + // 목록 및 드롭다운 갱신 + await handleLoadCycleOptions(); + renderCycleList(cachedCycleOptions.customOptions); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} + +/** + * 커스텀 주기 삭제 핸들러 + * @param {number} id - 주기 ID + */ +export async function handleDeleteCycle(id) { + if (!confirm('이 복습 주기를 삭제하시겠습니까?')) { + return; + } + + try { + showLoading(); + const storageData = await validateStorageForAuth(); + await deleteCustomCycle(storageData.identifier, id); + + showMessage('주기가 삭제되었습니다.', 'success'); + + // 목록 및 드롭다운 갱신 + await handleLoadCycleOptions(); + renderCycleList(cachedCycleOptions.customOptions); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} + +/** + * 커스텀 주기 수정 모달 열기 핸들러 + * @param {number} id - 주기 ID + */ +export function handleEditCycle(id) { + const cycle = cachedCycleOptions.customOptions.find(c => c.id === id); + if (cycle) { + showCycleModal(cycle); + } +} + +/** + * 새 주기 추가 버튼 클릭 핸들러 + */ +export function handleAddCycle() { + showCycleModal(null); +} + +/** + * Duration 추가 버튼 클릭 핸들러 + */ +export function handleAddDuration() { + addDurationInput(); +} diff --git a/src/handlers.js b/src/handlers/device.js similarity index 51% rename from src/handlers.js rename to src/handlers/device.js index 7db7904..fb8c806 100644 --- a/src/handlers.js +++ b/src/handlers/device.js @@ -1,14 +1,12 @@ /** - * 이벤트 핸들러 + * 디바이스 관련 핸들러 * - * 디바이스 등록, 인증 확인, URL 저장, 디바이스 관리, 로그아웃 등 - * 사용자 액션에 대한 핸들러 함수를 정의한다. + * 디바이스 목록 조회, 삭제, 로그아웃 처리를 담당한다. */ -import { STORAGE_KEYS } from './config.js'; -import { ERROR_CODES } from './constants.js'; -import { registerDevice, getDevices, deleteDevice, saveReviewUrl } from './api.js'; -import { setStorageData, clearStorage, validateStorageForAuth } from './storage.js'; +import { UI_CONSTANTS } from '../constants.js'; +import { getDevices, deleteDevice } from '../api.js'; +import { getStorageData, clearStorage, validateStorageForAuth } from '../storage.js'; import { elements, showLoading, @@ -16,113 +14,8 @@ import { showMessage, showView, handleApiError -} from './ui.js'; -import { formatDate, isValidEmail } from './utils.js'; - -/** - * 디바이스 등록 버튼 클릭 핸들러 - */ -export async function handleRegister() { - const email = elements.emailInput.value.trim(); - - if (!email) { - showMessage('이메일을 입력해주세요.', 'error'); - return; - } - - if (!isValidEmail(email)) { - showMessage('유효한 이메일 형식이 아닙니다.', 'error'); - return; - } - - try { - showLoading(); - const result = await registerDevice(email); - - await setStorageData({ - [STORAGE_KEYS.EMAIL]: result.email, - [STORAGE_KEYS.IDENTIFIER]: result.identifier, - [STORAGE_KEYS.IS_AUTHENTICATED]: false - }); - - elements.emailDisplay.textContent = result.email; - showView('pending'); - showMessage('이메일로 인증 링크가 전송되었습니다.', 'success'); - } catch (error) { - showMessage(error.message, 'error'); - } finally { - hideLoading(); - } -} - -/** - * 인증 확인 버튼 클릭 핸들러 - */ -export async function handleCheckAuth() { - try { - showLoading(); - const storageData = await validateStorageForAuth(); - const result = await getDevices(storageData.email, storageData.identifier); - - await setStorageData({ - [STORAGE_KEYS.IS_AUTHENTICATED]: true - }); - - elements.userEmail.textContent = result.email; - showView('main'); - showMessage('인증이 완료되었습니다!', 'success'); - } catch (error) { - if (error.code === ERROR_CODES.UNAUTHORIZED) { - showMessage('아직 인증이 완료되지 않았습니다.', 'info'); - } else { - await handleApiError(error); - } - } finally { - hideLoading(); - } -} - -/** - * 다른 이메일로 등록 버튼 클릭 핸들러 - */ -export async function handleReset() { - await clearStorage(); - elements.emailInput.value = ''; - showView('login'); -} - -/** - * URL 저장 버튼 클릭 핸들러 - */ -export async function handleSaveUrl() { - try { - showLoading(); - - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - - if (!tab?.url) { - showMessage('현재 페이지의 URL을 가져올 수 없습니다.', 'error'); - return; - } - - const storageData = await validateStorageForAuth(); - const result = await saveReviewUrl(storageData.identifier, tab.url); - - elements.scheduleDates.innerHTML = ''; - result.scheduledAts.forEach(date => { - const li = document.createElement('li'); - li.textContent = formatDate(date); - elements.scheduleDates.appendChild(li); - }); - - elements.saveResult.classList.remove('hidden'); - showMessage('저장되었습니다!', 'success'); - } catch (error) { - await handleApiError(error); - } finally { - hideLoading(); - } -} +} from '../ui/index.js'; +import { formatDate } from '../utils.js'; /** * 디바이스 관리 버튼 클릭 핸들러 @@ -151,7 +44,7 @@ export async function handleShowDevices() { const deviceIdDiv = document.createElement('div'); deviceIdDiv.className = 'device-id'; - deviceIdDiv.textContent = device.identifier.substring(0, 20) + '...'; + deviceIdDiv.textContent = device.identifier.substring(0, UI_CONSTANTS.DEVICE_ID_DISPLAY_LENGTH) + '...'; deviceInfo.appendChild(deviceIdDiv); const deviceDateDiv = document.createElement('div'); @@ -227,12 +120,12 @@ export async function handleLogout() { try { showLoading(); const storageData = await getStorageData(); - + // 인증된 상태라면 서버에 디바이스 삭제 요청 if (storageData.email && storageData.identifier) { await deleteDevice( - storageData.email, - storageData.identifier, + storageData.email, + storageData.identifier, storageData.identifier // 자기 자신을 삭제 ); } @@ -245,6 +138,7 @@ export async function handleLogout() { await clearStorage(); elements.saveResult.classList.add('hidden'); elements.devicesSection.classList.add('hidden'); + elements.cycleSection.classList.add('hidden'); showView('login'); showMessage('로그아웃 되었습니다.', 'info'); } diff --git a/src/handlers/index.js b/src/handlers/index.js new file mode 100644 index 0000000..db65c28 --- /dev/null +++ b/src/handlers/index.js @@ -0,0 +1,26 @@ +/** + * 핸들러 모듈 진입점 + * + * 모든 핸들러를 re-export한다. + */ + +// 인증 관련 +export { handleRegister, handleCheckAuth, handleReset } from './auth.js'; + +// 디바이스 관련 +export { handleShowDevices, handleDeleteDevice, handleLogout } from './device.js'; + +// 주기 관련 +export { + handleLoadCycleOptions, + handleShowCycleManagement, + handleSaveCycle, + handleDeleteCycle, + handleEditCycle, + handleAddCycle, + handleAddDuration, + getCachedCycleOptions +} from './cycle.js'; + +// URL 저장 관련 +export { handleSaveUrl } from './url.js'; diff --git a/src/handlers/url.js b/src/handlers/url.js new file mode 100644 index 0000000..47c383c --- /dev/null +++ b/src/handlers/url.js @@ -0,0 +1,51 @@ +/** + * URL 저장 관련 핸들러 + * + * 현재 페이지 URL 저장 처리를 담당한다. + */ + +import { saveReviewUrl } from '../api.js'; +import { validateStorageForAuth } from '../storage.js'; +import { + elements, + showLoading, + hideLoading, + showMessage, + handleApiError, + getSelectedCycle +} from '../ui/index.js'; +import { formatDate } from '../utils.js'; + +/** + * URL 저장 버튼 클릭 핸들러 + */ +export async function handleSaveUrl() { + try { + showLoading(); + + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab?.url) { + showMessage('현재 페이지의 URL을 가져올 수 없습니다.', 'error'); + return; + } + + const storageData = await validateStorageForAuth(); + const cycle = getSelectedCycle(); + const result = await saveReviewUrl(storageData.identifier, tab.url, cycle); + + elements.scheduleDates.innerHTML = ''; + result.scheduledAts.forEach(date => { + const li = document.createElement('li'); + li.textContent = formatDate(date); + elements.scheduleDates.appendChild(li); + }); + + elements.saveResult.classList.remove('hidden'); + showMessage('저장되었습니다!', 'success'); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} diff --git a/src/popup.js b/src/popup.js index b72c286..bcf4f1c 100644 --- a/src/popup.js +++ b/src/popup.js @@ -9,8 +9,9 @@ import { elements, initializeElements, showView, - showMessage -} from './ui.js'; + showMessage, + hideCycleModal +} from './ui/index.js'; import { handleRegister, handleCheckAuth, @@ -18,8 +19,15 @@ import { handleSaveUrl, handleShowDevices, handleDeleteDevice, - handleLogout -} from './handlers.js'; + handleLogout, + handleLoadCycleOptions, + handleShowCycleManagement, + handleSaveCycle, + handleDeleteCycle, + handleEditCycle, + handleAddCycle, + handleAddDuration +} from './handlers/index.js'; /** * 이벤트 리스너 등록 @@ -32,6 +40,23 @@ function setupEventListeners() { elements.showDevicesBtn.addEventListener('click', handleShowDevices); elements.logoutBtn.addEventListener('click', handleLogout); + // 주기 관리 이벤트 + elements.cycleManageBtn.addEventListener('click', handleShowCycleManagement); + elements.cycleAddBtn.addEventListener('click', handleAddCycle); + elements.cycleFormCancelBtn.addEventListener('click', hideCycleModal); + elements.cycleForm.addEventListener('submit', handleSaveCycle); + elements.addDurationBtn.addEventListener('click', handleAddDuration); + + // 주기 목록 이벤트 위임 (수정/삭제) + elements.cycleList.addEventListener('click', (e) => { + const id = parseInt(e.target.dataset.id, 10); + if (e.target.classList.contains('cycle-edit-btn')) { + handleEditCycle(id); + } else if (e.target.classList.contains('cycle-delete-btn')) { + handleDeleteCycle(id); + } + }); + // 디바이스 삭제 버튼 (이벤트 위임) elements.devicesList.addEventListener('click', (e) => { if (e.target.classList.contains('btn-danger')) { @@ -67,6 +92,8 @@ async function initialize() { if (storageData.isAuthenticated) { elements.userEmail.textContent = storageData.email; showView('main'); + // 주기 옵션 로드 + handleLoadCycleOptions(); } else if (storageData.email && storageData.identifier) { elements.emailDisplay.textContent = storageData.email; showView('pending'); diff --git a/src/ui.js b/src/ui.js deleted file mode 100644 index 481b19e..0000000 --- a/src/ui.js +++ /dev/null @@ -1,153 +0,0 @@ -/** - * UI 관련 함수 - * - * DOM 요소 캐싱, 로딩/메시지 표시, 뷰 전환, 에러 처리 등 - * 화면 표시와 관련된 기능을 담당한다. - */ - -import { clearStorage } from './storage.js'; -import { getErrorMessage, isLogoutRequiredError } from './errors.js'; -import { ERROR_CODES } from './constants.js'; - -/** - * DOM 요소 캐시 - */ -export const elements = { - // 뷰 - loginView: null, - pendingView: null, - mainView: null, - - // 로그인 화면 - emailInput: null, - registerBtn: null, - - // 인증 대기 화면 - emailDisplay: null, - checkAuthBtn: null, - resetBtn: null, - - // 메인 화면 - userEmail: null, - saveUrlBtn: null, - saveResult: null, - scheduleDates: null, - showDevicesBtn: null, - devicesSection: null, - devicesList: null, - logoutBtn: null, - - // 공통 - messageArea: null, - loading: null -}; - -/** - * DOM 요소 초기화 - */ -export function initializeElements() { - elements.loginView = document.getElementById('login-view'); - elements.pendingView = document.getElementById('pending-view'); - elements.mainView = document.getElementById('main-view'); - - elements.emailInput = document.getElementById('email-input'); - elements.registerBtn = document.getElementById('register-btn'); - - elements.emailDisplay = document.querySelector('.email-display'); - elements.checkAuthBtn = document.getElementById('check-auth-btn'); - elements.resetBtn = document.getElementById('reset-btn'); - - elements.userEmail = document.getElementById('user-email'); - elements.saveUrlBtn = document.getElementById('save-url-btn'); - elements.saveResult = document.getElementById('save-result'); - elements.scheduleDates = document.getElementById('schedule-dates'); - elements.showDevicesBtn = document.getElementById('show-devices-btn'); - elements.devicesSection = document.getElementById('devices-section'); - elements.devicesList = document.getElementById('devices-list'); - elements.logoutBtn = document.getElementById('logout-btn'); - - elements.messageArea = document.getElementById('message-area'); - elements.loading = document.getElementById('loading'); -} - -/** - * 로딩 표시 - */ -export function showLoading() { - elements.loading.classList.remove('hidden'); -} - -/** - * 로딩 숨김 - */ -export function hideLoading() { - elements.loading.classList.add('hidden'); -} - -/** - * 메시지 표시 - * @param {string} message - 표시할 메시지 - * @param {string} type - 메시지 타입 ('info' | 'success' | 'error') - */ -export function showMessage(message, type = 'info') { - elements.messageArea.textContent = message; - elements.messageArea.className = `message-area ${type}`; - elements.messageArea.classList.remove('hidden'); - - setTimeout(() => { - elements.messageArea.classList.add('hidden'); - }, 3000); -} - -/** - * 뷰 전환 - * @param {string} viewName - 표시할 뷰 ('login' | 'pending' | 'main') - */ -export function showView(viewName) { - elements.loginView.classList.add('hidden'); - elements.pendingView.classList.add('hidden'); - elements.mainView.classList.add('hidden'); - - switch (viewName) { - case 'login': - elements.loginView.classList.remove('hidden'); - break; - case 'pending': - elements.pendingView.classList.remove('hidden'); - break; - case 'main': - elements.mainView.classList.remove('hidden'); - break; - } -} - -/** - * 강제 로그아웃 처리 - * @param {string} message - 표시할 메시지 - */ -export async function forceLogout(message) { - await clearStorage(); - elements.saveResult.classList.add('hidden'); - elements.devicesSection.classList.add('hidden'); - elements.emailInput.value = ''; - showView('login'); - showMessage(message, 'error'); -} - -/** - * 공통 API 에러 핸들러 - * @param {Error} error - 에러 객체 - * @returns {Promise} 로그아웃되었으면 true - */ -export async function handleApiError(error) { - const code = error.code || ERROR_CODES.BAD_REQUEST; - const message = getErrorMessage(code, error.message); - - if (isLogoutRequiredError(code)) { - await forceLogout(message); - return true; - } - - showMessage(message, 'error'); - return false; -} diff --git a/src/ui/cycle.js b/src/ui/cycle.js new file mode 100644 index 0000000..f425b10 --- /dev/null +++ b/src/ui/cycle.js @@ -0,0 +1,115 @@ +/** + * 주기 관련 UI + * + * 주기 선택 드롭다운 및 주기 목록 렌더링을 담당한다. + */ + +import { elements } from './elements.js'; +import { formatDurationsForDisplay } from '../duration-utils.js'; + +/** + * 주기 선택 드롭다운 렌더링 + * @param {Array} defaultOptions - 기본 주기 옵션 + * @param {Array} customOptions - 커스텀 주기 옵션 + */ +export function renderCycleSelect(defaultOptions, customOptions) { + elements.cycleSelect.innerHTML = ''; + + // 기본 주기 옵션 + if (defaultOptions && defaultOptions.length > 0) { + const defaultGroup = document.createElement('optgroup'); + defaultGroup.label = '기본 주기'; + defaultOptions.forEach(option => { + const opt = document.createElement('option'); + opt.value = `DEFAULT:${option.code}`; + opt.textContent = option.title; + defaultGroup.appendChild(opt); + }); + elements.cycleSelect.appendChild(defaultGroup); + } + + // 커스텀 주기 옵션 + if (customOptions && customOptions.length > 0) { + const customGroup = document.createElement('optgroup'); + customGroup.label = '나의 주기'; + customOptions.forEach(option => { + const opt = document.createElement('option'); + opt.value = `CUSTOM:${option.id}`; + opt.textContent = option.title; + customGroup.appendChild(opt); + }); + elements.cycleSelect.appendChild(customGroup); + } +} + +/** + * 주기 관리 목록 렌더링 + * @param {Array} customOptions - 커스텀 주기 옵션 + */ +export function renderCycleList(customOptions) { + elements.cycleList.innerHTML = ''; + + if (!customOptions || customOptions.length === 0) { + const emptyItem = document.createElement('li'); + emptyItem.className = 'cycle-item-empty'; + emptyItem.textContent = '생성된 커스텀 주기가 없습니다.'; + elements.cycleList.appendChild(emptyItem); + return; + } + + customOptions.forEach(option => { + const li = document.createElement('li'); + li.className = 'cycle-item'; + li.setAttribute('data-id', option.id); + + const info = document.createElement('div'); + info.className = 'cycle-info'; + + const title = document.createElement('div'); + title.className = 'cycle-title'; + title.textContent = option.title; + info.appendChild(title); + + const durations = document.createElement('div'); + durations.className = 'cycle-durations'; + durations.textContent = formatDurationsForDisplay(option.durations); + info.appendChild(durations); + + li.appendChild(info); + + const actions = document.createElement('div'); + actions.className = 'cycle-actions'; + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary btn-small cycle-edit-btn'; + editBtn.setAttribute('data-id', option.id); + editBtn.textContent = '수정'; + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-danger btn-small cycle-delete-btn'; + deleteBtn.setAttribute('data-id', option.id); + deleteBtn.textContent = '삭제'; + actions.appendChild(deleteBtn); + + li.appendChild(actions); + elements.cycleList.appendChild(li); + }); +} + +/** + * 선택된 주기 정보 반환 + * @returns {Object|null} { type, code } 또는 { type, id } 또는 null + */ +export function getSelectedCycle() { + const value = elements.cycleSelect.value; + if (!value) return null; + + const [type, identifier] = value.split(':'); + if (type === 'DEFAULT') { + return { type: 'DEFAULT', code: identifier }; + } else if (type === 'CUSTOM') { + return { type: 'CUSTOM', id: parseInt(identifier, 10) }; + } + return null; +} diff --git a/src/ui/elements.js b/src/ui/elements.js new file mode 100644 index 0000000..8badbfe --- /dev/null +++ b/src/ui/elements.js @@ -0,0 +1,95 @@ +/** + * DOM 요소 관리 + * + * DOM 요소 캐싱 및 초기화를 담당한다. + */ + +/** + * DOM 요소 캐시 + */ +export const elements = { + // 뷰 + loginView: null, + pendingView: null, + mainView: null, + + // 로그인 화면 + emailInput: null, + registerBtn: null, + + // 인증 대기 화면 + emailDisplay: null, + checkAuthBtn: null, + resetBtn: null, + + // 메인 화면 + userEmail: null, + cycleSelect: null, + saveUrlBtn: null, + saveResult: null, + scheduleDates: null, + cycleManageBtn: null, + cycleSection: null, + cycleList: null, + cycleAddBtn: null, + showDevicesBtn: null, + devicesSection: null, + devicesList: null, + logoutBtn: null, + + // 주기 폼 모달 + cycleModal: null, + cycleModalTitle: null, + cycleForm: null, + cycleTitleInput: null, + cycleDurationsContainer: null, + addDurationBtn: null, + cycleFormSubmitBtn: null, + cycleFormCancelBtn: null, + + // 공통 + messageArea: null, + loading: null +}; + +/** + * DOM 요소 초기화 + */ +export function initializeElements() { + elements.loginView = document.getElementById('login-view'); + elements.pendingView = document.getElementById('pending-view'); + elements.mainView = document.getElementById('main-view'); + + elements.emailInput = document.getElementById('email-input'); + elements.registerBtn = document.getElementById('register-btn'); + + elements.emailDisplay = document.querySelector('.email-display'); + elements.checkAuthBtn = document.getElementById('check-auth-btn'); + elements.resetBtn = document.getElementById('reset-btn'); + + elements.userEmail = document.getElementById('user-email'); + elements.cycleSelect = document.getElementById('cycle-select'); + elements.saveUrlBtn = document.getElementById('save-url-btn'); + elements.saveResult = document.getElementById('save-result'); + elements.scheduleDates = document.getElementById('schedule-dates'); + elements.cycleManageBtn = document.getElementById('cycle-manage-btn'); + elements.cycleSection = document.getElementById('cycle-section'); + elements.cycleList = document.getElementById('cycle-list'); + elements.cycleAddBtn = document.getElementById('cycle-add-btn'); + elements.showDevicesBtn = document.getElementById('show-devices-btn'); + elements.devicesSection = document.getElementById('devices-section'); + elements.devicesList = document.getElementById('devices-list'); + elements.logoutBtn = document.getElementById('logout-btn'); + + elements.cycleModal = document.getElementById('cycle-modal'); + elements.cycleModalTitle = document.getElementById('cycle-modal-title'); + elements.cycleForm = document.getElementById('cycle-form'); + elements.cycleTitleInput = document.getElementById('cycle-title-input'); + elements.cycleDurationsContainer = document.getElementById('cycle-durations-container'); + elements.addDurationBtn = document.getElementById('add-duration-btn'); + elements.cycleFormSubmitBtn = document.getElementById('cycle-form-submit'); + elements.cycleFormCancelBtn = document.getElementById('cycle-form-cancel'); + + elements.messageArea = document.getElementById('message-area'); + elements.loading = document.getElementById('loading'); +} diff --git a/src/ui/error-handler.js b/src/ui/error-handler.js new file mode 100644 index 0000000..583bd1a --- /dev/null +++ b/src/ui/error-handler.js @@ -0,0 +1,28 @@ +/** + * API 에러 처리 + * + * API 에러 처리 및 자동 로그아웃 판단을 담당한다. + */ + +import { ERROR_CODES } from '../constants.js'; +import { getErrorMessage, isLogoutRequiredError } from '../errors.js'; +import { showMessage } from './messages.js'; +import { forceLogout } from './views.js'; + +/** + * 공통 API 에러 핸들러 + * @param {Error} error - 에러 객체 + * @returns {Promise} 로그아웃되었으면 true + */ +export async function handleApiError(error) { + const code = error.code || ERROR_CODES.BAD_REQUEST; + const message = getErrorMessage(code, error.message); + + if (isLogoutRequiredError(code)) { + await forceLogout(message); + return true; + } + + showMessage(message, 'error'); + return false; +} diff --git a/src/ui/index.js b/src/ui/index.js new file mode 100644 index 0000000..2c2114e --- /dev/null +++ b/src/ui/index.js @@ -0,0 +1,29 @@ +/** + * UI 모듈 진입점 + * + * 모든 UI 함수를 re-export한다. + */ + +// DOM 요소 +export { elements, initializeElements } from './elements.js'; + +// 뷰 전환 +export { showView, forceLogout } from './views.js'; + +// 메시지/로딩 +export { showLoading, hideLoading, showMessage } from './messages.js'; + +// 에러 처리 +export { handleApiError } from './error-handler.js'; + +// 주기 UI +export { renderCycleSelect, renderCycleList, getSelectedCycle } from './cycle.js'; + +// 모달 +export { + showCycleModal, + hideCycleModal, + getEditingCycleId, + addDurationInput, + getDurationsFromForm +} from './modal.js'; diff --git a/src/ui/messages.js b/src/ui/messages.js new file mode 100644 index 0000000..169c63b --- /dev/null +++ b/src/ui/messages.js @@ -0,0 +1,37 @@ +/** + * 메시지 및 로딩 표시 + * + * 사용자 피드백 메시지와 로딩 상태 표시를 담당한다. + */ + +import { elements } from './elements.js'; +import { UI_CONSTANTS } from '../constants.js'; + +/** + * 로딩 표시 + */ +export function showLoading() { + elements.loading.classList.remove('hidden'); +} + +/** + * 로딩 숨김 + */ +export function hideLoading() { + elements.loading.classList.add('hidden'); +} + +/** + * 메시지 표시 + * @param {string} message - 표시할 메시지 + * @param {string} type - 메시지 타입 ('info' | 'success' | 'error') + */ +export function showMessage(message, type = 'info') { + elements.messageArea.textContent = message; + elements.messageArea.className = `message-area ${type}`; + elements.messageArea.classList.remove('hidden'); + + setTimeout(() => { + elements.messageArea.classList.add('hidden'); + }, UI_CONSTANTS.MESSAGE_DISPLAY_DURATION_MS); +} diff --git a/src/ui/modal.js b/src/ui/modal.js new file mode 100644 index 0000000..e21d8b2 --- /dev/null +++ b/src/ui/modal.js @@ -0,0 +1,120 @@ +/** + * 모달 관리 + * + * 주기 폼 모달 및 Duration 입력 필드 관리를 담당한다. + */ + +import { elements } from './elements.js'; +import { + parseDurationToValueUnit, + formatValueUnitToDuration +} from '../duration-utils.js'; +import { DURATION_CONSTANTS } from '../constants.js'; + +// 현재 편집 중인 주기 ID (null이면 생성 모드) +let editingCycleId = null; +let editingCycleData = null; + +/** + * 주기 폼 모달 표시 + * @param {Object|null} cycle - 수정할 주기 (null이면 생성 모드) + */ +export function showCycleModal(cycle = null) { + editingCycleId = cycle ? cycle.id : null; + editingCycleData = cycle; + + elements.cycleModalTitle.textContent = cycle ? '복습 주기 수정' : '새 복습 주기'; + elements.cycleTitleInput.value = cycle ? cycle.title : ''; + elements.cycleDurationsContainer.innerHTML = ''; + + if (cycle && cycle.durations && cycle.durations.length > 0) { + cycle.durations.forEach(d => { + addDurationInput(d); + }); + } else { + // 기본 하나 추가 + addDurationInput(); + } + + elements.cycleModal.classList.remove('hidden'); +} + +/** + * 주기 폼 모달 숨김 + */ +export function hideCycleModal() { + editingCycleId = null; + editingCycleData = null; + elements.cycleModal.classList.add('hidden'); + elements.cycleTitleInput.value = ''; + elements.cycleDurationsContainer.innerHTML = ''; +} + +/** + * 현재 편집 중인 주기 ID 반환 + * @returns {number|null} + */ +export function getEditingCycleId() { + return editingCycleId; +} + +/** + * Duration 입력 필드 추가 + * @param {string} duration - ISO 8601 Duration (기본: 'PT10M') + */ +export function addDurationInput(duration = '') { + const group = document.createElement('div'); + group.className = 'duration-input-group'; + + const { value, unit } = parseDurationToValueUnit( + duration || DURATION_CONSTANTS.DEFAULT_DURATION + ); + + const valueInput = document.createElement('input'); + valueInput.type = 'number'; + valueInput.min = '1'; + valueInput.className = 'duration-value'; + valueInput.value = value; + + const unitSelect = document.createElement('select'); + unitSelect.className = 'duration-unit'; + unitSelect.innerHTML = ` + + + + `; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn-remove-duration'; + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', () => { + group.remove(); + }); + + group.appendChild(valueInput); + group.appendChild(unitSelect); + group.appendChild(removeBtn); + + elements.cycleDurationsContainer.appendChild(group); +} + +/** + * 폼에서 durations 배열 추출 + * @returns {string[]} ISO 8601 Duration 배열 + */ +export function getDurationsFromForm() { + const groups = elements.cycleDurationsContainer.querySelectorAll('.duration-input-group'); + const durations = []; + + groups.forEach(group => { + const value = parseInt(group.querySelector('.duration-value').value) || 0; + const unit = group.querySelector('.duration-unit').value; + + if (value > 0) { + durations.push(formatValueUnitToDuration(value, unit)); + } + }); + + return durations; +} diff --git a/src/ui/views.js b/src/ui/views.js new file mode 100644 index 0000000..b3e3f09 --- /dev/null +++ b/src/ui/views.js @@ -0,0 +1,52 @@ +/** + * 뷰 전환 관리 + * + * 화면 전환 및 강제 로그아웃 처리를 담당한다. + */ + +import { elements } from './elements.js'; +import { UI_CONSTANTS } from '../constants.js'; +import { clearStorage } from '../storage.js'; + +/** + * 뷰 전환 + * @param {string} viewName - 표시할 뷰 ('login' | 'pending' | 'main') + */ +export function showView(viewName) { + elements.loginView.classList.add('hidden'); + elements.pendingView.classList.add('hidden'); + elements.mainView.classList.add('hidden'); + + switch (viewName) { + case 'login': + elements.loginView.classList.remove('hidden'); + break; + case 'pending': + elements.pendingView.classList.remove('hidden'); + break; + case 'main': + elements.mainView.classList.remove('hidden'); + break; + } +} + +/** + * 강제 로그아웃 처리 + * @param {string} message - 표시할 메시지 + */ +export async function forceLogout(message) { + await clearStorage(); + elements.saveResult.classList.add('hidden'); + elements.devicesSection.classList.add('hidden'); + elements.emailInput.value = ''; + showView('login'); + + // 메시지 표시 (순환 참조 방지를 위해 직접 처리) + elements.messageArea.textContent = message; + elements.messageArea.className = 'message-area error'; + elements.messageArea.classList.remove('hidden'); + + setTimeout(() => { + elements.messageArea.classList.add('hidden'); + }, UI_CONSTANTS.MESSAGE_DISPLAY_DURATION_MS); +}