From 3bfe242c1d049ad75b75d79e56fc506afed07bd5 Mon Sep 17 00:00:00 2001 From: jhan0121 <56645802+jhan0121@users.noreply.github.com> Date: Sat, 27 Dec 2025 23:43:07 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=EB=B3=B5=EC=8A=B5=20URL=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=81=AC=EB=A1=AC=20=EC=9D=B5=EC=8A=A4=ED=85=90?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=ED=98=84=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 크롬 익스텐션 기능 추가 * refactor: vite 기반 프로젝트 구조 리팩터링 * chore: EOF 개행 추가 * refactor: XSS 취약점 방지를 위해 innerHTML 제거 * refactor: .env 기반 환경 관리 적용 * fix: 설정 변수명 오기입 수정 * refactor: json 파싱 에러 처리 명시 * refactor: 불필요한 리턴 제거 * refactor: 불필요한 import 제거 * chore: 파일별 주석 내용 정리 --- .gitignore | 6 + package-lock.json | 942 +++++++++++++++++++++++++++++++++++++++ package.json | 14 + public/icons/icon128.png | Bin 0 -> 19463 bytes public/icons/icon16.png | Bin 0 -> 727 bytes public/icons/icon48.png | Bin 0 -> 3965 bytes public/manifest.json | 31 ++ public/popup.css | 333 ++++++++++++++ public/popup.html | 76 ++++ src/api.js | 102 +++++ src/background.js | 25 ++ src/config.js | 16 + src/constants.js | 25 ++ src/errors.js | 63 +++ src/handlers.js | 232 ++++++++++ src/popup.js | 90 ++++ src/storage.js | 83 ++++ src/ui.js | 153 +++++++ src/utils.js | 31 ++ vite.config.js | 26 ++ 20 files changed, 2248 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/icons/icon128.png create mode 100644 public/icons/icon16.png create mode 100644 public/icons/icon48.png create mode 100644 public/manifest.json create mode 100644 public/popup.css create mode 100644 public/popup.html create mode 100644 src/api.js create mode 100644 src/background.js create mode 100644 src/config.js create mode 100644 src/constants.js create mode 100644 src/errors.js create mode 100644 src/handlers.js create mode 100644 src/popup.js create mode 100644 src/storage.js create mode 100644 src/ui.js create mode 100644 src/utils.js create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore index 527cb86..16aadca 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ out/ ### .env ### .env +.env.* + +### Node.js ### +node_modules/ +dist/ +*.log diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e3d9d0a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,942 @@ +{ + "name": "recycle-study-extension", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recycle-study-extension", + "version": "1.0.0", + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2f73fa7 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "recycle-study-extension", + "version": "1.0.0", + "description": "복습 URL 저장 크롬 익스텐션", + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/public/icons/icon128.png b/public/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..421194b623695eae0abb853fde0f4e3bf860e8c5 GIT binary patch literal 19463 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 JPDHLkV1oNFO40xT literal 0 HcmV?d00001 diff --git a/public/icons/icon48.png b/public/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..db5747ec3ecc50b7f1a417205f209909c51baa0a GIT binary patch literal 3965 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 X?w9bD0e%BU00000NkvXXu0mjfsoAl} literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..d7882d9 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Recycle Study", + "version": "1.0.0", + "description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션", + "permissions": [ + "storage", + "activeTab", + "tabs" + ], + "host_permissions": [ + "http://localhost:8080/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/public/popup.css b/public/popup.css new file mode 100644 index 0000000..986e745 --- /dev/null +++ b/public/popup.css @@ -0,0 +1,333 @@ +/* 기본 스타일 초기화 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + color: #333; + background-color: #fff; +} + +.container { + width: 320px; + min-height: 200px; + padding: 16px; +} + +/* 헤더 */ +.header { + text-align: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #e0e0e0; +} + +.header h1 { + font-size: 18px; + font-weight: 600; + color: #2c3e50; +} + +/* 뷰 전환 */ +.view { + display: block; +} + +.hidden { + display: none !important; +} + +/* 폼 요소 */ +.form-group { + margin-bottom: 12px; +} + +.form-group label { + display: block; + margin-bottom: 4px; + font-size: 12px; + font-weight: 500; + color: #666; +} + +.form-group input { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus { + outline: none; + border-color: #3498db; +} + +/* 버튼 */ +.btn { + display: block; + width: 100%; + padding: 10px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, opacity 0.2s; +} + +.btn:hover { + opacity: 0.9; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: #3498db; + color: #fff; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #ecf0f1; + color: #333; +} + +.btn-secondary:hover { + background-color: #d5dbdb; +} + +.btn-text { + background: none; + color: #7f8c8d; + font-size: 12px; + margin-top: 8px; +} + +.btn-text:hover { + color: #e74c3c; +} + +.btn-large { + padding: 14px 16px; + font-size: 16px; +} + +.btn-danger { + background-color: #e74c3c; + color: #fff; + padding: 6px 12px; + font-size: 12px; + width: auto; +} + +/* 힌트 텍스트 */ +.hint { + margin-top: 12px; + font-size: 12px; + color: #95a5a6; + text-align: center; +} + +/* 인증 대기 화면 */ +.pending-message { + text-align: center; + padding: 20px 0; +} + +.pending-message p { + margin-bottom: 8px; +} + +.email-display { + font-weight: 600; + color: #3498db; +} + +/* 메인 화면 */ +.user-info { + text-align: center; + margin-bottom: 16px; + padding: 8px; + background-color: #f8f9fa; + border-radius: 6px; + font-size: 12px; + color: #666; +} + +/* 저장 결과 */ +.result { + margin-top: 16px; + padding: 12px; + background-color: #d5f5e3; + border-radius: 6px; +} + +.result-title { + font-weight: 600; + color: #27ae60; + margin-bottom: 8px; +} + +.schedule-list { + font-size: 12px; + color: #333; +} + +.schedule-list p { + margin-bottom: 4px; + font-weight: 500; +} + +.schedule-list ul { + list-style: none; + padding-left: 0; +} + +.schedule-list li { + padding: 4px 0; + color: #555; +} + +/* 구분선 */ +.divider { + border: none; + border-top: 1px solid #e0e0e0; + margin: 16px 0; +} + +/* 디바이스 목록 */ +.devices-list { + list-style: none; + margin-top: 12px; +} + +.devices-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + margin-bottom: 8px; + background-color: #f8f9fa; + border-radius: 6px; + font-size: 12px; +} + +.device-info { + flex: 1; +} + +.device-id { + font-weight: 500; + word-break: break-all; +} + +.device-date { + color: #95a5a6; + font-size: 11px; +} + +.current-device { + color: #27ae60; + font-size: 10px; + font-weight: 600; +} + +/* 메시지 영역 */ +.message-area { + margin-top: 12px; + padding: 10px; + border-radius: 6px; + font-size: 13px; + text-align: center; +} + +.message-area.error { + background-color: #fadbd8; + color: #c0392b; +} + +.message-area.success { + background-color: #d5f5e3; + color: #27ae60; +} + +.message-area.info { + background-color: #d6eaf8; + color: #2980b9; +} + +/* 로딩 */ +.loading { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; +} + +.spinner { + width: 30px; + height: 30px; + border: 3px solid #e0e0e0; + border-top-color: #3498db; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 확인 다이얼로그 */ +.confirm-dialog { + 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: 100; +} + +.confirm-content { + background: #fff; + padding: 20px; + border-radius: 8px; + text-align: center; + max-width: 280px; +} + +.confirm-content p { + margin-bottom: 16px; +} + +.confirm-buttons { + display: flex; + gap: 8px; +} + +.confirm-buttons .btn { + flex: 1; +} diff --git a/public/popup.html b/public/popup.html new file mode 100644 index 0000000..60f7628 --- /dev/null +++ b/public/popup.html @@ -0,0 +1,76 @@ + + + + + + Recycle Study + + + +
+
+

Recycle Study

+
+ + +
+
+ + +
+ +

이메일 인증 후 서비스를 이용할 수 있습니다.

+
+ + + + + + + + + + + + +
+ + + + diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..6b65d26 --- /dev/null +++ b/src/api.js @@ -0,0 +1,102 @@ +/** + * API 호출 관련 함수 + * + * 서버와의 통신을 담당하며, 디바이스 등록/조회/삭제, 복습 URL 저장 등의 API를 제공한다. + */ + +import { CONFIG } from './config.js'; +import { ERROR_CODES } from './constants.js'; +import { ApiError, getErrorCodeFromStatus } from './errors.js'; + +/** + * API 요청 래퍼 (공통 에러 처리) + * @param {string} url - 요청 URL + * @param {Object} options - fetch 옵션 + * @returns {Promise} 응답 데이터 + * @throws {ApiError} + */ +async function apiRequest(url, options = {}) { + let response; + + try { + response = await fetch(url, options); + } catch (error) { + throw new ApiError(ERROR_CODES.NETWORK_ERROR, error.message); + } + + // 204 No Content인 경우 (DELETE 성공 등) + if (response.status === 204) { + return null; + } + + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error('Failed to parse JSON response:', parseError); + data = { message: 'Invalid JSON response from server.' }; + } + + if (!response.ok) { + const errorCode = getErrorCodeFromStatus(response.status); + throw new ApiError(errorCode, data.message); + } + + return data; +} + +/** + * 디바이스 등록 (회원가입) + * @param {string} email - 사용자 이메일 + * @returns {Promise} { email, identifier } + */ +export async function registerDevice(email) { + return await apiRequest(`${CONFIG.BASE_URL}/api/v1/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); +} + +/** + * 디바이스 목록 조회 + * @param {string} email - 사용자 이메일 + * @param {string} identifier - 디바이스 식별자 + * @returns {Promise} { email, devices } + */ +export async function getDevices(email, identifier) { + const params = new URLSearchParams({ email, identifier }); + return await apiRequest(`${CONFIG.BASE_URL}/api/v1/members?${params}`); +} + +/** + * 디바이스 삭제 + * @param {string} email - 사용자 이메일 + * @param {string} deviceIdentifier - 현재 디바이스 식별자 + * @param {string} targetDeviceIdentifier - 삭제할 디바이스 식별자 + */ +export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifier) { + return await apiRequest(`${CONFIG.BASE_URL}/api/v1/device`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + deviceIdentifier, + targetDeviceIdentifier + }) + }); +} + +/** + * 복습 URL 저장 + * @param {string} identifier - 디바이스 식별자 + * @param {string} targetUrl - 저장할 URL + * @returns {Promise} { url, scheduledAts } + */ +export async function saveReviewUrl(identifier, targetUrl) { + return await apiRequest(`${CONFIG.BASE_URL}/api/v1/reviews`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier, targetUrl }) + }); +} diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..c0ee00d --- /dev/null +++ b/src/background.js @@ -0,0 +1,25 @@ +/** + * 백그라운드 서비스 워커 + * + * 익스텐션 설치/업데이트 이벤트 처리 및 popup과의 메시지 통신을 담당한다. + */ + +// ============================================ +// 익스텐션 설치/업데이트 이벤트 +// ============================================ +chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + console.log('Recycle Study 익스텐션이 설치되었습니다.'); + } else if (details.reason === 'update') { + console.log('Recycle Study 익스텐션이 업데이트되었습니다.'); + } +}); + +// ============================================ +// 메시지 리스너 (popup.js와 통신용) +// ============================================ +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'CHECK_AUTH') { + sendResponse({ success: true }); + } +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b689717 --- /dev/null +++ b/src/config.js @@ -0,0 +1,16 @@ +/** + * 환경 설정 + * + * 개발: vite build --mode dev (.env.dev 사용) + * 프로덕션: vite build --mode prod (.env.prod 사용) + */ +export const CONFIG = { + BASE_URL: import.meta.env.VITE_BASE_URL || 'http://localhost:8080', + ENV: import.meta.env.MODE || 'development' +}; + +export const STORAGE_KEYS = { + EMAIL: 'email', + IDENTIFIER: 'identifier', + IS_AUTHENTICATED: 'isAuthenticated' +}; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..13a535f --- /dev/null +++ b/src/constants.js @@ -0,0 +1,25 @@ +/** + * 상수 정의 + * + * 에러 코드 및 자동 로그아웃이 필요한 에러 목록을 정의한다. + */ +export const ERROR_CODES = { + // 로그아웃이 필요한 에러 + UNAUTHORIZED: 'UNAUTHORIZED', // 401: 인증되지 않은 디바이스 + NOT_FOUND: 'NOT_FOUND', // 404: 존재하지 않는 리소스 + INVALID_STORAGE: 'INVALID_STORAGE', // 스토리지 데이터 손상 + + // 로그아웃 불필요한 에러 + BAD_REQUEST: 'BAD_REQUEST', // 400: 잘못된 요청 + SERVER_ERROR: 'SERVER_ERROR', // 5xx: 서버 오류 + NETWORK_ERROR: 'NETWORK_ERROR' // 네트워크 연결 실패 +}; + +/** + * 자동 로그아웃이 필요한 에러 코드 + */ +export const LOGOUT_REQUIRED_ERRORS = [ + ERROR_CODES.UNAUTHORIZED, + ERROR_CODES.NOT_FOUND, + ERROR_CODES.INVALID_STORAGE +]; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..bc1a7af --- /dev/null +++ b/src/errors.js @@ -0,0 +1,63 @@ +/** + * 에러 처리 관련 함수 + * + * ApiError 클래스, HTTP 상태 코드 변환, 사용자 메시지 생성, 로그아웃 필요 여부 판단 등을 제공한다. + */ + +import { ERROR_CODES, LOGOUT_REQUIRED_ERRORS } from './constants.js'; + +/** + * API 에러 클래스 + */ +export class ApiError extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = 'ApiError'; + } +} + +/** + * HTTP 상태 코드를 에러 코드로 변환 + * @param {number} status - HTTP 상태 코드 + * @returns {string} 에러 코드 + */ +export function getErrorCodeFromStatus(status) { + if (status === 401) return ERROR_CODES.UNAUTHORIZED; + if (status === 404) return ERROR_CODES.NOT_FOUND; + if (status === 400) return ERROR_CODES.BAD_REQUEST; + if (status >= 500) return ERROR_CODES.SERVER_ERROR; + return ERROR_CODES.BAD_REQUEST; +} + +/** + * 에러 코드에 따른 사용자 메시지 생성 + * @param {string} code - 에러 코드 + * @param {string} serverMessage - 서버에서 받은 메시지 + * @returns {string} 사용자에게 표시할 메시지 + */ +export function getErrorMessage(code, serverMessage) { + switch (code) { + case ERROR_CODES.UNAUTHORIZED: + return '인증 정보가 유효하지 않습니다. 다시 로그인해주세요.'; + case ERROR_CODES.NOT_FOUND: + return '계정 정보를 찾을 수 없습니다. 다시 등록해주세요.'; + case ERROR_CODES.INVALID_STORAGE: + return '저장된 정보가 손상되었습니다. 다시 로그인해주세요.'; + case ERROR_CODES.SERVER_ERROR: + return '서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.'; + case ERROR_CODES.NETWORK_ERROR: + return '서버에 연결할 수 없습니다. 네트워크를 확인해주세요.'; + default: + return serverMessage || '오류가 발생했습니다.'; + } +} + +/** + * 로그아웃이 필요한 에러인지 확인 + * @param {string} code - 에러 코드 + * @returns {boolean} + */ +export function isLogoutRequiredError(code) { + return LOGOUT_REQUIRED_ERRORS.includes(code); +} diff --git a/src/handlers.js b/src/handlers.js new file mode 100644 index 0000000..9f9878e --- /dev/null +++ b/src/handlers.js @@ -0,0 +1,232 @@ +/** + * 이벤트 핸들러 + * + * 디바이스 등록, 인증 확인, 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 { + elements, + showLoading, + hideLoading, + 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(); + } +} + +/** + * 디바이스 관리 버튼 클릭 핸들러 + */ +export async function handleShowDevices() { + const isVisible = !elements.devicesSection.classList.contains('hidden'); + + if (isVisible) { + elements.devicesSection.classList.add('hidden'); + return; + } + + try { + showLoading(); + const storageData = await validateStorageForAuth(); + const result = await getDevices(storageData.email, storageData.identifier); + + elements.devicesList.innerHTML = ''; + + result.devices.forEach(device => { + const li = document.createElement('li'); + const isCurrentDevice = device.identifier === storageData.identifier; + + const deviceInfo = document.createElement('div'); + deviceInfo.className = 'device-info'; + + const deviceIdDiv = document.createElement('div'); + deviceIdDiv.className = 'device-id'; + deviceIdDiv.textContent = device.identifier.substring(0, 20) + '...'; + deviceInfo.appendChild(deviceIdDiv); + + const deviceDateDiv = document.createElement('div'); + deviceDateDiv.className = 'device-date'; + deviceDateDiv.textContent = formatDate(device.createdAt); + deviceInfo.appendChild(deviceDateDiv); + + if (isCurrentDevice) { + const currentDeviceDiv = document.createElement('div'); + currentDeviceDiv.className = 'current-device'; + currentDeviceDiv.textContent = '현재 디바이스'; + deviceInfo.appendChild(currentDeviceDiv); + } + + li.appendChild(deviceInfo); + + if (!isCurrentDevice) { + const deleteButton = document.createElement('button'); + deleteButton.className = 'btn btn-danger'; + deleteButton.setAttribute('data-id', device.identifier); + deleteButton.textContent = '삭제'; + li.appendChild(deleteButton); + } + + elements.devicesList.appendChild(li); + }); + + elements.devicesSection.classList.remove('hidden'); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} + +/** + * 디바이스 삭제 클릭 핸들러 + * @param {string} targetIdentifier - 삭제할 디바이스 식별자 + */ +export async function handleDeleteDevice(targetIdentifier) { + if (!confirm('이 디바이스를 삭제하시겠습니까?')) { + return; + } + + try { + showLoading(); + const storageData = await validateStorageForAuth(); + + await deleteDevice( + storageData.email, + storageData.identifier, + targetIdentifier + ); + + showMessage('디바이스가 삭제되었습니다.', 'success'); + elements.devicesSection.classList.add('hidden'); + await handleShowDevices(); + } catch (error) { + await handleApiError(error); + } finally { + hideLoading(); + } +} + +/** + * 로그아웃 버튼 클릭 핸들러 + */ +export async function handleLogout() { + if (!confirm('로그아웃 하시겠습니까?')) { + return; + } + + await clearStorage(); + elements.saveResult.classList.add('hidden'); + elements.devicesSection.classList.add('hidden'); + showView('login'); + showMessage('로그아웃 되었습니다.', 'info'); +} diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..b72c286 --- /dev/null +++ b/src/popup.js @@ -0,0 +1,90 @@ +/** + * 팝업 진입점 + * + * DOM 로드 후 UI 초기화, 이벤트 리스너 등록, 인증 상태에 따른 뷰 전환을 수행한다. + */ + +import { getStorageData, clearStorage, isStorageDataValid } from './storage.js'; +import { + elements, + initializeElements, + showView, + showMessage +} from './ui.js'; +import { + handleRegister, + handleCheckAuth, + handleReset, + handleSaveUrl, + handleShowDevices, + handleDeleteDevice, + handleLogout +} from './handlers.js'; + +/** + * 이벤트 리스너 등록 + */ +function setupEventListeners() { + elements.registerBtn.addEventListener('click', handleRegister); + elements.checkAuthBtn.addEventListener('click', handleCheckAuth); + elements.resetBtn.addEventListener('click', handleReset); + elements.saveUrlBtn.addEventListener('click', handleSaveUrl); + elements.showDevicesBtn.addEventListener('click', handleShowDevices); + elements.logoutBtn.addEventListener('click', handleLogout); + + // 디바이스 삭제 버튼 (이벤트 위임) + elements.devicesList.addEventListener('click', (e) => { + if (e.target.classList.contains('btn-danger')) { + const targetId = e.target.dataset.id; + handleDeleteDevice(targetId); + } + }); + + // 엔터키로 등록 + elements.emailInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + handleRegister(); + } + }); +} + +/** + * 앱 초기화 + */ +async function initialize() { + try { + const storageData = await getStorageData(); + + // 스토리지 데이터 유효성 검증 + if (!isStorageDataValid(storageData)) { + console.warn('손상된 스토리지 데이터 감지, 초기화 진행'); + await clearStorage(); + showView('login'); + showMessage('저장된 정보에 문제가 있어 초기화되었습니다.', 'info'); + return; + } + + if (storageData.isAuthenticated) { + elements.userEmail.textContent = storageData.email; + showView('main'); + } else if (storageData.email && storageData.identifier) { + elements.emailDisplay.textContent = storageData.email; + showView('pending'); + } else { + showView('login'); + } + } catch (error) { + console.error('초기화 오류:', error); + await clearStorage(); + showView('login'); + } +} + +/** + * DOM 로드 후 실행 + */ +document.addEventListener('DOMContentLoaded', () => { + initializeElements(); + setupEventListeners(); + initialize(); +}); diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..c73ac16 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,83 @@ +/** + * Chrome Storage 관련 함수 + * + * 로컬 스토리지를 통해 이메일, 디바이스 식별자, 인증 상태를 저장하고 관리한다. + */ + +import { STORAGE_KEYS } from './config.js'; +import { ERROR_CODES } from './constants.js'; +import { ApiError } from './errors.js'; + +/** + * 스토리지에서 데이터 가져오기 + * @returns {Promise} 저장된 데이터 + */ +export async function getStorageData() { + return await chrome.storage.local.get([ + STORAGE_KEYS.EMAIL, + STORAGE_KEYS.IDENTIFIER, + STORAGE_KEYS.IS_AUTHENTICATED + ]); +} + +/** + * 스토리지에 데이터 저장 + * @param {Object} data - 저장할 데이터 + */ +export async function setStorageData(data) { + await chrome.storage.local.set(data); +} + +/** + * 스토리지 초기화 + */ +export async function clearStorage() { + await chrome.storage.local.remove([ + STORAGE_KEYS.EMAIL, + STORAGE_KEYS.IDENTIFIER, + STORAGE_KEYS.IS_AUTHENTICATED + ]); +} + +/** + * 스토리지 데이터 유효성 검증 + * @param {Object} data - 검증할 데이터 + * @returns {boolean} 유효하면 true + */ +export function isStorageDataValid(data) { + // 인증 완료 상태라면 email과 identifier가 모두 있어야 함 + if (data.isAuthenticated) { + return !!(data.email && data.identifier); + } + + // 인증 대기 상태 (email, identifier 있고 isAuthenticated는 false) + if (data.email && data.identifier) { + return true; + } + + // 미등록 상태 (모두 없으면 정상) + if (!data.email && !data.identifier && !data.isAuthenticated) { + return true; + } + + // 일부만 있는 경우는 손상된 상태 + return false; +} + +/** + * 인증이 필요한 작업 전 스토리지 검증 + * @throws {ApiError} 스토리지가 손상된 경우 + * @returns {Promise} 검증된 스토리지 데이터 + */ +export async function validateStorageForAuth() { + const data = await getStorageData(); + + if (!data.email || !data.identifier) { + throw new ApiError( + ERROR_CODES.INVALID_STORAGE, + '저장된 인증 정보가 없습니다.' + ); + } + + return data; +} diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 0000000..481b19e --- /dev/null +++ b/src/ui.js @@ -0,0 +1,153 @@ +/** + * 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/utils.js b/src/utils.js new file mode 100644 index 0000000..d68d51c --- /dev/null +++ b/src/utils.js @@ -0,0 +1,31 @@ +/** + * 유틸리티 함수 + * + * 날짜 포맷팅, 이메일 검증 등 범용 헬퍼 함수를 정의한다. + */ + +/** + * 날짜 포맷팅 + * @param {string} dateString - ISO 형식 날짜 문자열 + * @returns {string} 포맷된 날짜 문자열 + */ +export function formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +/** + * 이메일 형식 검증 + * @param {string} email - 검증할 이메일 + * @returns {boolean} 유효하면 true + */ +export function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..7752f84 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + publicDir: 'public', + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + popup: resolve(__dirname, 'src/popup.js'), + background: resolve(__dirname, 'src/background.js') + }, + output: { + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name].js', + assetFileNames: '[name].[ext]' + } + } + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + } +}); From 1b2496ef5dc150c040caee096090353bdc1495e4 Mon Sep 17 00:00:00 2001 From: jhan0121 <56645802+jhan0121@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:17:13 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20CORS=20=EC=9A=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20API=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 71 +++++++++++++++++++---------------------------- src/background.js | 59 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 44 deletions(-) diff --git a/src/api.js b/src/api.js index 6b65d26..40bc5ee 100644 --- a/src/api.js +++ b/src/api.js @@ -2,47 +2,33 @@ * API 호출 관련 함수 * * 서버와의 통신을 담당하며, 디바이스 등록/조회/삭제, 복습 URL 저장 등의 API를 제공한다. + * 모든 API 요청은 background.js를 통해 처리되어 CORS 문제를 우회한다. */ -import { CONFIG } from './config.js'; import { ERROR_CODES } from './constants.js'; import { ApiError, getErrorCodeFromStatus } from './errors.js'; /** - * API 요청 래퍼 (공통 에러 처리) - * @param {string} url - 요청 URL - * @param {Object} options - fetch 옵션 + * Background Script로 API 요청 전송 + * @param {Object} request - API 요청 정보 * @returns {Promise} 응답 데이터 * @throws {ApiError} */ -async function apiRequest(url, options = {}) { - let response; - - try { - response = await fetch(url, options); - } catch (error) { - throw new ApiError(ERROR_CODES.NETWORK_ERROR, error.message); - } - - // 204 No Content인 경우 (DELETE 성공 등) - if (response.status === 204) { - return null; - } - - let data; - try { - data = await response.json(); - } catch (parseError) { - console.error('Failed to parse JSON response:', parseError); - data = { message: 'Invalid JSON response from server.' }; - } +async function sendApiRequest(request) { + const response = await chrome.runtime.sendMessage({ + type: 'API_REQUEST', + request + }); - if (!response.ok) { + if (!response.success) { + if (response.isNetworkError) { + throw new ApiError(ERROR_CODES.NETWORK_ERROR, response.message); + } const errorCode = getErrorCodeFromStatus(response.status); - throw new ApiError(errorCode, data.message); + throw new ApiError(errorCode, response.message); } - return data; + return response.data; } /** @@ -51,10 +37,10 @@ async function apiRequest(url, options = {}) { * @returns {Promise} { email, identifier } */ export async function registerDevice(email) { - return await apiRequest(`${CONFIG.BASE_URL}/api/v1/members`, { + return await sendApiRequest({ + endpoint: '/api/v1/members', method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }) + body: { email } }); } @@ -65,8 +51,11 @@ export async function registerDevice(email) { * @returns {Promise} { email, devices } */ export async function getDevices(email, identifier) { - const params = new URLSearchParams({ email, identifier }); - return await apiRequest(`${CONFIG.BASE_URL}/api/v1/members?${params}`); + return await sendApiRequest({ + endpoint: '/api/v1/members', + method: 'GET', + params: { email, identifier } + }); } /** @@ -76,14 +65,10 @@ export async function getDevices(email, identifier) { * @param {string} targetDeviceIdentifier - 삭제할 디바이스 식별자 */ export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifier) { - return await apiRequest(`${CONFIG.BASE_URL}/api/v1/device`, { + return await sendApiRequest({ + endpoint: '/api/v1/device', method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email, - deviceIdentifier, - targetDeviceIdentifier - }) + body: { email, deviceIdentifier, targetDeviceIdentifier } }); } @@ -94,9 +79,9 @@ export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifi * @returns {Promise} { url, scheduledAts } */ export async function saveReviewUrl(identifier, targetUrl) { - return await apiRequest(`${CONFIG.BASE_URL}/api/v1/reviews`, { + return await sendApiRequest({ + endpoint: '/api/v1/reviews', method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, targetUrl }) + body: { identifier, targetUrl } }); } diff --git a/src/background.js b/src/background.js index c0ee00d..bcf430a 100644 --- a/src/background.js +++ b/src/background.js @@ -2,8 +2,11 @@ * 백그라운드 서비스 워커 * * 익스텐션 설치/업데이트 이벤트 처리 및 popup과의 메시지 통신을 담당한다. + * API 요청은 CORS 우회를 위해 이 서비스 워커에서 처리한다. */ +import { CONFIG } from './config.js'; + // ============================================ // 익스텐션 설치/업데이트 이벤트 // ============================================ @@ -16,9 +19,63 @@ chrome.runtime.onInstalled.addListener((details) => { }); // ============================================ -// 메시지 리스너 (popup.js와 통신용) +// API 프록시 핸들러 +// ============================================ +async function handleApiRequest(request) { + const { endpoint, method = 'GET', body, params } = request; + + let url = `${CONFIG.BASE_URL}${endpoint}`; + if (params) { + url += `?${new URLSearchParams(params)}`; + } + + const options = { + method, + headers: { 'Content-Type': 'application/json' } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + + // 204 No Content + if (response.status === 204) { + return { success: true, data: null }; + } + + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error('Failed to parse JSON response:', parseError); + data = { message: 'Invalid JSON response from server.' }; + } + + if (!response.ok) { + console.error('API request failed:', { url, status: response.status, data }); + return { success: false, status: response.status, message: data.message }; + } + + return { success: true, data }; + } catch (error) { + console.error('Network request failed:', { url, error }); + return { success: false, status: 0, message: error.message, isNetworkError: true }; + } +} + +// ============================================ +// 메시지 리스너 (popup과 통신) // ============================================ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'API_REQUEST') { + handleApiRequest(message.request) + .then(sendResponse); + return true; // 비동기 응답을 위해 true 반환 + } + if (message.type === 'CHECK_AUTH') { sendResponse({ success: true }); } From 4b3f6f3afc678e6ebe9f4933087c9c8d1e35543e Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:25:43 +0900 Subject: [PATCH 3/9] =?UTF-8?q?CICD=20=EC=9E=90=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 운영/개발 서버 환경 관리 추가 * feat: 배포 스크립트 추가 * test: 테스트 의존성 추가 및 작성 * feat: 테스트 검증 자동화 스크립트 추가 * feat: 배포 스크립트 추가 --- .github/workflows/release.yml | 76 ++ .github/workflows/test-validation.yml | 34 + package-lock.json | 971 +++++++++++++++++++++++++- package.json | 7 +- public/manifest.json | 1 + src/__tests__/errors.test.js | 54 ++ src/__tests__/utils.test.js | 29 + src/config.js | 4 +- vite.config.js | 5 + 9 files changed, 1176 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test-validation.yml create mode 100644 src/__tests__/errors.test.js create mode 100644 src/__tests__/utils.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b23b65 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release Extension + +concurrency: + group: ext-release + cancel-in-progress: true + +on: + push: + tags: + - 'v*-ext' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag is on prod branch + run: | + git fetch origin prod + if git merge-base --is-ancestor ${{ github.sha }} origin/prod; then + echo "이 태그는 prod branch에 포함되어 있습니다. 릴리즈를 진행합니다." + else + echo "Error: 이 tag는 prod branch에 없습니다." + exit 1 + fi + + - name: Validate version match + run: | + TAG_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//' | sed 's/-ext$//') + MANIFEST_VERSION=$(node -p "require('./public/manifest.json').version") + + echo "Tag version: $TAG_VERSION" + echo "Manifest version: $MANIFEST_VERSION" + + if [ "$TAG_VERSION" != "$MANIFEST_VERSION" ]; then + echo "Error: 태그 버전($TAG_VERSION)과 manifest.json 버전($MANIFEST_VERSION)이 일치하지 않습니다." + exit 1 + fi + + echo "버전이 일치합니다." + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test:run + + - name: Build extension + run: npm run build + env: + VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} + + - name: Create ZIP file + run: | + cd dist + zip -r ../recycle-study-extension-${{ github.ref_name }}.zip . + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: recycle-study-extension-${{ github.ref_name }}.zip + generate_release_notes: true + draft: true diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml new file mode 100644 index 0000000..e2f21d4 --- /dev/null +++ b/.github/workflows/test-validation.yml @@ -0,0 +1,34 @@ +name: Test & Build Validation + +on: + pull_request: + branches: + - dev + - prod + push: + branches: + - dev + - prod + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test:run + + - name: Build + run: npm run build diff --git a/package-lock.json b/package-lock.json index e3d9d0a..79ac838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "recycle-study-extension", "version": "1.0.0", "devDependencies": { - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^4.0.16" } }, "node_modules/@esbuild/aix-ppc64": { @@ -300,6 +301,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -317,6 +335,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -334,6 +369,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -402,6 +454,13 @@ "node": ">=12" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", @@ -710,6 +769,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -717,6 +801,117 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -756,6 +951,44 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -771,6 +1004,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -790,6 +1033,24 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -797,6 +1058,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -868,6 +1142,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -878,6 +1159,64 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -937,6 +1276,636 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 2f73fa7..6f8315d 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "scripts": { "dev": "vite build --watch", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" }, "devDependencies": { - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^4.0.16" } } diff --git a/public/manifest.json b/public/manifest.json index d7882d9..87be2e1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -9,6 +9,7 @@ "tabs" ], "host_permissions": [ + "https://api.recycle-study.site/*", "http://localhost:8080/*" ], "action": { diff --git a/src/__tests__/errors.test.js b/src/__tests__/errors.test.js new file mode 100644 index 0000000..416340c --- /dev/null +++ b/src/__tests__/errors.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { ERROR_CODES } from '../constants.js'; +import { + ApiError, + getErrorCodeFromStatus, + getErrorMessage, + isLogoutRequiredError +} from '../errors.js'; + +describe('ApiError', () => { + it('code와 message를 저장한다', () => { + const error = new ApiError(ERROR_CODES.UNAUTHORIZED, '인증 실패'); + expect(error.code).toBe(ERROR_CODES.UNAUTHORIZED); + expect(error.message).toBe('인증 실패'); + expect(error.name).toBe('ApiError'); + }); +}); + +describe('getErrorCodeFromStatus', () => { + it('401은 UNAUTHORIZED를 반환한다', () => { + expect(getErrorCodeFromStatus(401)).toBe(ERROR_CODES.UNAUTHORIZED); + }); + + it('404는 NOT_FOUND를 반환한다', () => { + expect(getErrorCodeFromStatus(404)).toBe(ERROR_CODES.NOT_FOUND); + }); + + it('400은 BAD_REQUEST를 반환한다', () => { + expect(getErrorCodeFromStatus(400)).toBe(ERROR_CODES.BAD_REQUEST); + }); + + it('5xx는 SERVER_ERROR를 반환한다', () => { + expect(getErrorCodeFromStatus(500)).toBe(ERROR_CODES.SERVER_ERROR); + expect(getErrorCodeFromStatus(503)).toBe(ERROR_CODES.SERVER_ERROR); + }); +}); + +describe('isLogoutRequiredError', () => { + it('UNAUTHORIZED는 로그아웃 필요', () => { + expect(isLogoutRequiredError(ERROR_CODES.UNAUTHORIZED)).toBe(true); + }); + + it('NOT_FOUND는 로그아웃 필요', () => { + expect(isLogoutRequiredError(ERROR_CODES.NOT_FOUND)).toBe(true); + }); + + it('NETWORK_ERROR는 로그아웃 불필요', () => { + expect(isLogoutRequiredError(ERROR_CODES.NETWORK_ERROR)).toBe(false); + }); + + it('SERVER_ERROR는 로그아웃 불필요', () => { + expect(isLogoutRequiredError(ERROR_CODES.SERVER_ERROR)).toBe(false); + }); +}); diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js new file mode 100644 index 0000000..82d7519 --- /dev/null +++ b/src/__tests__/utils.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { isValidEmail, formatDate } from '../utils.js'; + +describe('isValidEmail', () => { + it('유효한 이메일 형식을 통과시킨다', () => { + expect(isValidEmail('test@example.com')).toBe(true); + expect(isValidEmail('user.name@domain.co.kr')).toBe(true); + }); + + it('잘못된 이메일 형식을 거부한다', () => { + expect(isValidEmail('invalid')).toBe(false); + expect(isValidEmail('no-at-sign.com')).toBe(false); + expect(isValidEmail('@no-local.com')).toBe(false); + expect(isValidEmail('no-domain@')).toBe(false); + }); + + it('빈 문자열을 거부한다', () => { + expect(isValidEmail('')).toBe(false); + }); +}); + +describe('formatDate', () => { + it('ISO 날짜 문자열을 한국어 형식으로 변환한다', () => { + const result = formatDate('2024-01-15T10:30:00'); + expect(result).toContain('2024'); + expect(result).toContain('1월'); + expect(result).toContain('15'); + }); +}); diff --git a/src/config.js b/src/config.js index b689717..943b4c2 100644 --- a/src/config.js +++ b/src/config.js @@ -1,8 +1,8 @@ /** * 환경 설정 * - * 개발: vite build --mode dev (.env.dev 사용) - * 프로덕션: vite build --mode prod (.env.prod 사용) + * 개발: vite build (.env.development 사용, 기본값) + * 프로덕션: vite build --mode production (.env.production 사용) */ export const CONFIG = { BASE_URL: import.meta.env.VITE_BASE_URL || 'http://localhost:8080', diff --git a/vite.config.js b/vite.config.js index 7752f84..739727c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,13 @@ +/// import { defineConfig } from 'vite'; import { resolve } from 'path'; export default defineConfig({ publicDir: 'public', + test: { + globals: true, + environment: 'node' + }, build: { outDir: 'dist', emptyOutDir: true, From 8be0f06b6a8e5fbf216c402b207b079512031c54 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:35:40 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-validation.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-validation.yml b/.github/workflows/test-validation.yml index e2f21d4..7f1f5da 100644 --- a/.github/workflows/test-validation.yml +++ b/.github/workflows/test-validation.yml @@ -2,10 +2,8 @@ name: Test & Build Validation on: pull_request: - branches: - - dev - - prod - push: + types: + [ opened, synchronize, reopened ] branches: - dev - prod From 68ea07f17035635f7d43b50e5b1d91197c9710a6 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:04:03 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20v1.0.1-ext=20=EB=B0=B0=ED=8F=AC=20(?= =?UTF-8?q?#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/manifest.json b/public/manifest.json index 87be2e1..2bf0902 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Recycle Study", - "version": "1.0.0", + "version": "1.0.1", "description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션", "permissions": [ "storage", From 0f84980d1ab4a138403f7105c8edef54a583616f Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:55:48 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EC=9E=90=20=EC=A0=84=EC=86=A1=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 디바이스 관련 api 호출 구조 변경 * fix: 로그아웃 시 디바이스 id 삭제 api 호출 추가 * test: 테스트 추가 * build: dev 빌드 옵션 추가 --- package.json | 1 + src/__tests__/api.test.js | 127 ++++++++++++++++++++++++++++++ src/__tests__/background.test.js | 130 +++++++++++++++++++++++++++++++ src/api-proxy.js | 54 +++++++++++++ src/api.js | 9 ++- src/background.js | 48 +----------- src/handlers.js | 18 +++++ 7 files changed, 337 insertions(+), 50 deletions(-) create mode 100644 src/__tests__/api.test.js create mode 100644 src/__tests__/background.test.js create mode 100644 src/api-proxy.js diff --git a/package.json b/package.json index 6f8315d..b052cb5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite build --watch", "build": "vite build", + "build:dev": "vite build --mode development", "preview": "vite preview", "test": "vitest", "test:run": "vitest run" diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js new file mode 100644 index 0000000..7ba2ebe --- /dev/null +++ b/src/__tests__/api.test.js @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { registerDevice, getDevices, deleteDevice, saveReviewUrl } from '../api.js'; +import { ERROR_CODES } from '../constants.js'; + +// Mock chrome.runtime.sendMessage +const sendMessageMock = vi.fn(); +global.chrome = { + runtime: { + sendMessage: sendMessageMock + } +}; + +describe('api.js', () => { + beforeEach(() => { + sendMessageMock.mockReset(); + }); + + describe('registerDevice', () => { + it('이메일로 등록 요청을 보낸다', async () => { + const email = 'test@example.com'; + const mockResponse = { success: true, data: { email, identifier: 'dev-id' } }; + sendMessageMock.mockResolvedValue(mockResponse); + + const result = await registerDevice(email); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/members', + method: 'POST', + body: { email } + } + }); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('getDevices', () => { + it('식별자를 헤더에 포함하여 디바이스 목록을 조회한다', async () => { + const email = 'test@example.com'; + const identifier = 'my-device-id'; + const mockResponse = { success: true, data: { devices: [] } }; + sendMessageMock.mockResolvedValue(mockResponse); + + await getDevices(email, identifier); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/members', + method: 'GET', + params: { email }, + headers: { 'X-Device-Id': identifier } + } + }); + }); + }); + + describe('deleteDevice', () => { + it('식별자를 헤더에 포함하여 디바이스 삭제 요청을 보낸다', async () => { + const email = 'test@example.com'; + const deviceIdentifier = 'my-device-id'; + const targetDeviceIdentifier = 'target-id'; + const mockResponse = { success: true, data: null }; + sendMessageMock.mockResolvedValue(mockResponse); + + await deleteDevice(email, deviceIdentifier, targetDeviceIdentifier); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/device', + method: 'DELETE', + headers: { 'X-Device-Id': deviceIdentifier }, + body: { email, targetDeviceIdentifier } + } + }); + }); + }); + + describe('saveReviewUrl', () => { + it('식별자를 헤더에 포함하여 URL 저장 요청을 보낸다', async () => { + const identifier = 'my-device-id'; + const targetUrl = 'https://example.com'; + const mockResponse = { success: true, data: { url: targetUrl } }; + sendMessageMock.mockResolvedValue(mockResponse); + + await saveReviewUrl(identifier, targetUrl); + + expect(sendMessageMock).toHaveBeenCalledWith({ + type: 'API_REQUEST', + request: { + endpoint: '/api/v1/reviews', + method: 'POST', + headers: { 'X-Device-Id': identifier }, + body: { targetUrl } + } + }); + }); + }); + + describe('Error Handling', () => { + it('API 실패 시 에러를 던진다', async () => { + sendMessageMock.mockResolvedValue({ + success: false, + status: 400, + message: 'Bad Request' + }); + + await expect(registerDevice('test@test.com')).rejects.toThrow('Bad Request'); + }); + + it('네트워크 에러 시 NETWORK_ERROR 코드를 반환한다', async () => { + sendMessageMock.mockResolvedValue({ + success: false, + isNetworkError: true, + message: 'Network Error' + }); + + try { + await registerDevice('test@test.com'); + } catch (error) { + expect(error.code).toBe(ERROR_CODES.NETWORK_ERROR); + } + }); + }); +}); diff --git a/src/__tests__/background.test.js b/src/__tests__/background.test.js new file mode 100644 index 0000000..20c37c1 --- /dev/null +++ b/src/__tests__/background.test.js @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleApiRequest } from '../api-proxy.js'; +import { CONFIG } from '../config.js'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('handleApiRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('GET 요청을 올바르게 구성하여 보낸다', async () => { + const mockResponse = { data: 'test' }; + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse + }); + + const request = { + endpoint: '/test', + method: 'GET', + params: { q: 'hello' } + }; + + const result = await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/test?q=hello'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + expect(result).toEqual({ success: true, data: mockResponse }); + }); + + it('POST 요청과 Body를 올바르게 보낸다', async () => { + const mockResponse = { id: 1 }; + fetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => mockResponse + }); + + const request = { + endpoint: '/create', + method: 'POST', + body: { name: 'item' } + }; + + await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/create'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'item' }) + }) + ); + }); + + it('커스텀 헤더를 포함하여 요청을 보낸다', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}) + }); + + const request = { + endpoint: '/header-test', + headers: { 'X-Device-Id': '12345' } + }; + + await handleApiRequest(request); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Device-Id': '12345' + }) + }) + ); + }); + + it('204 응답을 올바르게 처리한다', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 204 + }); + + const result = await handleApiRequest({ endpoint: '/delete' }); + + expect(result).toEqual({ success: true, data: null }); + }); + + it('API 에러 응답을 처리한다', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ message: 'Invalid data' }) + }); + + const result = await handleApiRequest({ endpoint: '/error' }); + + expect(result).toEqual({ + success: false, + status: 400, + message: 'Invalid data' + }); + }); + + it('네트워크 에러를 처리한다', async () => { + fetch.mockRejectedValue(new Error('Network error')); + + const result = await handleApiRequest({ endpoint: '/network-fail' }); + + expect(result).toEqual({ + success: false, + status: 0, + message: 'Network error', + isNetworkError: true + }); + }); +}); diff --git a/src/api-proxy.js b/src/api-proxy.js new file mode 100644 index 0000000..5b08e2d --- /dev/null +++ b/src/api-proxy.js @@ -0,0 +1,54 @@ +import { CONFIG } from './config.js'; + +/** + * API 요청 처리 로직 + * @param {Object} request + * @returns {Promise} + */ +export async function handleApiRequest(request) { + const { endpoint, method = 'GET', body, params, headers } = request; + + let url = `${CONFIG.BASE_URL}${endpoint}`; + if (params) { + url += `?${new URLSearchParams(params)}`; + } + + const options = { + method, + headers: { + 'Content-Type': 'application/json', + ...(headers || {}) + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + + // 204 No Content + if (response.status === 204) { + return { success: true, data: null }; + } + + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error('Failed to parse JSON response:', parseError); + data = { message: 'Invalid JSON response from server.' }; + } + + if (!response.ok) { + console.error('API request failed:', { url, status: response.status, data }); + return { success: false, status: response.status, message: data.message }; + } + + return { success: true, data }; + } catch (error) { + console.error('Network request failed:', { url, error }); + return { success: false, status: 0, message: error.message, isNetworkError: true }; + } +} diff --git a/src/api.js b/src/api.js index 40bc5ee..8da19cd 100644 --- a/src/api.js +++ b/src/api.js @@ -54,7 +54,8 @@ export async function getDevices(email, identifier) { return await sendApiRequest({ endpoint: '/api/v1/members', method: 'GET', - params: { email, identifier } + params: { email }, + headers: { 'X-Device-Id': identifier } }); } @@ -68,7 +69,8 @@ export async function deleteDevice(email, deviceIdentifier, targetDeviceIdentifi return await sendApiRequest({ endpoint: '/api/v1/device', method: 'DELETE', - body: { email, deviceIdentifier, targetDeviceIdentifier } + headers: { 'X-Device-Id': deviceIdentifier }, + body: { email, targetDeviceIdentifier } }); } @@ -82,6 +84,7 @@ export async function saveReviewUrl(identifier, targetUrl) { return await sendApiRequest({ endpoint: '/api/v1/reviews', method: 'POST', - body: { identifier, targetUrl } + headers: { 'X-Device-Id': identifier }, + body: { targetUrl } }); } diff --git a/src/background.js b/src/background.js index bcf430a..cc1dd31 100644 --- a/src/background.js +++ b/src/background.js @@ -5,7 +5,7 @@ * API 요청은 CORS 우회를 위해 이 서비스 워커에서 처리한다. */ -import { CONFIG } from './config.js'; +import { handleApiRequest } from './api-proxy.js'; // ============================================ // 익스텐션 설치/업데이트 이벤트 @@ -18,53 +18,7 @@ chrome.runtime.onInstalled.addListener((details) => { } }); -// ============================================ -// API 프록시 핸들러 -// ============================================ -async function handleApiRequest(request) { - const { endpoint, method = 'GET', body, params } = request; - - let url = `${CONFIG.BASE_URL}${endpoint}`; - if (params) { - url += `?${new URLSearchParams(params)}`; - } - - const options = { - method, - headers: { 'Content-Type': 'application/json' } - }; - - if (body) { - options.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, options); - - // 204 No Content - if (response.status === 204) { - return { success: true, data: null }; - } - let data; - try { - data = await response.json(); - } catch (parseError) { - console.error('Failed to parse JSON response:', parseError); - data = { message: 'Invalid JSON response from server.' }; - } - - if (!response.ok) { - console.error('API request failed:', { url, status: response.status, data }); - return { success: false, status: response.status, message: data.message }; - } - - return { success: true, data }; - } catch (error) { - console.error('Network request failed:', { url, error }); - return { success: false, status: 0, message: error.message, isNetworkError: true }; - } -} // ============================================ // 메시지 리스너 (popup과 통신) diff --git a/src/handlers.js b/src/handlers.js index 9f9878e..7db7904 100644 --- a/src/handlers.js +++ b/src/handlers.js @@ -224,6 +224,24 @@ export async function handleLogout() { return; } + try { + showLoading(); + const storageData = await getStorageData(); + + // 인증된 상태라면 서버에 디바이스 삭제 요청 + if (storageData.email && storageData.identifier) { + await deleteDevice( + storageData.email, + storageData.identifier, + storageData.identifier // 자기 자신을 삭제 + ); + } + } catch (error) { + console.warn('서버 디바이스 삭제 실패 (로컬 로그아웃은 진행):', error); + } finally { + hideLoading(); + } + await clearStorage(); elements.saveResult.classList.add('hidden'); elements.devicesSection.classList.add('hidden'); From 84537bdf40fb3b5dbfac3c10c9d2baac07612ca6 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:59:38 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=20v1.0.2?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- public/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b052cb5..b657e5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recycle-study-extension", - "version": "1.0.0", + "version": "1.0.2", "description": "복습 URL 저장 크롬 익스텐션", "type": "module", "scripts": { diff --git a/public/manifest.json b/public/manifest.json index 2bf0902..6dae4fd 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Recycle Study", - "version": "1.0.1", + "version": "1.0.2", "description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션", "permissions": [ "storage", From 608d451bd13f7d5d8e03066d8c429883fd6cde63 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:49:45 +0900 Subject: [PATCH 8/9] =?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=20?= =?UTF-8?q?(#13)?= 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); +} From ed107248180dac1163f11d90f5f039586b8d1ef1 Mon Sep 17 00:00:00 2001 From: flinter <56645802+jhan0121@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:55:15 +0900 Subject: [PATCH 9/9] =?UTF-8?q?v1.1.0-ext=20=EB=B0=B0=ED=8F=AC=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=20=EC=9E=91=EC=97=85=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- public/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b657e5a..214c45c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recycle-study-extension", - "version": "1.0.2", + "version": "1.1.0", "description": "복습 URL 저장 크롬 익스텐션", "type": "module", "scripts": { diff --git a/public/manifest.json b/public/manifest.json index 6dae4fd..f443b7a 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Recycle Study", - "version": "1.0.2", + "version": "1.1.0", "description": "복습 URL을 저장하고 스케줄에 따라 알림을 받는 익스텐션", "permissions": [ "storage",