Compare commits
593 Commits
a24f766e37
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8775f8b1fe | |||
| 700c1cbbbe | |||
| 161b758a1b | |||
| 84f1fcb42a | |||
| fee8fe2551 | |||
| 4d14f9ba9c | |||
| 56ef1223c1 | |||
| 33ded78edc | |||
| 347dd44dcd | |||
| 852c9d31b1 | |||
| 8776d645a5 | |||
| 70ad4a82fa | |||
| 06b1ae6aa5 | |||
| fffaa01a73 | |||
| d7cf0d6fd9 | |||
| 91dcc19bd3 | |||
| 8daf9128bd | |||
| 7c512f410f | |||
| 616524d5ef | |||
| e102373026 | |||
| ae7c1e449b | |||
| 8bcc9ca701 | |||
| 8c45a748ef | |||
| ee7f1d97f3 | |||
| edd76e2db6 | |||
| 2851f3df0a | |||
| 1ff0dcd103 | |||
| ff1f94b7df | |||
| bab84ca531 | |||
| eb9afeef26 | |||
| f915d6d44c | |||
| aa1379a11a | |||
| 8bb2cf05c0 | |||
| 1f73b9744a | |||
| 6c952bbf48 | |||
| b9c73e0a4d | |||
| 4515f86e5c | |||
| e8b2a2c16e | |||
| 09fae4ef54 | |||
| e886fd62d1 | |||
| 2ff4160944 | |||
| 2ef40ac5a3 | |||
| 7663d9ce4c | |||
| f5629bce50 | |||
| c40f310fba | |||
| 8eb037474c | |||
| aa4648027f | |||
| cd67421c5f | |||
| ed7ee9e857 | |||
| 34a84bd574 | |||
| d64d2a032e | |||
| 6dd1be08a4 | |||
| 61613ac7d5 | |||
| e472db461d | |||
| 66a6db193b | |||
| b82e2f2424 | |||
| aa8b05d6bf | |||
| b849690e5f | |||
| 52cf322a00 | |||
| 9fdfbb22d8 | |||
| ab595394be | |||
| 2b34c0ccf5 | |||
| d62106abf5 | |||
| 553f7b1aef | |||
| db8c4fff2c | |||
| 80d4ff9044 | |||
| 56cbbfdc7a | |||
| 94532a4e6b | |||
| d9939217c7 | |||
| 456374bb86 | |||
| e90b6486c8 | |||
| 0537a5a4f6 | |||
| f500619817 | |||
| 9d1168594e | |||
| 6ce93a54b1 | |||
| ff1a4fa450 | |||
| 9a67fcfcc9 | |||
| e2af29413f | |||
| b8dbd73951 | |||
| 90e9764658 | |||
| 491ae26500 | |||
| 16dc093d96 | |||
| 352485f626 | |||
| 8022b0ea0a | |||
| 89cc8d378b | |||
| a2163d2f1b | |||
| a00859030e | |||
| 70ecc08f22 | |||
| 6257c872e3 | |||
| 4297f2e807 | |||
| 99a7d2de30 | |||
| 536ce07b12 | |||
| d7f88378e5 | |||
| 9ce8422596 | |||
| 8093cc8d2a | |||
| 0a5e1b9251 | |||
| b5ca44b96c | |||
| 2e33136d88 | |||
| 875bbbec65 | |||
| c6b166d265 | |||
| e822dac6dc | |||
| e6648f6ad0 | |||
| a9c6454e8f | |||
| 0f450461ac | |||
| 9746e4b4f6 | |||
| 8801b62252 | |||
| ecf32940ca | |||
| f11ca388f8 | |||
| d698705352 | |||
| c5f1bfa15a | |||
| 1c880e39d1 | |||
| 8ba97c9d8b | |||
| fa26c50ebc | |||
| 819d7e0420 | |||
| 702d923e92 | |||
| 6812371662 | |||
| 97c65e4132 | |||
| 99457258db | |||
| 47f0a0484b | |||
| 836fc70c49 | |||
| f6e03a3998 | |||
| 4de6bf4986 | |||
| 39a574c2c2 | |||
| da9d658238 | |||
| 3533be1c8c | |||
| ce21c1085f | |||
| 02ed0c971c | |||
| 02513fec2a | |||
| 9de0b19f94 | |||
| 2db764104e | |||
| c595623893 | |||
| 868e506ad8 | |||
| cb7c37ae04 | |||
| 6b73a73e14 | |||
| 85334e502b | |||
| 098c9e30fd | |||
| d2e03b1d62 | |||
| d3a16e5aa4 | |||
| 7fdc6e33f4 | |||
| af7155f483 | |||
| f476447357 | |||
| 2f0f0e840f | |||
| c82e9ea5ec | |||
| 05371a35f5 | |||
| 46808cf279 | |||
| 16c88d5fb7 | |||
| 0098155471 | |||
| 7bf1f9c459 | |||
| ee24b9bffc | |||
| 2327557764 | |||
| 4615fa0ef1 | |||
| 4d3818f961 | |||
| 670fcf90df | |||
| 3511d4380b | |||
| cfde33e38c | |||
| 9f8c7ed741 | |||
| ffe8f6c83d | |||
| fcbcd2ec96 | |||
| fd826807d8 | |||
| dff0cd964b | |||
| 97e7ecd1b2 | |||
| ca36f33d69 | |||
| a367485203 | |||
| 551a605a20 | |||
| 77ec6436a8 | |||
| 688f75babf | |||
| dfc57b84fd | |||
| 4e3c1deaac | |||
| 31c40ecd6b | |||
| 1799d4980a | |||
| be3072dc75 | |||
| 85ad180bb5 | |||
| 2eb6cd25be | |||
| bb6039cd7b | |||
| a99a9db967 | |||
| 4293e0405a | |||
| 545903149e | |||
| 8c0e9c8d37 | |||
| 96a9c07184 | |||
| 1eb61c2a4b | |||
| a848110dec | |||
| 0316fad26b | |||
| 36ac9b5eb1 | |||
| 1b942fdd26 | |||
| 0f12b0e865 | |||
| adf8e1ab72 | |||
| 5dafcfa232 | |||
| 66ad148ccc | |||
| 75a5e5ba47 | |||
| ece566ea56 | |||
| e4f99e82f4 | |||
| 38603a46d8 | |||
| 66eae1ddcd | |||
| 5a56aa4cbc | |||
| 8d9b5125cc | |||
| ec1e1f794b | |||
| efc97eea31 | |||
| 7b04d39768 | |||
| 7f06b56fde | |||
| df888ddfd7 | |||
| 0b465d82c1 | |||
| 716f20039f | |||
| cb75fa34a0 | |||
| 243e4c2eda | |||
| 2723b96c0b | |||
| 0148d0a1f1 | |||
| 568224f2d9 | |||
| cb23c5e923 | |||
| eaf43a54c8 | |||
| c077989f51 | |||
| ba0c84fa77 | |||
| ea98011fbc | |||
| c292ca72d4 | |||
| e87f8e6dd4 | |||
| 3ef1cd8300 | |||
| e55740782e | |||
| dbf60a4860 | |||
| 6f6ddb3660 | |||
| bea0f15566 | |||
| 5feab84540 | |||
| ff7f19a790 | |||
| ef1a2d2210 | |||
| 48e785770e | |||
| 45a9eb5099 | |||
| d75c52c93e | |||
| d755e77c44 | |||
| 4952bc7649 | |||
| 7a1fb11dfb | |||
| 0639d577a1 | |||
| aa8a3e2aab | |||
| 81310abbce | |||
| 13e6ce7467 | |||
| 0cc01aa476 | |||
| 8650920c7c | |||
| 4769193ae4 | |||
| fc2a528d89 | |||
| bb05c7e3e1 | |||
| 87a7f28cf8 | |||
| cb81e29c31 | |||
| 835168179e | |||
| 6c5ed223c6 | |||
| a5592f3857 | |||
| 5ac738a689 | |||
| c47e2199bb | |||
| 0bd9576426 | |||
| 3d0b848699 | |||
| 6fb5190582 | |||
| 61236dc0b2 | |||
| f2e1725156 | |||
| cac222a39b | |||
| 4c50f296fc | |||
| 6cce9ba646 | |||
| 1d6e1ca196 | |||
| 51ca3dc2c4 | |||
| 9f41ad7817 | |||
| 8e35ab4f15 | |||
| 6b31eab984 | |||
| 02f198db93 | |||
| b97199e286 | |||
| 6f89686862 | |||
| f16d261476 | |||
| 7713e51c50 | |||
| 8eaff2b134 | |||
| 78a1e6bc69 | |||
| 2945042d34 | |||
| 139c58cdae | |||
| 92d60f9cfc | |||
| 02959bb13a | |||
| b07c74d3c1 | |||
| 199c46b3e6 | |||
| 418c2cb20f | |||
| c6fc7894dc | |||
| 5509bb3500 | |||
| a951af24e3 | |||
| 3ebfc1a519 | |||
| 49270e6727 | |||
| f30c7880f6 | |||
| e98cb05b14 | |||
| 9507059ad9 | |||
| 78e5dd9217 | |||
| 1218d0d226 | |||
| a190f9b324 | |||
| 832b3e9cc3 | |||
| 12ea08a7e1 | |||
| b97d1c1f71 | |||
| ce90dac264 | |||
| 29aadc41ea | |||
| b0ad499b7e | |||
| c99fdec60d | |||
| 69354f5b71 | |||
| 9254bf2f80 | |||
| 9b4b418724 | |||
| a34468275f | |||
| a69ebafb2c | |||
| c7696eb4bb | |||
| 2f636ab08d | |||
| a92c49b178 | |||
| 6f72715726 | |||
| 619aab0309 | |||
| e1bf447c21 | |||
| 5d8f753659 | |||
| 3fdc9a510d | |||
| 7498aa5e73 | |||
| 8b1a24ded8 | |||
| 98b0d9b298 | |||
| b5a78e5283 | |||
| 069ac1cea2 | |||
| 5bfe7c6078 | |||
| 3c7e84b21a | |||
| 17d24b72d1 | |||
| 777bcae010 | |||
| 7e3e2191fa | |||
| 1761b466d1 | |||
| 771dd91118 | |||
| be79a96387 | |||
| 8f3b051db1 | |||
| 03bf0f38d6 | |||
| f31e5f8840 | |||
| fcc734a9bc | |||
| 7b5b80d1d1 | |||
| fcfc4215e0 | |||
| fb92606f44 | |||
| 8afaf18985 | |||
| 24a70d1cd7 | |||
| df6dcd03d1 | |||
| fb2629334c | |||
| 6835e3a7b8 | |||
| 1f85443db9 | |||
| b2de57e4ef | |||
| 4ac4b9a3f6 | |||
| 5e7e411a52 | |||
| 7dc792abc9 | |||
| 308001594b | |||
| beff7cd832 | |||
| 43bc719292 | |||
| 4f6b12feee | |||
| cc7d66c40b | |||
| 84711b0c98 | |||
| 00b8d01cff | |||
| 1e892cc407 | |||
| 855ee207fe | |||
| 923dfefb5a | |||
| 52ed9c04c0 | |||
| 6a28e72c6c | |||
| dae271f8a8 | |||
| 29d9629a5d | |||
| fad07f5d90 | |||
| 90159b2055 | |||
| ecf7e1f316 | |||
| d77706be9f | |||
| 622cec5e2d | |||
| 6ec72ff36f | |||
| d9d0de243d | |||
| d94ad418c8 | |||
| bb344d4096 | |||
| 77da2ef580 | |||
| 50664739fe | |||
| 2063c65efd | |||
| d3ac20f9fa | |||
| cf106cd5f3 | |||
| 338ee1f750 | |||
| 59823392e1 | |||
| 740005e4b8 | |||
| a736519d76 | |||
| 5151846a88 | |||
| bc48762925 | |||
| 9c1d836e4f | |||
| 4bbcdbb96e | |||
| 8f74fef02a | |||
| 88f426127e | |||
| 42f9acc4fa | |||
| 8f06e50820 | |||
| d3c6f45757 | |||
| 0c0cc33e1e | |||
| 74f0e1e252 | |||
| e51d484083 | |||
| 56578917e8 | |||
| 07a6f902d9 | |||
| af34c7c719 | |||
| 1e97e190c5 | |||
| f132339abf | |||
| 6d44590dff | |||
| 5d0c428dcd | |||
| 0044f1e965 | |||
| ac0a31c071 | |||
| 3434a361d9 | |||
| b6cbc12c5f | |||
| 7048ee7a77 | |||
| bb558be6f8 | |||
| 3ae5a93e57 | |||
| 177ae5c60d | |||
| d782eba3b4 | |||
| c87d494eee | |||
| b07f1fd61a | |||
| ae8832daff | |||
| 0515fb7958 | |||
| d5ba1d5ab1 | |||
| 9dafca49b0 | |||
| 67829cd09b | |||
| c7b78e32c7 | |||
| 35e9a97708 | |||
| fa8286fa54 | |||
| 5eb704af2d | |||
| bf38a226fc | |||
| 61fe7c6594 | |||
| 155a3d3985 | |||
| 5453f80317 | |||
| 6d3d81c3c0 | |||
| 4cb66eafbd | |||
| 94b1828c88 | |||
| 247d4330b2 | |||
| 2d89f09b92 | |||
| e061278123 | |||
| be3fd84901 | |||
| b06382c0bb | |||
| 9467d4d81d | |||
| 77a5d09db1 | |||
| 1279cfaa4e | |||
| 6b2e5795bc | |||
| cd85a0f0a4 | |||
| a8b5ee6e3c | |||
| 2a8cfd4011 | |||
| 4c950b5686 | |||
| d5ec0cd9db | |||
| 6dfe06a412 | |||
| 26ff87450e | |||
| c91843d828 | |||
| 84ea99c219 | |||
| 5fcd3fc768 | |||
| d7207e5014 | |||
| 9e8c1f12aa | |||
| 0e0b23a3a1 | |||
| fcdab93e55 | |||
| ed5b819c41 | |||
| cc71cf1a91 | |||
| 507d9d600f | |||
| ed44fad9bd | |||
| 649c7733c3 | |||
| 94a4d01966 | |||
| 6bf6bb3a1c | |||
| 1a398b2528 | |||
| 32698e5261 | |||
| a8b92e1d3b | |||
| c6cc4a290e | |||
| 3dc5565df5 | |||
| 9507c7b304 | |||
| 5584dd8502 | |||
| d70c8e23f4 | |||
| b91e1da47b | |||
| 8b6ba2787e | |||
| 89a90e2adf | |||
| fad082195b | |||
| a2ec573d39 | |||
| b6707d75de | |||
| d91765941a | |||
| ddf2289ea6 | |||
| 109a3f1995 | |||
| 74893f7f74 | |||
| ba798ff999 | |||
| c3d758fda6 | |||
| b8a4539fe3 | |||
| 8fc32c517e | |||
| f758069c82 | |||
| d30612a883 | |||
| 94bb78346f | |||
| 15aa7d2fea | |||
| 8beb332548 | |||
| 384e2df3f1 | |||
| 4d951130a5 | |||
| 1cc137b9bb | |||
| b953f5b9dc | |||
| 653702dfc6 | |||
| bdbec024fc | |||
| b903505ac2 | |||
| 765742c6c7 | |||
| 1b9a1d9386 | |||
| 469bbb186d | |||
| 42447dd952 | |||
| 4079fa0975 | |||
| 9f241da3b0 | |||
| 0a53b7758f | |||
| feaa990ea0 | |||
| b4b1c77f49 | |||
| 5614a6775b | |||
| f4d394ae47 | |||
| 800baeb8f4 | |||
| fd4d1d1358 | |||
| e9a461a478 | |||
| a687a51229 | |||
| 6ccca7ae5e | |||
| 36804c1c24 | |||
| 2e9b34b266 | |||
| 06a8bf956e | |||
| 4c89d15525 | |||
| 8e431e83f7 | |||
| 6980cb8657 | |||
| 308df336c2 | |||
| a130770bc0 | |||
| f41f83ee2a | |||
| 2e94360a38 | |||
| bee38e5eaa | |||
| 8ca0c4b68c | |||
| 65a8d64666 | |||
| fedb0496b4 | |||
| 9f18a7b3be | |||
| 300246397d | |||
| ead6103dfb | |||
| a82a16f8e2 | |||
| d65039470e | |||
| 980622c365 | |||
| 289ba1156a | |||
| ca798a3334 | |||
| 585b0fb7a3 | |||
| 070a1f2f74 | |||
| dc6a625025 | |||
| f17408b283 | |||
| 0b23d8b998 | |||
| 6060db82d7 | |||
| 49ce60ce80 | |||
| e2259e79f0 | |||
| 66470d127c | |||
| afda1c7824 | |||
| 12eeb7d9f4 | |||
| d0f23b8088 | |||
| b9babf9693 | |||
| 41206dcacc | |||
| 0060db14a4 | |||
| 59f0b26e63 | |||
| 1ad9389318 | |||
| 07c4379d81 | |||
| b3d6bb0772 | |||
| 2a262b77d8 | |||
| 058cd4e1bf | |||
| 5c338bd622 | |||
| 05b2894cc8 | |||
| 71d328acdf | |||
| 1028cca194 | |||
| 4445514a57 | |||
| 14496ada29 | |||
| ac76ef108b | |||
| a169b1e698 | |||
| 201c3be2df | |||
| 0a7b920bb5 | |||
| 5785985419 | |||
| 9eb91d3075 | |||
| 57ac382c99 | |||
| c38d14a432 | |||
| 6f060001d5 | |||
| 7140004554 | |||
| 67d333fb31 | |||
| 5db2f0840b | |||
| de839e00db | |||
| 19a5d0f093 | |||
| cbe874d3bd | |||
| 9d2590ccfd | |||
| f563e27224 | |||
| 9a2faef3bf | |||
| 6f5284ec10 | |||
| aae38d0353 | |||
| e4ca6b392b | |||
| 21d7d0c9be | |||
| 5bcff917ef | |||
| 8b4cadf3c4 | |||
| 76f42cacc7 | |||
| 4596112762 | |||
| f0e8164645 | |||
| cc78b80747 | |||
| 679da54c77 | |||
| f616cbc0b2 | |||
| 1ad59722b9 | |||
| 22117fa23d | |||
| 0de8c0ee71 | |||
| a921b09ba6 | |||
| 33272b67a8 | |||
| 4ae4d50ff2 | |||
| 68777f3596 | |||
| f25bbb3b2d | |||
| 593a707438 | |||
| 816eb7cb67 | |||
| 3894a2415c | |||
| b70c8efc22 | |||
| 6dbeff1c4a | |||
| 0f206a0158 | |||
| b7a6a139b0 | |||
| 1ddc7761f5 | |||
| 8c4829f8c5 | |||
| 30d8202c55 | |||
| 149c3370a2 | |||
| 8f7f9915e1 | |||
| c723496e69 | |||
| 36caa456fb | |||
| e55537ff8b | |||
| e85aea3f0c |
12
.env
Normal file
12
.env
Normal file
@@ -0,0 +1,12 @@
|
||||
# Discord OAuth Configuration
|
||||
# These variables are read by Vite during build and embedded into the frontend bundle
|
||||
# Get your values from https://discord.com/developers/applications
|
||||
|
||||
VITE_DISCORD_CLIENT_ID=your_client_id_here
|
||||
VITE_DISCORD_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# Backend server port
|
||||
BACKEND_PORT=3099
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=development
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -1,5 +1,9 @@
|
||||
# GitHub Copilot Instructions for Memory Palace
|
||||
|
||||
## MUST FOLLOW
|
||||
Do not run commands in the same terminal where the server was started.
|
||||
Always open a new terminal for running new commands or tests if the active terminal has a long-running process.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hybrid workspace combining Obsidian-style knowledge management with code development. Uses Obsidian MD for VSCode extension (wiki-links, backlinks, graph view) alongside JavaScript/Python development tools.
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -6,6 +6,8 @@
|
||||
"obsidian.dailyNoteFormat": "daily-YYYY-MM-DD",
|
||||
"obsidian.newNoteDirectory": "docs",
|
||||
"obsidian.attachmentsFolder": "docs/assets",
|
||||
"javascript.validate.enable": false,
|
||||
"typescript.validate.enable": false, // Optional: also disable validation in .ts files
|
||||
// Markdown Configuration
|
||||
"markdown.extension.toc.levels": "2..6",
|
||||
"markdown.extension.completion.root": "./docs",
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
/**
|
||||
* DEPRECATED: Use deploy.sh instead
|
||||
*
|
||||
* This utility is being phased out in favor of the comprehensive deploy.sh script
|
||||
* located at code/websites/pokedex.online/deploy.sh
|
||||
*
|
||||
* Migration guide:
|
||||
* Old: node code/utils/deploy-pokedex.js --target internal
|
||||
* New: cd code/websites/pokedex.online && ./deploy.sh --target production
|
||||
*
|
||||
* Old: node code/utils/deploy-pokedex.js --target local
|
||||
* New: cd code/websites/pokedex.online && ./deploy.sh --target local
|
||||
*
|
||||
* The new deploy.sh provides:
|
||||
* - Environment-specific builds using Vite modes
|
||||
* - Automatic build verification
|
||||
* - Pre-deployment validation
|
||||
* - Integrated testing
|
||||
* - Better error handling
|
||||
*
|
||||
* This file will be removed in a future update.
|
||||
*/
|
||||
|
||||
console.warn('⚠️ WARNING: deploy-pokedex.js is DEPRECATED');
|
||||
console.warn(' Please use deploy.sh instead:');
|
||||
console.warn(' cd code/websites/pokedex.online');
|
||||
console.warn(' ./deploy.sh --target local # Local Docker testing');
|
||||
console.warn(' ./deploy.sh --target production # Deploy to Synology');
|
||||
console.warn('');
|
||||
process.exit(1);
|
||||
|
||||
/**
|
||||
* Pokedex.Online Deployment Script
|
||||
*
|
||||
* Deploys the Vue 3 pokedex.online application to Synology NAS via SSH.
|
||||
* Deploys the Vue 3 pokedex.online application with backend to Synology NAS via SSH.
|
||||
* - Builds the Vue 3 application locally (npm run build)
|
||||
* - Connects to Synology using configured SSH hosts
|
||||
* - Transfers built files and Docker configuration via SFTP
|
||||
* - Manages Docker deployment with rollback on failure
|
||||
* - Performs health check to verify deployment
|
||||
* - Transfers built files, backend code, and Docker configuration via SFTP
|
||||
* - Manages multi-container Docker deployment (frontend + backend) with rollback on failure
|
||||
* - Performs health checks on both frontend and backend containers
|
||||
*
|
||||
* Usage:
|
||||
* node code/utils/deploy-pokedex.js [--target internal|external] [--port 8080] [--ssl-port 8443]
|
||||
* npm run deploy:pokedex -- --target external --port 8081 --ssl-port 8444
|
||||
* node code/utils/deploy-pokedex.js [--target internal|external] [--port 8080] [--ssl-port 8443] [--backend-port 3000]
|
||||
* npm run deploy:pokedex -- --target external --port 8081 --ssl-port 8444 --backend-port 3001
|
||||
*
|
||||
* Examples:
|
||||
* npm run deploy:pokedex # Deploy to internal (10.0.0.81) on port 8080
|
||||
@@ -34,15 +65,16 @@ const SSH_HOSTS = {
|
||||
host: '10.0.0.81',
|
||||
port: 2323,
|
||||
username: 'GregRJacobs',
|
||||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs',
|
||||
password: 'J@Cubs88'
|
||||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs'
|
||||
},
|
||||
external: {
|
||||
host: 'home.gregrjacobs.com',
|
||||
port: 2323,
|
||||
username: 'GregRJacobs',
|
||||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs',
|
||||
password: 'J@Cubs88'
|
||||
privateKeyPath: '~/.ssh/ds3627xs_gregrjacobs'
|
||||
},
|
||||
local: {
|
||||
host: 'localhost'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,8 +91,9 @@ function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {
|
||||
target: 'internal',
|
||||
port: 8080,
|
||||
sslPort: null
|
||||
port: 8099,
|
||||
sslPort: null,
|
||||
backendPort: 3099
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -73,13 +106,16 @@ function parseArgs() {
|
||||
} else if (args[i] === '--ssl-port' && args[i + 1]) {
|
||||
config.sslPort = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '--backend-port' && args[i + 1]) {
|
||||
config.backendPort = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate target
|
||||
if (!SSH_HOSTS[config.target]) {
|
||||
throw new Error(
|
||||
`Invalid target: ${config.target}. Must be 'internal' or 'external'.`
|
||||
`Invalid target: ${config.target}. Must be 'internal', 'external', or 'local'.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,26 +152,40 @@ function expandTilde(filepath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create modified docker-compose.yml with custom ports
|
||||
* @param {number} port - HTTP port to map to container
|
||||
* @param {number|null} sslPort - HTTPS port to map to container (optional)
|
||||
* Create modified docker-compose.production.yml with custom ports
|
||||
* @param {number} port - HTTP port to map to frontend container
|
||||
* @param {number|null} sslPort - HTTPS port to map to frontend container (optional)
|
||||
* @param {number} backendPort - Port to map to backend container
|
||||
* @returns {string} Modified docker-compose content
|
||||
*/
|
||||
function createModifiedDockerCompose(port, sslPort) {
|
||||
const originalPath = path.join(SOURCE_DIR, 'docker-compose.yml');
|
||||
function createModifiedDockerCompose(port, sslPort, backendPort) {
|
||||
const originalPath = path.join(SOURCE_DIR, 'docker-compose.production.yml');
|
||||
let content = fs.readFileSync(originalPath, 'utf8');
|
||||
|
||||
// Replace HTTP port mapping (handle both single and double quotes)
|
||||
content = content.replace(/- ['"](\d+):80['"]/, `- '${port}:80'`);
|
||||
// Replace frontend HTTP port mapping
|
||||
content = content.replace(
|
||||
/(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:80['"])/,
|
||||
`$1${port}$3`
|
||||
);
|
||||
|
||||
// Replace HTTPS port mapping if SSL port provided
|
||||
// Replace frontend HTTPS port mapping if SSL port provided
|
||||
if (sslPort !== null) {
|
||||
content = content.replace(/- ['"](\d+):443['"]/, `- '${sslPort}:443'`);
|
||||
content = content.replace(
|
||||
/(frontend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:443['"])/,
|
||||
`$1${sslPort}$3`
|
||||
);
|
||||
} else {
|
||||
// Remove HTTPS port mapping if no SSL port specified
|
||||
content = content.replace(/\s*- ['"](\d+):443['"]/g, '');
|
||||
// Remove HTTPS port mapping line if no SSL port specified
|
||||
// Make sure to preserve newline structure
|
||||
content = content.replace(/\n\s*- ['"](\d+):443['"]/g, '');
|
||||
}
|
||||
|
||||
// Replace backend port mapping
|
||||
content = content.replace(
|
||||
/(backend:[\s\S]*?ports:[\s\S]*?- ['"])(\d+)(:3000['"])/,
|
||||
`$1${backendPort}$3`
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -143,15 +193,16 @@ function createModifiedDockerCompose(port, sslPort) {
|
||||
* Perform HTTP health check
|
||||
* @param {string} host - Host to check
|
||||
* @param {number} port - Port to check
|
||||
* @param {string} path - Path to check (default: /)
|
||||
* @param {number} retries - Number of retries
|
||||
* @returns {Promise<boolean>} True if healthy
|
||||
*/
|
||||
async function healthCheck(host, port, retries = 5) {
|
||||
async function healthCheck(host, port, path = '/', retries = 5) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
`http://${host}:${port}`,
|
||||
`http://${host}:${port}${path}`,
|
||||
{ timeout: 5000 },
|
||||
res => {
|
||||
if (res.statusCode === 200) {
|
||||
@@ -180,6 +231,90 @@ async function healthCheck(host, port, retries = 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local deployment function
|
||||
* @param {Object} config - Deployment configuration
|
||||
*/
|
||||
async function deployLocal(config) {
|
||||
const { execSync } = await import('child_process');
|
||||
console.log('\n🐳 Deploying to local Docker...');
|
||||
|
||||
// Create modified docker-compose
|
||||
const modifiedCompose = createModifiedDockerCompose(
|
||||
config.port,
|
||||
config.sslPort,
|
||||
config.backendPort
|
||||
);
|
||||
const tmpComposePath = path.join(SOURCE_DIR, 'docker-compose.tmp.yml');
|
||||
fs.writeFileSync(tmpComposePath, modifiedCompose);
|
||||
|
||||
try {
|
||||
// Stop existing
|
||||
console.log(' 🛑 Stopping existing containers...');
|
||||
try {
|
||||
execSync(`docker compose -f "${tmpComposePath}" down --remove-orphans`, {
|
||||
cwd: SOURCE_DIR,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist yet or other issues on down
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f docker-compose.production.yml down --remove-orphans`,
|
||||
{ cwd: SOURCE_DIR, stdio: 'inherit' }
|
||||
);
|
||||
} catch (e2) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Set Discord redirect URI for local deployment
|
||||
const discordRedirectUri = `http://localhost:${config.port}/oauth/callback`;
|
||||
console.log(` 🔐 Discord Redirect URI: ${discordRedirectUri}`);
|
||||
|
||||
// Up with Discord redirect URI
|
||||
console.log(' 🚀 Starting containers...');
|
||||
execSync(`docker compose -f "${tmpComposePath}" up -d --build`, {
|
||||
cwd: SOURCE_DIR,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_DISCORD_REDIRECT_URI: discordRedirectUri,
|
||||
DISCORD_REDIRECT_URI: discordRedirectUri
|
||||
}
|
||||
});
|
||||
|
||||
// Health Check
|
||||
console.log('\n🏥 Performing health checks...');
|
||||
console.log(' Checking frontend...');
|
||||
const frontendHealthy = await healthCheck('localhost', config.port);
|
||||
if (!frontendHealthy) throw new Error('Frontend health check failed');
|
||||
console.log(' ✅ Frontend healthy');
|
||||
|
||||
console.log(' Checking backend...');
|
||||
const backendHealthy = await healthCheck(
|
||||
'localhost',
|
||||
config.backendPort,
|
||||
'/health'
|
||||
);
|
||||
// Backend might need more time
|
||||
if (!backendHealthy) throw new Error('Backend health check failed');
|
||||
console.log(' ✅ Backend healthy');
|
||||
|
||||
console.log(`\n🎉 Local Deployment successful!`);
|
||||
console.log(`🌐 Frontend: http://localhost:${config.port}`);
|
||||
if (config.sslPort)
|
||||
console.log(`🔒 HTTPS: https://localhost:${config.sslPort}`);
|
||||
console.log(`🔌 Backend: http://localhost:${config.backendPort}`);
|
||||
} catch (e) {
|
||||
console.error('❌ Local deployment failed:', e.message);
|
||||
// Clean up tmp file? Maybe keep for debugging if failed
|
||||
throw e;
|
||||
} finally {
|
||||
console.log(`\nℹ️ Docker Compose file: ${tmpComposePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main deployment function
|
||||
*/
|
||||
@@ -187,32 +322,84 @@ async function deploy() {
|
||||
const ssh = new NodeSSH();
|
||||
let previousImage = null;
|
||||
let containerExisted = false;
|
||||
let config = null;
|
||||
|
||||
try {
|
||||
// Parse arguments
|
||||
const config = parseArgs();
|
||||
config = parseArgs();
|
||||
const isLocal = config.target === 'local';
|
||||
const sshConfig = SSH_HOSTS[config.target];
|
||||
|
||||
console.log('🚀 Starting Pokedex.Online deployment');
|
||||
console.log(
|
||||
`📡 Target: ${config.target} (${sshConfig.host}:${sshConfig.port})`
|
||||
);
|
||||
console.log(`🔌 HTTP Port: ${config.port}`);
|
||||
if (isLocal) {
|
||||
console.log(`📡 Target: local`);
|
||||
} else {
|
||||
console.log(
|
||||
`📡 Target: ${config.target} (${sshConfig.host}:${sshConfig.port})`
|
||||
);
|
||||
}
|
||||
console.log(`🔌 Frontend Port: ${config.port}`);
|
||||
if (config.sslPort) {
|
||||
console.log(`🔒 HTTPS Port: ${config.sslPort}`);
|
||||
}
|
||||
console.log(`🔌 Backend Port: ${config.backendPort}`);
|
||||
|
||||
// Connect to Synology
|
||||
console.log('\n🔐 Connecting to Synology...');
|
||||
await ssh.connect({
|
||||
host: sshConfig.host,
|
||||
port: sshConfig.port,
|
||||
username: sshConfig.username,
|
||||
privateKeyPath: expandTilde(sshConfig.privateKeyPath),
|
||||
password: sshConfig.password,
|
||||
tryKeyboard: true
|
||||
});
|
||||
console.log('✅ Connected successfully');
|
||||
// Connect to Synology using ~/.ssh/config
|
||||
if (!isLocal) {
|
||||
console.log('\n🔐 Connecting to Synology...');
|
||||
|
||||
const keyPath = expandTilde(sshConfig.privateKeyPath);
|
||||
console.log(` 🔑 Using SSH key: ${keyPath}`);
|
||||
console.log(
|
||||
` 📍 Target: ${sshConfig.username}@${sshConfig.host}:${sshConfig.port}`
|
||||
);
|
||||
|
||||
// Verify key file exists
|
||||
if (!fs.existsSync(keyPath)) {
|
||||
throw new Error(`SSH key file not found: ${keyPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKeyContent = fs.readFileSync(keyPath, 'utf8');
|
||||
const keySize = privateKeyContent.length;
|
||||
console.log(` 📂 Key file size: ${keySize} bytes`);
|
||||
|
||||
if (keySize === 0) {
|
||||
throw new Error('SSH key file is empty');
|
||||
}
|
||||
|
||||
// Use node-ssh with private key directly
|
||||
await ssh.connect({
|
||||
host: sshConfig.host,
|
||||
port: sshConfig.port,
|
||||
username: sshConfig.username,
|
||||
privateKey: privateKeyContent,
|
||||
readyTimeout: 60000,
|
||||
tryKeyboard: false
|
||||
});
|
||||
console.log('✅ Connected successfully');
|
||||
} catch (connError) {
|
||||
console.error('\n❌ SSH Connection Failed');
|
||||
console.error(`Error: ${connError.message}`);
|
||||
console.error('\nPossible causes:');
|
||||
console.error(
|
||||
'1. SSH public key not added to ~/.ssh/authorized_keys on the server'
|
||||
);
|
||||
console.error('2. SSH key has wrong permissions (should be 600)');
|
||||
console.error('3. SSH user home directory permissions are wrong');
|
||||
console.error('\nVerify the key works manually:');
|
||||
console.error(
|
||||
` ssh -i ${keyPath} ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "whoami"`
|
||||
);
|
||||
console.error(
|
||||
'\nIf that fails, the public key needs to be added on the server:'
|
||||
);
|
||||
console.error(
|
||||
` cat ~/.ssh/${path.basename(keyPath)}.pub | ssh ${sshConfig.username}@${sshConfig.host} -p ${sshConfig.port} "cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`
|
||||
);
|
||||
throw new Error(`SSH connection failed: ${connError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build Vue 3 application
|
||||
console.log('\n🔨 Building Vue 3 application...');
|
||||
@@ -247,6 +434,11 @@ async function deploy() {
|
||||
throw new Error(`Build failed: ${error.message}`);
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
await deployLocal(config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container exists and capture current image
|
||||
console.log('\n📦 Checking for existing container...');
|
||||
console.log(` Container name: ${CONTAINER_NAME}`);
|
||||
@@ -283,7 +475,8 @@ async function deploy() {
|
||||
// Create modified docker-compose.yml
|
||||
const modifiedDockerCompose = createModifiedDockerCompose(
|
||||
config.port,
|
||||
config.sslPort
|
||||
config.sslPort,
|
||||
config.backendPort
|
||||
);
|
||||
const tempDockerComposePath = path.join(
|
||||
SOURCE_DIR,
|
||||
@@ -296,57 +489,101 @@ async function deploy() {
|
||||
|
||||
// First transfer the dist directory
|
||||
console.log(' 📦 Transferring dist directory...');
|
||||
const distFiles = [];
|
||||
|
||||
function getDistFiles(dir, baseDir = DIST_DIR) {
|
||||
// Count files for reporting
|
||||
let fileCount = 0;
|
||||
function countFiles(dir) {
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
getDistFiles(fullPath, baseDir);
|
||||
countFiles(fullPath);
|
||||
} else {
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
distFiles.push({
|
||||
local: fullPath,
|
||||
remote: `${REMOTE_PATH}/dist/${relativePath.replace(/\\/g, '/')}`
|
||||
});
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDistFiles(DIST_DIR);
|
||||
console.log(` Found ${distFiles.length} files in dist/`);
|
||||
countFiles(DIST_DIR);
|
||||
console.log(` Found ${fileCount} files in dist/`);
|
||||
|
||||
// Create dist directory on remote
|
||||
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/dist`);
|
||||
|
||||
// Transfer dist files
|
||||
let transferred = 0;
|
||||
for (const file of distFiles) {
|
||||
// Transfer dist directory using rsync
|
||||
try {
|
||||
console.log(` 📡 Transferring dist directory via rsync...`);
|
||||
|
||||
const { execSync } = await import('child_process');
|
||||
const expandedKeyPath = expandTilde(sshConfig.privateKeyPath);
|
||||
|
||||
// Use rsync with SSH for reliable transfer
|
||||
// Key options:
|
||||
// - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys
|
||||
// - rsync-path: Ensure we use the correct rsync binary on remote
|
||||
const rsyncCmd = `rsync -av --delete -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${DIST_DIR}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/dist/" --rsync-path="/usr/bin/rsync"`;
|
||||
|
||||
try {
|
||||
// Create remote directory for this file
|
||||
const remoteDir = path.dirname(file.remote);
|
||||
await ssh.execCommand(`mkdir -p ${remoteDir}`);
|
||||
await ssh.putFile(file.local, file.remote);
|
||||
transferred++;
|
||||
if (transferred % 10 === 0) {
|
||||
console.log(
|
||||
` 📁 Transferred ${transferred}/${distFiles.length} files...`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
` ⚠️ Failed to transfer ${path.relative(DIST_DIR, file.local)}: ${error.message}`
|
||||
);
|
||||
execSync(rsyncCmd, {
|
||||
stdio: 'inherit',
|
||||
shell: '/bin/bash'
|
||||
});
|
||||
console.log(` ✅ Transferred dist directory successfully via rsync`);
|
||||
} catch (rsyncError) {
|
||||
console.log(` ❌ Rsync failed: ${rsyncError.message}`);
|
||||
throw rsyncError;
|
||||
}
|
||||
} catch (transferError) {
|
||||
console.log(` ❌ File transfer failed: ${transferError.message}`);
|
||||
throw new Error(`File transfer failed: ${transferError.message}`);
|
||||
}
|
||||
|
||||
// Transfer backend server files
|
||||
console.log(' 📦 Transferring backend server files...');
|
||||
const serverDir = path.join(SOURCE_DIR, 'server');
|
||||
await ssh.execCommand(`mkdir -p ${REMOTE_PATH}/server`);
|
||||
|
||||
// Use rsync for server files with exclusions
|
||||
try {
|
||||
console.log(` 📡 Transferring server files via rsync...`);
|
||||
|
||||
const { execSync } = await import('child_process');
|
||||
const expandedKeyPath = expandTilde(sshConfig.privateKeyPath);
|
||||
|
||||
// Use rsync with SSH for reliable transfer and exclusions
|
||||
// Key options:
|
||||
// - IdentitiesOnly=yes: Only use specified key, not ssh-agent keys
|
||||
// - rsync-path: Ensure we use the correct rsync binary on remote
|
||||
const rsyncCmd = `rsync -av --delete --exclude='node_modules' --exclude='tests' --exclude='.git' --exclude='dist' --exclude='build' -e "ssh -p ${sshConfig.port} -i ${expandedKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes" "${serverDir}/" "${sshConfig.username}@${sshConfig.host}:${REMOTE_PATH}/server/" --rsync-path="/usr/bin/rsync"`;
|
||||
|
||||
try {
|
||||
execSync(rsyncCmd, {
|
||||
stdio: 'inherit',
|
||||
shell: '/bin/bash'
|
||||
});
|
||||
console.log(` ✅ Backend files transferred successfully via rsync`);
|
||||
} catch (rsyncError) {
|
||||
console.log(` ❌ Rsync failed: ${rsyncError.message}`);
|
||||
throw rsyncError;
|
||||
}
|
||||
} catch (transferError) {
|
||||
console.log(` ❌ File transfer failed: ${transferError.message}`);
|
||||
throw new Error(`Backend file transfer failed: ${transferError.message}`);
|
||||
}
|
||||
console.log(` ✅ Transferred ${transferred} files from dist/`);
|
||||
|
||||
// Now transfer config files
|
||||
console.log(' 📦 Transferring configuration files...');
|
||||
const filesToTransfer = [
|
||||
{
|
||||
local: path.join(SOURCE_DIR, 'Dockerfile'),
|
||||
remote: `${REMOTE_PATH}/Dockerfile`
|
||||
local: path.join(SOURCE_DIR, 'Dockerfile.frontend'),
|
||||
remote: `${REMOTE_PATH}/Dockerfile.frontend`
|
||||
},
|
||||
{
|
||||
local: path.join(SOURCE_DIR, 'server', 'Dockerfile'),
|
||||
remote: `${REMOTE_PATH}/server/Dockerfile`
|
||||
},
|
||||
{
|
||||
local: path.join(SOURCE_DIR, 'nginx.conf'),
|
||||
remote: `${REMOTE_PATH}/nginx.conf`
|
||||
},
|
||||
{
|
||||
local: tempDockerComposePath,
|
||||
@@ -354,29 +591,22 @@ async function deploy() {
|
||||
}
|
||||
];
|
||||
|
||||
// Transfer config files using cat over SSH (more reliable than SFTP)
|
||||
for (const file of filesToTransfer) {
|
||||
const fileName = path.basename(file.local);
|
||||
try {
|
||||
await ssh.putFile(file.local, file.remote);
|
||||
console.log(` ✅ ${path.basename(file.local)}`);
|
||||
} catch {
|
||||
// If SFTP fails, fall back to cat method
|
||||
console.log(
|
||||
` ⚠️ SFTP failed for ${path.basename(file.local)}, using cat fallback...`
|
||||
);
|
||||
const fileContent = fs.readFileSync(file.local, 'utf8');
|
||||
const catResult = await ssh.execCommand(
|
||||
`cat > '${file.remote}' << 'EOFMARKER'\n${fileContent}\nEOFMARKER`
|
||||
);
|
||||
if (catResult.stdout) console.log(` Output: ${catResult.stdout}`);
|
||||
if (catResult.stderr) console.log(` Stderr: ${catResult.stderr}`);
|
||||
if (catResult.code !== 0) {
|
||||
throw new Error(
|
||||
`Failed to transfer ${path.basename(file.local)}: ${catResult.stderr}`
|
||||
`Failed to transfer ${fileName}: ${catResult.stderr}`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
` ✅ ${path.basename(file.local)} (${fs.statSync(file.local).size} bytes)`
|
||||
);
|
||||
console.log(` ✅ ${fileName} (${fs.statSync(file.local).size} bytes)`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to transfer ${fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,23 +655,44 @@ async function deploy() {
|
||||
|
||||
console.log('\n✅ Container started');
|
||||
|
||||
// Health check
|
||||
console.log('\n🏥 Performing health check...');
|
||||
const isHealthy = await healthCheck(sshConfig.host, config.port);
|
||||
|
||||
if (!isHealthy) {
|
||||
throw new Error('Health check failed - container is not responding');
|
||||
// Health checks
|
||||
console.log('\n🏥 Performing health checks...');
|
||||
console.log(' Checking frontend...');
|
||||
const frontendHealthy = await healthCheck(sshConfig.host, config.port);
|
||||
if (!frontendHealthy) {
|
||||
throw new Error(
|
||||
'Frontend health check failed - container is not responding'
|
||||
);
|
||||
}
|
||||
console.log(' ✅ Frontend healthy');
|
||||
|
||||
console.log('✅ Health check passed');
|
||||
console.log(' Checking backend...');
|
||||
const backendHealthy = await healthCheck(
|
||||
sshConfig.host,
|
||||
config.backendPort,
|
||||
'/health'
|
||||
);
|
||||
if (!backendHealthy) {
|
||||
throw new Error(
|
||||
'Backend health check failed - container is not responding'
|
||||
);
|
||||
}
|
||||
console.log(' ✅ Backend healthy');
|
||||
|
||||
console.log('\n✅ All health checks passed');
|
||||
console.log(`\n🎉 Deployment successful!`);
|
||||
console.log(`🌐 HTTP: http://${sshConfig.host}:${config.port}`);
|
||||
console.log(`🌐 Frontend: http://${sshConfig.host}:${config.port}`);
|
||||
if (config.sslPort) {
|
||||
console.log(`🔒 HTTPS: https://${sshConfig.host}:${config.sslPort}`);
|
||||
console.log(`🔒 HTTPS: https://${sshConfig.host}:${config.sslPort}`);
|
||||
}
|
||||
console.log(`🔌 Backend: http://${sshConfig.host}:${config.backendPort}`);
|
||||
|
||||
ssh.dispose();
|
||||
} catch (error) {
|
||||
if (config && config.target === 'local') {
|
||||
console.error('\n❌ Deployment failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('\n❌ Deployment failed:', error.message);
|
||||
|
||||
// Rollback
|
||||
|
||||
21
code/websites/pokedex.online/.env.development
Normal file
21
code/websites/pokedex.online/.env.development
Normal file
@@ -0,0 +1,21 @@
|
||||
# ====================================================================
|
||||
# DEVELOPMENT ENVIRONMENT - Vite Dev Server (localhost:5173)
|
||||
# ====================================================================
|
||||
# This file is loaded automatically when running: npm run dev
|
||||
# Hot module reloading enabled for rapid development iteration
|
||||
# Backend OAuth proxy runs separately at localhost:3001
|
||||
# ====================================================================
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
VITE_DISCORD_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
VITE_CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
VITE_CHALLONGE_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_TITLE=Pokedex Online (Dev)
|
||||
|
||||
# Debug Mode - Enable detailed logging
|
||||
VITE_DEBUG=true
|
||||
22
code/websites/pokedex.online/.env.docker-local
Normal file
22
code/websites/pokedex.online/.env.docker-local
Normal file
@@ -0,0 +1,22 @@
|
||||
# ====================================================================
|
||||
# LOCAL DOCKER ENVIRONMENT - Production Build on localhost:8099
|
||||
# ====================================================================
|
||||
# This file is loaded when building with: vite build --mode docker-local
|
||||
# Tests full production build locally in Docker before deploying
|
||||
# Frontend: http://localhost:8099
|
||||
# Backend: http://localhost:3099
|
||||
# ====================================================================
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
VITE_DISCORD_REDIRECT_URI=http://localhost:8099/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
VITE_CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
VITE_CHALLONGE_REDIRECT_URI=http://localhost:8099/oauth/callback
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_TITLE=Pokedex Online (Local)
|
||||
|
||||
# Debug Mode - Disable for production-like testing
|
||||
VITE_DEBUG=false
|
||||
@@ -1,24 +0,0 @@
|
||||
# Challonge API Configuration
|
||||
# Get your API key from: https://challonge.com/settings/developer
|
||||
VITE_CHALLONGE_API_KEY=your_api_key_here
|
||||
|
||||
# OAuth Configuration (optional - for development)
|
||||
# Register your app at: https://connect.challonge.com
|
||||
VITE_CHALLONGE_CLIENT_ID=your_oauth_client_id_here
|
||||
VITE_CHALLONGE_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# OAuth Proxy Backend (development - server-side only)
|
||||
# These are NOT browser-accessible (no VITE_ prefix)
|
||||
CLIENT_ID=your_oauth_client_id_here
|
||||
CLIENT_SECRET=your_oauth_client_secret_here
|
||||
OAUTH_PROXY_PORT=3001
|
||||
|
||||
# Debug Mode (optional)
|
||||
# Set to 'true' to enable debug logging, or toggle in browser:
|
||||
# localStorage.setItem('DEBUG', '1')
|
||||
VITE_DEBUG=false
|
||||
|
||||
# VITE_DEFAULT_TOURNAMENT_ID=
|
||||
|
||||
# Application Configuration
|
||||
# VITE_APP_TITLE=Pokedex Online
|
||||
21
code/websites/pokedex.online/.env.production
Normal file
21
code/websites/pokedex.online/.env.production
Normal file
@@ -0,0 +1,21 @@
|
||||
# ====================================================================
|
||||
# PRODUCTION ENVIRONMENT - Synology Deployment (app.pokedex.online)
|
||||
# ====================================================================
|
||||
# This file is loaded when building with: vite build --mode production
|
||||
# Deploys to Synology NAS at 10.0.0.81:8099
|
||||
# Accessible via reverse proxy at: https://app.pokedex.online
|
||||
# ====================================================================
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
VITE_DISCORD_REDIRECT_URI=https://app.pokedex.online/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
VITE_CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
VITE_CHALLONGE_REDIRECT_URI=https://app.pokedex.online/oauth/callback
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_TITLE=Pokedex Online
|
||||
|
||||
# Debug Mode - Disabled in production
|
||||
VITE_DEBUG=false
|
||||
@@ -1,249 +1,16 @@
|
||||
# Pokedex Online
|
||||
# Pokedex.Online
|
||||
|
||||
A modern Vue 3 web application for Pokemon Professors to manage tournaments, process gamemaster data, and handle tournament printing materials.
|
||||
Pokedex.Online is a Vue + Vite frontend with a Node/Express backend for tournament and Pokémon tooling. This folder contains the runnable website code. Project documentation lives in docs/projects/Pokedex.Online.
|
||||
|
||||
## 🚀 Local Development
|
||||
## Quick start
|
||||
1. Install dependencies in the web root and server folder.
|
||||
2. Start the backend server, then run the frontend dev server.
|
||||
|
||||
### Prerequisites
|
||||
Common commands:
|
||||
- Frontend dev: npm run dev
|
||||
- Frontend build: npm run build
|
||||
- Backend dev: npm start (from server/)
|
||||
|
||||
- Node.js 20+
|
||||
- npm or yarn
|
||||
- Challonge API key (get from https://challonge.com/settings/developer)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. **Copy environment template**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Configure your API keys** in `.env`:
|
||||
```bash
|
||||
# Required: Get your API key from Challonge
|
||||
VITE_CHALLONGE_API_KEY=your_actual_api_key_here
|
||||
|
||||
# Optional: Set default tournament for testing
|
||||
VITE_DEFAULT_TOURNAMENT_ID=your_tournament_url
|
||||
```
|
||||
|
||||
3. **Keep your `.env` file secure**:
|
||||
- Never commit `.env` to git (already in `.gitignore`)
|
||||
- Use `.env.example` for documentation
|
||||
- Share API keys only through secure channels
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server (API key can be set via UI now!)
|
||||
npm run dev
|
||||
|
||||
# Open browser to http://localhost:5173
|
||||
```
|
||||
|
||||
**API Key Setup** (two options):
|
||||
1. **Option 1: UI-based (Recommended)** - Use the API Key Manager tool at `/api-key-manager` to store your key locally in the browser
|
||||
2. **Option 2: Environment-based** - Create `.env` file (see Environment Setup section below) for CI/CD or shared development
|
||||
|
||||
### Environment Setup (Optional)
|
||||
|
||||
If you prefer environment variables:
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your API keys
|
||||
VITE_CHALLONGE_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
**Note**: The API Key Manager tool (`/api-key-manager`) allows you to store your key in browser localStorage, so `.env` configuration is now optional.
|
||||
|
||||
```bash
|
||||
# Build the app
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Build and Run Locally
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t pokedex-online .
|
||||
|
||||
# Run the container
|
||||
docker run -d -p 8080:80 --name pokedex-online pokedex-online
|
||||
|
||||
# View in browser
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
# Start the service
|
||||
docker-compose up -d
|
||||
|
||||
# Stop the service
|
||||
docker-compose down
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 📦 Automated Deployment
|
||||
|
||||
Deploy to Synology NAS using the deployment script:
|
||||
|
||||
```bash
|
||||
# Deploy to internal network (10.0.0.81)
|
||||
npm run deploy:pokedex:internal
|
||||
|
||||
# Deploy to external network (home.gregrjacobs.com)
|
||||
npm run deploy:pokedex:external
|
||||
|
||||
# Deploy with custom ports
|
||||
npm run deploy:pokedex -- --target internal --port 8080 --ssl-port 8443
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
pokedex.online/
|
||||
├── src/
|
||||
│ ├── main.js # Application entry point
|
||||
│ ├── App.vue # Root component with transitions
|
||||
│ ├── style.css # Global styles
|
||||
│ ├── router/
|
||||
│ │ └── index.js # Vue Router configuration (4 routes)
|
||||
│ ├── views/
|
||||
│ │ ├── Home.vue # Landing page with tool cards
|
||||
│ │ ├── ApiKeyManager.vue # API key storage and management
|
||||
│ │ ├── GamemasterManager.vue # Gamemaster fetch/process/download
|
||||
│ │ └── ChallongeTest.vue # API testing and validation
|
||||
│ ├── components/
|
||||
│ │ └── shared/
|
||||
│ │ └── ProfessorPokeball.vue # Animated logo component
|
||||
│ ├── services/
|
||||
│ │ └── challonge.service.js # Challonge API client (20+ methods)
|
||||
│ ├── utilities/
|
||||
│ │ ├── constants.js # API config, types, CSV headers
|
||||
│ │ ├── string-utils.js # String utilities
|
||||
│ │ ├── csv-utils.js # CSV parsing and validation
|
||||
│ │ ├── participant-utils.js # Participant management
|
||||
│ │ ├── gamemaster-utils.js # Gamemaster processing
|
||||
│ │ └── models/ # Data models (Tournament, Participant, Pokemon)
|
||||
│ └── composables/
|
||||
│ └── useChallongeApiKey.js # API key storage composable
|
||||
├── .env.example # Environment template (optional)
|
||||
├── index.html # HTML entry point
|
||||
├── vite.config.js # Vite config with dev proxy
|
||||
├── nginx.conf # Production nginx proxy config
|
||||
├── package.json # Dependencies (Vue 3, Vue Router)
|
||||
├── Dockerfile # Docker build (nginx:alpine)
|
||||
├── docker-compose.yml # Docker Compose config
|
||||
├── PROJECT_PLAN.md # Implementation roadmap
|
||||
├── API_KEY_STORAGE.md # API key storage documentation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🎯 Available Tools
|
||||
|
||||
### API Key Manager (`/api-key-manager`)
|
||||
- Store your Challonge API key locally in browser localStorage
|
||||
- Works across all devices and browsers (mobile, tablet, desktop)
|
||||
- Masked display for security (shows "xxxx•••••••xxxx")
|
||||
- Update or clear your key anytime
|
||||
- No need to edit .env files
|
||||
- Secure browser-native storage
|
||||
|
||||
### Gamemaster Manager (`/gamemaster`)
|
||||
- Fetch latest Pokemon GO gamemaster from PokeMiners
|
||||
- Process and break up into separate files
|
||||
- Download pokemon.json, moves.json, and allFormsCostumes.json
|
||||
- View statistics about downloaded data
|
||||
|
||||
### Challonge API Test (`/challonge-test`)
|
||||
- Test your Challonge API connection
|
||||
- Enter API key (or load from stored key)
|
||||
- List your tournaments
|
||||
- View tournament details and participants
|
||||
- Verify API configuration is working
|
||||
|
||||
### Printing Tool (Coming Soon)
|
||||
- Import RK9 player CSV files
|
||||
- Generate team sheets
|
||||
- Create player badges
|
||||
- Print tournament materials
|
||||
|
||||
### Tournament Manager (Coming Soon)
|
||||
- Manage Challonge tournaments
|
||||
- Add/remove participants
|
||||
- Submit match scores
|
||||
- Track tournament progress
|
||||
|
||||
## 🔑 API Keys & Configuration
|
||||
|
||||
### Challonge API
|
||||
1. Create account at https://challonge.com
|
||||
2. Get API key from https://challonge.com/settings/developer
|
||||
3. Add to `.env`:
|
||||
```
|
||||
VITE_CHALLONGE_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
All environment variables must be prefixed with `VITE_` to be available in the browser:
|
||||
|
||||
```bash
|
||||
# ✅ Correct - Available in browser
|
||||
VITE_CHALLONGE_API_KEY=abc123
|
||||
|
||||
# ❌ Wrong - Not accessible
|
||||
CHALLONGE_API_KEY=abc123
|
||||
```
|
||||
|
||||
Access in code:
|
||||
```javascript
|
||||
const apiKey = import.meta.env.VITE_CHALLONGE_API_KEY;
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Vue 3.4** - Progressive JavaScript framework with Composition API
|
||||
- **Vue Router 4** - Official routing library
|
||||
- **Vite 5** - Next generation frontend tooling
|
||||
- **Docker** - Containerization
|
||||
- **nginx** - Web server for production
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [PROJECT_PLAN.md](./PROJECT_PLAN.md) - Complete implementation roadmap
|
||||
- [GAMEMASTER_IMPLEMENTATION.md](./GAMEMASTER_IMPLEMENTATION.md) - Gamemaster feature details
|
||||
- [Vue 3 Docs](https://vuejs.org/)
|
||||
- [Challonge API](https://api.challonge.com/v1)
|
||||
|
||||
## 🔒 Security Notes
|
||||
|
||||
- **Never commit `.env`** - Contains sensitive API keys
|
||||
- **Use environment variables** - All configs in `.env`
|
||||
- **Prefix with VITE_** - Required for browser access
|
||||
- **Keep API keys private** - Don't share in public repos
|
||||
|
||||
## 🔗 Related Apps
|
||||
|
||||
- **apps/** - Additional apps accessible at app.pokedex.online
|
||||
|
||||
## 📝 Development Notes
|
||||
|
||||
This project uses:
|
||||
- Vue 3 Composition API with `<script setup>`
|
||||
- Vite for fast HMR and optimized builds
|
||||
- Multi-stage Docker builds for minimal image size
|
||||
- nginx:alpine for production serving
|
||||
## Documentation
|
||||
See the docs hub for setup, deployment, and architecture details:
|
||||
- docs/projects/Pokedex.Online/README.md
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
605
code/websites/pokedex.online/deploy.sh
Executable file
605
code/websites/pokedex.online/deploy.sh
Executable file
@@ -0,0 +1,605 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Pokedex.Online Deployment Automation Script
|
||||
#
|
||||
# This script automates the deployment process with pre-deployment checks,
|
||||
# build verification, and rollback capability.
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --target <local|production> Deployment target (default: local)
|
||||
# --port <number> Frontend HTTP port (default: 8099)
|
||||
# --backend-port <number> Backend port (default: 3099)
|
||||
# --skip-tests Skip test execution
|
||||
# --skip-build Skip build step (use existing dist/)
|
||||
# --no-backup Skip backup creation
|
||||
# --dry-run Show what would be deployed without deploying
|
||||
#
|
||||
# Deployment Strategies:
|
||||
# local - Docker deployment on localhost:8099 for production testing
|
||||
# production - Deploy to Synology at https://app.pokedex.online
|
||||
#
|
||||
# Examples:
|
||||
# ./deploy.sh --target local
|
||||
# ./deploy.sh --target production
|
||||
# ./deploy.sh --dry-run
|
||||
###############################################################################
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default configuration
|
||||
TARGET="local"
|
||||
PORT=8099
|
||||
BACKEND_PORT=3099
|
||||
SKIP_TESTS=false
|
||||
SKIP_BUILD=false
|
||||
NO_BACKUP=false
|
||||
DRY_RUN=false
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$SCRIPT_DIR"
|
||||
|
||||
###############################################################################
|
||||
# Helper Functions
|
||||
###############################################################################
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌${NC} $1"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Parse Arguments
|
||||
###############################################################################
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--target)
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--backend-port)
|
||||
BACKEND_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-tests)
|
||||
SKIP_TESTS=true
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-backup)
|
||||
NO_BACKUP=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
head -n 30 "$0" | tail -n 25
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Pre-Deployment Checks
|
||||
###############################################################################
|
||||
|
||||
check_prerequisites() {
|
||||
log_step "🔍 Checking Prerequisites"
|
||||
|
||||
# Check Node.js
|
||||
if ! command -v node &> /dev/null; then
|
||||
log_error "Node.js is not installed"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Node.js $(node --version)"
|
||||
|
||||
# Check npm
|
||||
if ! command -v npm &> /dev/null; then
|
||||
log_error "npm is not installed"
|
||||
exit 1
|
||||
fi
|
||||
log_success "npm $(npm --version)"
|
||||
|
||||
# Check if dependencies are installed
|
||||
if [ ! -d "$PROJECT_ROOT/node_modules" ]; then
|
||||
log_error "Dependencies not installed. Run 'npm install' first"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Dependencies installed"
|
||||
|
||||
# Check if server dependencies are installed (workspaces hoist to root node_modules)
|
||||
# Check for key server dependencies in root node_modules
|
||||
if [ ! -d "$PROJECT_ROOT/node_modules/express" ] || [ ! -d "$PROJECT_ROOT/node_modules/cors" ]; then
|
||||
log_error "Server dependencies not installed. Run 'npm install' first"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Server dependencies installed"
|
||||
}
|
||||
|
||||
check_environment() {
|
||||
log_step "🔧 Checking Environment Configuration"
|
||||
|
||||
# Validate target
|
||||
if [ "$TARGET" != "local" ] && [ "$TARGET" != "production" ]; then
|
||||
log_error "Invalid target: $TARGET"
|
||||
log_info "Valid targets: local, production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for mode-specific env files
|
||||
local mode
|
||||
if [ "$TARGET" = "local" ]; then
|
||||
mode="docker-local"
|
||||
else
|
||||
mode="production"
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROJECT_ROOT/.env.$mode" ]; then
|
||||
log_error "Environment file .env.$mode not found"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Frontend environment configuration found (.env.$mode)"
|
||||
|
||||
if [ ! -f "$PROJECT_ROOT/server/.env.$mode" ]; then
|
||||
log_error "Environment file server/.env.$mode not found"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Backend environment configuration found (server/.env.$mode)"
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
if [ "$SKIP_TESTS" = true ]; then
|
||||
log_warning "Skipping tests (--skip-tests flag set)"
|
||||
return
|
||||
fi
|
||||
|
||||
log_step "🧪 Running Tests"
|
||||
|
||||
log_info "Running frontend tests..."
|
||||
npm run test:run || {
|
||||
log_error "Frontend tests failed"
|
||||
exit 1
|
||||
}
|
||||
log_success "Frontend tests passed"
|
||||
|
||||
log_info "Running backend tests..."
|
||||
npm run test:run --workspace=server || {
|
||||
log_warning "Backend tests failed or not found - continuing anyway"
|
||||
}
|
||||
log_success "Backend checks completed"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Build
|
||||
###############################################################################
|
||||
|
||||
prepare_build_mode() {
|
||||
# Determine Vite build mode based on deployment target
|
||||
if [ "$TARGET" = "local" ]; then
|
||||
BUILD_MODE="docker-local"
|
||||
else
|
||||
BUILD_MODE="production"
|
||||
fi
|
||||
|
||||
log_info "Preparing build for ${TARGET} deployment..."
|
||||
log_info "Vite build mode: ${BUILD_MODE}"
|
||||
log_info "Environment file: .env.${BUILD_MODE}"
|
||||
}
|
||||
|
||||
build_application() {
|
||||
if [ "$SKIP_BUILD" = true ]; then
|
||||
log_warning "Skipping build (--skip-build flag set)"
|
||||
|
||||
# Check if dist exists
|
||||
if [ ! -d "$PROJECT_ROOT/dist" ]; then
|
||||
log_error "dist/ directory not found and --skip-build is set"
|
||||
log_info "Remove --skip-build or run 'npm run build' first"
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
log_step "🔨 Building Application"
|
||||
|
||||
# Prepare build mode
|
||||
prepare_build_mode
|
||||
|
||||
log_info "Building frontend with mode: $BUILD_MODE..."
|
||||
npx vite build --mode "$BUILD_MODE" || {
|
||||
log_error "Frontend build failed"
|
||||
exit 1
|
||||
}
|
||||
log_success "Frontend built successfully"
|
||||
|
||||
log_info "Verifying build..."
|
||||
BUILD_TARGET="$TARGET" npm run build:verify || {
|
||||
log_error "Build verification failed"
|
||||
exit 1
|
||||
}
|
||||
log_success "Build verified"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Backup
|
||||
###############################################################################
|
||||
|
||||
create_backup() {
|
||||
if [ "$NO_BACKUP" = true ]; then
|
||||
log_warning "Skipping backup (--no-backup flag set)"
|
||||
return
|
||||
fi
|
||||
|
||||
log_step "💾 Creating Backup"
|
||||
|
||||
BACKUP_DIR="$PROJECT_ROOT/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/backup_${TIMESTAMP}.tar.gz"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log_info "Creating backup of current deployment..."
|
||||
tar -czf "$BACKUP_FILE" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='dist' \
|
||||
--exclude='.git' \
|
||||
--exclude='backups' \
|
||||
-C "$PROJECT_ROOT" . || {
|
||||
log_error "Backup creation failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "Backup created: $BACKUP_FILE"
|
||||
|
||||
# Keep only last 5 backups
|
||||
BACKUP_COUNT=$(ls -1 "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | wc -l)
|
||||
if [ "$BACKUP_COUNT" -gt 5 ]; then
|
||||
log_info "Cleaning old backups (keeping last 5)..."
|
||||
ls -1t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +6 | xargs rm -f
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Deployment
|
||||
###############################################################################
|
||||
|
||||
deploy_to_server() {
|
||||
log_step "🚀 Deploying to Server"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
log_info "DRY RUN - Would deploy with following configuration:"
|
||||
log_info " Target: $TARGET"
|
||||
log_info " Frontend Port: $PORT"
|
||||
log_info " Backend Port: $BACKEND_PORT"
|
||||
log_success "Dry run completed"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$TARGET" = "local" ]; then
|
||||
log_info "Deploying to local Docker..."
|
||||
|
||||
# Copy server env file
|
||||
cp "$PROJECT_ROOT/server/.env.docker-local" "$PROJECT_ROOT/server/.env" || {
|
||||
log_error "Failed to copy server environment file"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Use docker-compose.docker-local.yml
|
||||
log_info "Starting Docker containers..."
|
||||
docker compose -f docker-compose.docker-local.yml down || true
|
||||
docker compose -f docker-compose.docker-local.yml up -d --build || {
|
||||
log_error "Docker deployment failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
else
|
||||
log_info "Deploying to production (Synology)..."
|
||||
deploy_to_synology
|
||||
fi
|
||||
|
||||
log_success "Deployment completed successfully"
|
||||
}
|
||||
|
||||
deploy_to_synology() {
|
||||
# SSH Configuration
|
||||
local SSH_HOST="10.0.0.81"
|
||||
local SSH_PORT="2323"
|
||||
local SSH_USER="GregRJacobs"
|
||||
local SSH_KEY="$HOME/.ssh/ds3627xs_gregrjacobs"
|
||||
local REMOTE_PATH="/volume1/docker/pokedex-online"
|
||||
|
||||
log_info "SSH Config: ${SSH_USER}@${SSH_HOST}:${SSH_PORT}"
|
||||
|
||||
# Verify SSH key exists
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
log_error "SSH key not found: $SSH_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test SSH connection
|
||||
log_info "Testing SSH connection..."
|
||||
if ! ssh -i "$SSH_KEY" -p "$SSH_PORT" -o ConnectTimeout=10 -o BatchMode=yes "$SSH_USER@$SSH_HOST" "echo 'Connection successful'" > /dev/null 2>&1; then
|
||||
log_error "SSH connection failed"
|
||||
log_info "Please verify:"
|
||||
log_info " 1. SSH key exists: $SSH_KEY"
|
||||
log_info " 2. Key has correct permissions: chmod 600 $SSH_KEY"
|
||||
log_info " 3. Public key is in authorized_keys on server"
|
||||
log_info " 4. You can connect manually: ssh -i $SSH_KEY -p $SSH_PORT $SSH_USER@$SSH_HOST"
|
||||
exit 1
|
||||
fi
|
||||
log_success "SSH connection verified"
|
||||
|
||||
# Create remote directory
|
||||
log_info "Creating remote directory..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_PATH/dist $REMOTE_PATH/server" || {
|
||||
log_error "Failed to create remote directory"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Transfer dist directory using tar over SSH
|
||||
log_info "Transferring frontend build (dist/)..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "rm -rf $REMOTE_PATH/dist && mkdir -p $REMOTE_PATH/dist"
|
||||
tar -czf - -C "$PROJECT_ROOT" dist 2>/dev/null | \
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"tar -xzf - -C $REMOTE_PATH --strip-components=0 2>/dev/null" || {
|
||||
log_error "Failed to transfer dist directory"
|
||||
exit 1
|
||||
}
|
||||
log_success "Frontend build transferred"
|
||||
|
||||
# Transfer server directory (excluding node_modules) using tar over SSH
|
||||
log_info "Transferring backend code (server/)..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "rm -rf $REMOTE_PATH/server && mkdir -p $REMOTE_PATH/server"
|
||||
tar -czf - -C "$PROJECT_ROOT" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.env*' \
|
||||
--exclude='data' \
|
||||
--exclude='logs' \
|
||||
server 2>/dev/null | \
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"tar -xzf - -C $REMOTE_PATH --strip-components=0 2>/dev/null" || {
|
||||
log_error "Failed to transfer server directory"
|
||||
exit 1
|
||||
}
|
||||
log_success "Backend code transferred"
|
||||
|
||||
# Copy production environment file to server (after server code transfer)
|
||||
log_info "Copying server environment file..."
|
||||
cat "$PROJECT_ROOT/server/.env.production" | \
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cat > $REMOTE_PATH/server/.env" || {
|
||||
log_error "Failed to copy server .env file"
|
||||
exit 1
|
||||
}
|
||||
log_success "Server environment configured"
|
||||
|
||||
# Create required directories for volume mounts
|
||||
log_info "Creating volume mount directories..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"mkdir -p $REMOTE_PATH/server/data $REMOTE_PATH/server/logs" || {
|
||||
log_error "Failed to create volume mount directories"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Transfer Docker configuration files
|
||||
log_info "Transferring Docker configuration..."
|
||||
for file in docker-compose.production.yml nginx.conf Dockerfile.frontend; do
|
||||
cat "$PROJECT_ROOT/$file" | \
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cat > $REMOTE_PATH/$file" || {
|
||||
log_error "Failed to transfer $file"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
cat "$PROJECT_ROOT/server/Dockerfile" | \
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cat > $REMOTE_PATH/server/Dockerfile" || {
|
||||
log_error "Failed to transfer server Dockerfile"
|
||||
exit 1
|
||||
}
|
||||
log_success "Docker configuration transferred"
|
||||
|
||||
# Find Docker on remote system
|
||||
log_info "Locating Docker on remote system..."
|
||||
DOCKER_PATH=$(ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"which docker 2>/dev/null || echo /usr/local/bin/docker")
|
||||
log_info "Using Docker at: $DOCKER_PATH"
|
||||
|
||||
# Stop and remove existing containers (force cleanup)
|
||||
log_info "Stopping and removing existing containers..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cd $REMOTE_PATH && $DOCKER_PATH compose -f docker-compose.production.yml down --remove-orphans 2>/dev/null || true"
|
||||
|
||||
# Force remove any lingering containers with matching names
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"$DOCKER_PATH rm -f pokedex-frontend pokedex-backend 2>/dev/null || true"
|
||||
|
||||
# Start containers
|
||||
log_info "Starting Docker containers..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cd $REMOTE_PATH && $DOCKER_PATH compose -f docker-compose.production.yml up -d --build" || {
|
||||
log_error "Failed to start Docker containers"
|
||||
log_info "Rolling back..."
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
"cd $REMOTE_PATH && $DOCKER_PATH compose -f docker-compose.production.yml down --remove-orphans"
|
||||
exit 1
|
||||
}
|
||||
log_success "Containers started"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Post-Deployment
|
||||
###############################################################################
|
||||
|
||||
verify_deployment() {
|
||||
log_step "🏥 Verifying Deployment"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
log_info "DRY RUN - Skipping verification"
|
||||
return
|
||||
fi
|
||||
|
||||
# Determine host based on target
|
||||
if [ "$TARGET" = "production" ]; then
|
||||
HOST="10.0.0.81"
|
||||
else
|
||||
HOST="localhost"
|
||||
fi
|
||||
|
||||
log_info "Checking frontend health..."
|
||||
sleep 3 # Give containers time to start
|
||||
|
||||
if curl -f -s "http://$HOST:$PORT/health" > /dev/null; then
|
||||
log_success "Frontend is responding"
|
||||
else
|
||||
log_warning "Frontend health check failed"
|
||||
fi
|
||||
|
||||
log_info "Checking backend health..."
|
||||
if curl -f -s "http://$HOST:$BACKEND_PORT/health" > /dev/null; then
|
||||
log_success "Backend is responding"
|
||||
else
|
||||
log_warning "Backend health check failed"
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
log_step "📊 Deployment Summary"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
log_info "DRY RUN completed - no changes made"
|
||||
return
|
||||
fi
|
||||
|
||||
# Determine URLs based on target
|
||||
if [ "$TARGET" = "production" ]; then
|
||||
FRONTEND_URL="https://app.pokedex.online"
|
||||
BACKEND_URL="http://10.0.0.81:$BACKEND_PORT"
|
||||
else
|
||||
FRONTEND_URL="http://localhost:$PORT"
|
||||
BACKEND_URL="http://localhost:$BACKEND_PORT"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} 🎉 Deployment Successful!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}Frontend:${NC} $FRONTEND_URL"
|
||||
echo -e " ${BLUE}Backend:${NC} $BACKEND_URL"
|
||||
echo -e " ${BLUE}Target:${NC} $TARGET"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Next Steps:${NC}"
|
||||
echo -e " • Test the application manually"
|
||||
if [ "$TARGET" = "local" ]; then
|
||||
echo -e " • Check logs: docker compose -f docker-compose.local.yml logs -f"
|
||||
echo -e " • Stop containers: docker compose -f docker-compose.local.yml down"
|
||||
else
|
||||
echo -e " • Check logs via SSH or Docker commands on Synology"
|
||||
fi
|
||||
echo -e " • Monitor backend: curl $BACKEND_URL/health"
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Rollback
|
||||
###############################################################################
|
||||
|
||||
rollback() {
|
||||
log_step "🔄 Rollback Instructions"
|
||||
|
||||
echo "To rollback to a previous version:"
|
||||
echo ""
|
||||
echo "1. List available backups:"
|
||||
echo " ls -lh backups/"
|
||||
echo ""
|
||||
echo "2. Extract backup:"
|
||||
echo " tar -xzf backups/backup_TIMESTAMP.tar.gz -C /tmp/restore"
|
||||
echo ""
|
||||
echo "3. Copy files back:"
|
||||
echo " rsync -av /tmp/restore/ ./"
|
||||
echo ""
|
||||
echo "4. Redeploy:"
|
||||
echo " ./deploy.sh --skip-tests --target $TARGET"
|
||||
echo ""
|
||||
echo "Or use the deployment script's built-in rollback:"
|
||||
echo " node ../../utils/deploy-pokedex.js --target $TARGET"
|
||||
echo " (will auto-rollback on failure)"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Main Execution
|
||||
###############################################################################
|
||||
|
||||
main() {
|
||||
echo -e "${BLUE}"
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ║"
|
||||
echo "║ Pokedex.Online Deployment Automation ║"
|
||||
echo "║ ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}\n"
|
||||
|
||||
# Parse command line arguments
|
||||
parse_args "$@"
|
||||
|
||||
# Run deployment pipeline
|
||||
check_prerequisites
|
||||
check_environment
|
||||
run_tests
|
||||
build_application
|
||||
create_backup
|
||||
deploy_to_server
|
||||
verify_deployment
|
||||
print_summary
|
||||
|
||||
log_success "All done! 🚀"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
82
code/websites/pokedex.online/docker-compose.docker-local.yml
Normal file
82
code/websites/pokedex.online/docker-compose.docker-local.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
# Pokedex.Online Local Docker Compose
|
||||
# For testing production builds locally before deploying to Synology
|
||||
# Frontend: http://localhost:8099
|
||||
# Backend: http://localhost:3099
|
||||
|
||||
services:
|
||||
# Frontend - Nginx serving built Vue.js app
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: pokedex-frontend-docker-local
|
||||
ports:
|
||||
- '8099:80'
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pokedex-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--quiet',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:80/'
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Backend - Node.js API server
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: pokedex-backend-docker-local
|
||||
ports:
|
||||
- '3099:3000'
|
||||
environment:
|
||||
- DEPLOYMENT_TARGET=docker-local
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- FRONTEND_URL=http://localhost:8099
|
||||
env_file:
|
||||
- ./server/.env
|
||||
volumes:
|
||||
# Persist OAuth session data
|
||||
- ./server/data:/app/data
|
||||
# Persist logs
|
||||
- ./server/logs:/app/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pokedex-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--quiet',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:3000/health'
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
pokedex-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
driver: local
|
||||
backend-logs:
|
||||
driver: local
|
||||
80
code/websites/pokedex.online/docker-compose.production.yml
Normal file
80
code/websites/pokedex.online/docker-compose.production.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
# Pokedex.Online Production Docker Compose
|
||||
# Multi-container setup with frontend (nginx) and backend (Node.js)
|
||||
|
||||
services:
|
||||
# Frontend - Nginx serving built Vue.js app
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: pokedex-frontend
|
||||
ports:
|
||||
- '8099:80'
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pokedex-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--quiet',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:80/'
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Backend - Node.js API server
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: pokedex-backend
|
||||
ports:
|
||||
- '3099:3000'
|
||||
environment:
|
||||
- DEPLOYMENT_TARGET=production
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- FRONTEND_URL=https://app.pokedex.online
|
||||
env_file:
|
||||
- ./server/.env
|
||||
volumes:
|
||||
# Persist OAuth session data
|
||||
- ./server/data:/app/data
|
||||
# Persist logs
|
||||
- ./server/logs:/app/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pokedex-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--quiet',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:3000/health'
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
pokedex-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
driver: local
|
||||
backend-logs:
|
||||
driver: local
|
||||
@@ -6,5 +6,4 @@ services:
|
||||
container_name: pokedex-online
|
||||
ports:
|
||||
- '8080:80'
|
||||
- '8443:443'
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name app.pokedex.online localhost;
|
||||
server_name app.pokedex.online localhost 10.0.0.81;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
@@ -19,50 +30,50 @@ server {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Don't cache HTML files
|
||||
location ~* \.(html)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||
}
|
||||
}
|
||||
|
||||
# Proxy Challonge API requests to avoid CORS
|
||||
location /api/challonge/ {
|
||||
# Remove /api/challonge prefix and forward to Challonge API
|
||||
rewrite ^/api/challonge/(.*) /v1/$1 break;
|
||||
|
||||
proxy_pass https://api.challonge.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# Proxy to backend API server (OAuth proxy + Gamemaster API)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Proxy headers
|
||||
proxy_set_header Host api.challonge.com;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# CORS headers for browser requests
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Handle preflight OPTIONS requests
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
# WebSocket support (if needed later)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeout settings
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Buffer settings
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /index.html;
|
||||
}
|
||||
|
||||
|
||||
3514
code/websites/pokedex.online/package-lock.json
generated
3514
code/websites/pokedex.online/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,24 +3,49 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "A modern Vue 3 web application for exploring Pokémon data",
|
||||
"workspaces": [
|
||||
"server"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev:full": "concurrently \"npm run dev\" \"npm run oauth-proxy\"",
|
||||
"oauth-proxy": "npm run dev --workspace=server",
|
||||
"preview": "vite preview",
|
||||
"oauth-proxy": "node server/oauth-proxy.js",
|
||||
"dev:full": "concurrently \"npm run dev\" \"npm run oauth-proxy\""
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "npm run build --workspace=server",
|
||||
"build:verify": "node scripts/verify-build.js",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:run": "vitest run",
|
||||
"test:all": "npm run test:run && npm run test:run --workspace=server",
|
||||
"docker:local": "docker compose -f docker-compose.docker-local.yml up -d --build",
|
||||
"docker:local:down": "docker compose -f docker-compose.docker-local.yml down",
|
||||
"docker:local:logs": "docker compose -f docker-compose.docker-local.yml logs -f",
|
||||
"docker:prod": "docker compose -f docker-compose.production.yml up -d --build",
|
||||
"docker:prod:down": "docker compose -f docker-compose.production.yml down",
|
||||
"docker:prod:logs": "docker compose -f docker-compose.production.yml logs -f",
|
||||
"deploy:local": "./deploy.sh --target local",
|
||||
"deploy:prod": "./deploy.sh --target production",
|
||||
"install:all": "npm install && npm install --workspace=server",
|
||||
"lint": "echo 'Add ESLint when ready'"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitest/ui": "^1.6.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"concurrently": "^8.2.2",
|
||||
"vite": "^5.0.12"
|
||||
"happy-dom": "^12.10.3",
|
||||
"jsdom": "^23.2.0",
|
||||
"terser": "^5.46.0",
|
||||
"vite": "^5.0.12",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
145
code/websites/pokedex.online/scripts/verify-build.js
Normal file
145
code/websites/pokedex.online/scripts/verify-build.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Build Verification Script
|
||||
*
|
||||
* Extracts and validates environment variables embedded in the built bundle.
|
||||
* Ensures redirect URIs match the expected deployment target.
|
||||
*
|
||||
* Usage:
|
||||
* BUILD_TARGET=docker-local npm run build:verify
|
||||
* BUILD_TARGET=production npm run build:verify
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Expected redirect URIs for each deployment target
|
||||
const EXPECTED_URIS = {
|
||||
'docker-local': 'http://localhost:8099/oauth/callback',
|
||||
production: 'https://app.pokedex.online/oauth/callback'
|
||||
};
|
||||
|
||||
// ANSI colors
|
||||
const colors = {
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
};
|
||||
|
||||
function log(color, symbol, message) {
|
||||
console.log(`${colors[color]}${symbol}${colors.reset} ${message}`);
|
||||
}
|
||||
|
||||
function findBuiltAssets() {
|
||||
const distPath = path.resolve(__dirname, '../dist/assets');
|
||||
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error('dist/assets directory not found. Run build first.');
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(distPath);
|
||||
const jsFiles = files.filter(
|
||||
f => f.endsWith('.js') && f.startsWith('index-')
|
||||
);
|
||||
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error('No built JavaScript files found in dist/assets/');
|
||||
}
|
||||
|
||||
return jsFiles.map(f => path.join(distPath, f));
|
||||
}
|
||||
|
||||
function extractRedirectUri(content) {
|
||||
// Look for the Discord redirect URI in the built bundle
|
||||
const patterns = [
|
||||
/VITE_DISCORD_REDIRECT_URI[\"']?\s*[:=]\s*[\"']([^\"']+)[\"']/,
|
||||
/discord_redirect_uri[\"']?\s*[:=]\s*[\"']([^\"']+)[\"']/i,
|
||||
/redirectUri[\"']?\s*[:=]\s*[\"']([^\"']+oauth\/callback)[\"']/,
|
||||
/(https?:\/\/[^\"'\s]+\/oauth\/callback)/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyBuild() {
|
||||
console.log('\n🔍 Build Verification\n');
|
||||
|
||||
// Get target from environment variable
|
||||
const target = process.env.BUILD_TARGET || 'local';
|
||||
|
||||
if (!EXPECTED_URIS[target]) {
|
||||
log('red', '❌', `Invalid BUILD_TARGET: ${target}`);
|
||||
log('blue', 'ℹ', 'Valid targets: docker-local, production');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expectedUri = EXPECTED_URIS[target];
|
||||
log('blue', 'ℹ', `Deployment target: ${target}`);
|
||||
log('blue', 'ℹ', `Expected redirect URI: ${expectedUri}`);
|
||||
|
||||
try {
|
||||
// Find built assets
|
||||
const assetFiles = findBuiltAssets();
|
||||
log('green', '✅', `Found ${assetFiles.length} built asset(s)`);
|
||||
|
||||
// Search for redirect URI in all assets
|
||||
let foundUri = null;
|
||||
for (const assetFile of assetFiles) {
|
||||
const content = fs.readFileSync(assetFile, 'utf8');
|
||||
const uri = extractRedirectUri(content);
|
||||
|
||||
if (uri) {
|
||||
foundUri = uri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUri) {
|
||||
log('yellow', '⚠', 'Could not find Discord redirect URI in bundle');
|
||||
log('blue', 'ℹ', 'This may be OK if OAuth is not used');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
log('blue', 'ℹ', `Found redirect URI: ${foundUri}`);
|
||||
|
||||
// Validate URI matches expected
|
||||
if (foundUri === expectedUri) {
|
||||
log('green', '✅', 'Redirect URI matches expected value!');
|
||||
console.log('\n✅ Build verification passed\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
log('red', '❌', 'Redirect URI MISMATCH!');
|
||||
log('blue', 'ℹ', `Expected: ${expectedUri}`);
|
||||
log('blue', 'ℹ', `Found: ${foundUri}`);
|
||||
console.log('\n❌ Build verification failed\n');
|
||||
console.log(
|
||||
'This usually means the wrong .env.{mode} file was used during build.'
|
||||
);
|
||||
console.log("Check that you're using the correct Vite mode flag:\n");
|
||||
console.log(
|
||||
' vite build --mode docker-local (for local Docker deployment)'
|
||||
);
|
||||
console.log(
|
||||
' vite build --mode production (for Synology deployment)\n'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
log('red', '❌', `Verification error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run verification
|
||||
verifyBuild();
|
||||
33
code/websites/pokedex.online/server/.env.development
Normal file
33
code/websites/pokedex.online/server/.env.development
Normal file
@@ -0,0 +1,33 @@
|
||||
# ====================================================================
|
||||
# BACKEND DEVELOPMENT ENVIRONMENT (localhost:3001)
|
||||
# ====================================================================
|
||||
# Used when running: npm run dev (from server directory)
|
||||
# Supports Vite dev server at localhost:5173
|
||||
# ====================================================================
|
||||
|
||||
# Deployment Target
|
||||
DEPLOYMENT_TARGET=dev
|
||||
|
||||
# Server Configuration
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
DISCORD_CLIENT_SECRET=dMRPAmWxXKQdWOKzgkPbcdsHNCifqtYV
|
||||
DISCORD_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
VITE_DISCORD_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
CHALLONGE_CLIENT_SECRET=5dc805e41001281c2415b5ffd6b85a7855fc08b6019d9be2907fdb85ab2d56fc
|
||||
CHALLONGE_REDIRECT_URI=http://localhost:5173/oauth/callback
|
||||
|
||||
# Discord User Permissions
|
||||
DISCORD_ADMIN_USERS=.fraggin
|
||||
|
||||
# Session Security (dev only - change in production)
|
||||
SESSION_SECRET=dev-secret-for-local-testing-only
|
||||
33
code/websites/pokedex.online/server/.env.docker-local
Normal file
33
code/websites/pokedex.online/server/.env.docker-local
Normal file
@@ -0,0 +1,33 @@
|
||||
# ====================================================================
|
||||
# BACKEND LOCAL DOCKER ENVIRONMENT (localhost:3099)
|
||||
# ====================================================================
|
||||
# Used in docker-compose.docker-local.yml for local production testing
|
||||
# Supports frontend at localhost:8099
|
||||
# ====================================================================
|
||||
|
||||
# Deployment Target
|
||||
DEPLOYMENT_TARGET=docker-local
|
||||
|
||||
# Server Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:8099
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
DISCORD_CLIENT_SECRET=dMRPAmWxXKQdWOKzgkPbcdsHNCifqtYV
|
||||
DISCORD_REDIRECT_URI=http://localhost:8099/oauth/callback
|
||||
VITE_DISCORD_REDIRECT_URI=http://localhost:8099/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
CHALLONGE_CLIENT_SECRET=5dc805e41001281c2415b5ffd6b85a7855fc08b6019d9be2907fdb85ab2d56fc
|
||||
CHALLONGE_REDIRECT_URI=http://localhost:8099/oauth/callback
|
||||
|
||||
# Discord User Permissions
|
||||
DISCORD_ADMIN_USERS=.fraggin
|
||||
|
||||
# Session Security
|
||||
SESSION_SECRET=local-docker-session-secret-change-for-production
|
||||
23
code/websites/pokedex.online/server/.env.example
Normal file
23
code/websites/pokedex.online/server/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# Environment Variables for Production
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
CHALLONGE_CLIENT_ID=your_client_id_here
|
||||
CHALLONGE_CLIENT_SECRET=your_client_secret_here
|
||||
REDIRECT_URI=https://yourdomain.com/oauth/callback
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
|
||||
# Session Security
|
||||
SESSION_SECRET=generate_a_random_secret_key_here
|
||||
|
||||
# Optional: Logging Level
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Discord User Permissions
|
||||
# Comma-separated list of Discord usernames that have developer access
|
||||
# Format: username1,username2,username3
|
||||
# Leave empty to disable developer tools for all users
|
||||
DISCORD_ADMIN_USERS=YourDiscordUsername,AnotherUser
|
||||
33
code/websites/pokedex.online/server/.env.production
Normal file
33
code/websites/pokedex.online/server/.env.production
Normal file
@@ -0,0 +1,33 @@
|
||||
# ====================================================================
|
||||
# BACKEND PRODUCTION ENVIRONMENT (app.pokedex.online)
|
||||
# ====================================================================
|
||||
# Used in docker-compose.production.yml for Synology deployment
|
||||
# Supports frontend at https://app.pokedex.online
|
||||
# ====================================================================
|
||||
|
||||
# Deployment Target
|
||||
DEPLOYMENT_TARGET=production
|
||||
|
||||
# Server Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=https://app.pokedex.online
|
||||
|
||||
# Discord OAuth Configuration
|
||||
VITE_DISCORD_CLIENT_ID=1466544972059775223
|
||||
DISCORD_CLIENT_SECRET=dMRPAmWxXKQdWOKzgkPbcdsHNCifqtYV
|
||||
DISCORD_REDIRECT_URI=https://app.pokedex.online/oauth/callback
|
||||
VITE_DISCORD_REDIRECT_URI=https://app.pokedex.online/oauth/callback
|
||||
|
||||
# Challonge OAuth Configuration
|
||||
CHALLONGE_CLIENT_ID=9d40113bdfd802c6fb01137fa9041b23342ce4f100caedad6dee865a486662df
|
||||
CHALLONGE_CLIENT_SECRET=5dc805e41001281c2415b5ffd6b85a7855fc08b6019d9be2907fdb85ab2d56fc
|
||||
CHALLONGE_REDIRECT_URI=https://app.pokedex.online/oauth/callback
|
||||
|
||||
# Discord User Permissions
|
||||
DISCORD_ADMIN_USERS=.fraggin
|
||||
|
||||
# Session Security (IMPORTANT: Set a strong secret in production!)
|
||||
SESSION_SECRET=production-secret-change-this-to-secure-random-string
|
||||
31
code/websites/pokedex.online/server/Dockerfile
Normal file
31
code/websites/pokedex.online/server/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Backend Production Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create directories for data and logs
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Expose the server port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check endpoint (will be added to server)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Run the application
|
||||
CMD ["node", "oauth-proxy.js"]
|
||||
@@ -0,0 +1,2 @@
|
||||
# Gamemaster data files stored here
|
||||
# These are generated by the GamemasterManager tool
|
||||
422661
code/websites/pokedex.online/server/data/gamemaster/latest-raw.json
Normal file
422661
code/websites/pokedex.online/server/data/gamemaster/latest-raw.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
103711
code/websites/pokedex.online/server/data/gamemaster/pokemon.json
Normal file
103711
code/websites/pokedex.online/server/data/gamemaster/pokemon.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
375
code/websites/pokedex.online/server/gamemaster-api.js
Normal file
375
code/websites/pokedex.online/server/gamemaster-api.js
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Gamemaster API Server
|
||||
* Provides endpoints for accessing processed gamemaster data
|
||||
* Allows other apps to fetch unmodified and modified pokemon data
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
const DATA_DIR = path.join(__dirname, 'data', 'gamemaster');
|
||||
|
||||
/**
|
||||
* Ensure data directory exists
|
||||
*/
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to a gamemaster file
|
||||
* @param {string} filename
|
||||
* @returns {string} Full file path
|
||||
*/
|
||||
function getFilePath(filename) {
|
||||
return path.join(DATA_DIR, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
* @param {string} filename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function fileExists(filename) {
|
||||
return fs.existsSync(getFilePath(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata (size, modified time)
|
||||
* @param {string} filename
|
||||
* @returns {Object|null} File info or null if doesn't exist
|
||||
*/
|
||||
function getFileInfo(filename) {
|
||||
const filepath = getFilePath(filename);
|
||||
if (!fs.existsSync(filepath)) return null;
|
||||
|
||||
const stats = fs.statSync(filepath);
|
||||
return {
|
||||
filename,
|
||||
size: stats.size,
|
||||
sizeKb: (stats.size / 1024).toFixed(2),
|
||||
modified: stats.mtime.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save gamemaster data to file
|
||||
* @param {string} filename
|
||||
* @param {Object|Array} data
|
||||
*/
|
||||
function saveFile(filename, data) {
|
||||
ensureDataDir();
|
||||
const filepath = getFilePath(filename);
|
||||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
|
||||
return getFileInfo(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load gamemaster data from file
|
||||
* @param {string} filename
|
||||
* @returns {Object|Array|null}
|
||||
*/
|
||||
function loadFile(filename) {
|
||||
const filepath = getFilePath(filename);
|
||||
if (!fs.existsSync(filepath)) return null;
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Break up gamemaster into separate categories
|
||||
* @param {Array} gamemaster - Full gamemaster data
|
||||
* @returns {Object} Separated data {pokemon, pokemonAllForms, moves}
|
||||
*/
|
||||
function breakUpGamemaster(gamemaster) {
|
||||
const regionCheck = ['alola', 'galarian', 'hisuian', 'paldea'];
|
||||
|
||||
const result = gamemaster.reduce(
|
||||
(acc, item) => {
|
||||
const templateId = item.templateId;
|
||||
|
||||
// POKEMON FILTER
|
||||
if (
|
||||
templateId.startsWith('V') &&
|
||||
templateId.toLowerCase().includes('pokemon')
|
||||
) {
|
||||
const pokemonSettings = item.data?.pokemonSettings;
|
||||
const pokemonId = pokemonSettings?.pokemonId;
|
||||
|
||||
acc.pokemonAllForms.push(item);
|
||||
|
||||
const form = pokemonSettings?.form;
|
||||
const isRegionalForm =
|
||||
form && typeof form === 'string'
|
||||
? regionCheck.includes(form.split('_')[1]?.toLowerCase())
|
||||
: false;
|
||||
|
||||
if (
|
||||
!acc.pokemonSeen.has(pokemonId) ||
|
||||
(acc.pokemonSeen.has(pokemonId) && isRegionalForm)
|
||||
) {
|
||||
acc.pokemonSeen.add(pokemonId);
|
||||
acc.pokemon.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// POKEMON MOVE FILTER
|
||||
if (
|
||||
templateId.startsWith('V') &&
|
||||
templateId.toLowerCase().includes('move')
|
||||
) {
|
||||
const moveSettings = item.data?.moveSettings;
|
||||
const moveId = moveSettings?.movementId;
|
||||
if (!acc.moveSeen.has(moveId)) {
|
||||
acc.moveSeen.add(moveId);
|
||||
acc.moves.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
pokemon: [],
|
||||
pokemonAllForms: [],
|
||||
moves: [],
|
||||
pokemonSeen: new Set(),
|
||||
moveSeen: new Set()
|
||||
}
|
||||
);
|
||||
|
||||
delete result.pokemonSeen;
|
||||
delete result.moveSeen;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROUTES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/status
|
||||
* Get status of available gamemaster files
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
ensureDataDir();
|
||||
|
||||
const files = {
|
||||
pokemon: getFileInfo('pokemon.json'),
|
||||
pokemonAllForms: getFileInfo('pokemon-allFormsCostumes.json'),
|
||||
moves: getFileInfo('pokemon-moves.json'),
|
||||
raw: getFileInfo('latest-raw.json')
|
||||
};
|
||||
|
||||
res.json({
|
||||
available: Object.values(files).filter(f => f !== null),
|
||||
lastUpdate: files.pokemon?.modified || 'Never',
|
||||
totalFiles: Object.values(files).filter(f => f !== null).length
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/pokemon
|
||||
* Get filtered pokemon data (base forms + regional variants)
|
||||
*/
|
||||
router.get('/pokemon', (req, res) => {
|
||||
const data = loadFile('pokemon.json');
|
||||
if (!data) {
|
||||
return res.status(404).json({
|
||||
error: 'Pokemon data not available. Generate from GamemasterManager.'
|
||||
});
|
||||
}
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/pokemon/allForms
|
||||
* Get all pokemon forms including costumes
|
||||
*/
|
||||
router.get('/pokemon/allForms', (req, res) => {
|
||||
const data = loadFile('pokemon-allFormsCostumes.json');
|
||||
if (!data) {
|
||||
return res.status(404).json({
|
||||
error: 'All forms data not available. Generate from GamemasterManager.'
|
||||
});
|
||||
}
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/moves
|
||||
* Get all pokemon moves
|
||||
*/
|
||||
router.get('/moves', (req, res) => {
|
||||
const data = loadFile('pokemon-moves.json');
|
||||
if (!data) {
|
||||
return res.status(404).json({
|
||||
error: 'Moves data not available. Generate from GamemasterManager.'
|
||||
});
|
||||
}
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/raw
|
||||
* Get raw unmodified gamemaster data
|
||||
*/
|
||||
router.get('/raw', (req, res) => {
|
||||
const data = loadFile('latest-raw.json');
|
||||
if (!data) {
|
||||
return res.status(404).json({
|
||||
error: 'Raw gamemaster data not available. Fetch from GamemasterManager.'
|
||||
});
|
||||
}
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/gamemaster/process
|
||||
* Fetch and process gamemaster data on the server
|
||||
* This avoids sending large payloads from the frontend
|
||||
*/
|
||||
router.post('/process', async (req, res) => {
|
||||
try {
|
||||
const POKEMINERS_URL =
|
||||
'https://raw.githubusercontent.com/PokeMiners/game_masters/master/latest/latest.json';
|
||||
|
||||
console.log('🔄 Fetching gamemaster from PokeMiners...');
|
||||
const response = await fetch(POKEMINERS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
console.log(`✅ Fetched ${raw.length} items from PokeMiners`);
|
||||
|
||||
// Break up the data server-side
|
||||
console.log('⚙️ Processing gamemaster data...');
|
||||
const processed = breakUpGamemaster(raw);
|
||||
|
||||
// Save all files
|
||||
console.log('💾 Saving files to disk...');
|
||||
const results = {
|
||||
pokemon: saveFile('pokemon.json', processed.pokemon),
|
||||
pokemonAllForms: saveFile(
|
||||
'pokemon-allFormsCostumes.json',
|
||||
processed.pokemonAllForms
|
||||
),
|
||||
moves: saveFile('pokemon-moves.json', processed.moves),
|
||||
raw: saveFile('latest-raw.json', raw)
|
||||
};
|
||||
|
||||
console.log('✅ All files saved successfully');
|
||||
|
||||
res.json({
|
||||
message: 'Gamemaster processed and saved successfully',
|
||||
files: results,
|
||||
stats: {
|
||||
pokemon: processed.pokemon.length,
|
||||
pokemonAllForms: processed.pokemonAllForms.length,
|
||||
moves: processed.moves.length,
|
||||
raw: raw.length
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing gamemaster:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to process gamemaster',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/gamemaster/save
|
||||
* Save processed gamemaster data (called by GamemasterManager)
|
||||
* Body: {pokemon, pokemonAllForms, moves, raw}
|
||||
*/
|
||||
router.post('/save', express.json({ limit: '50mb' }), (req, res) => {
|
||||
try {
|
||||
const { pokemon, pokemonAllForms, moves, raw } = req.body;
|
||||
|
||||
if (!pokemon || !pokemonAllForms || !moves) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required data: pokemon, pokemonAllForms, moves'
|
||||
});
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
if (pokemon) {
|
||||
results.pokemon = saveFile('pokemon.json', pokemon);
|
||||
}
|
||||
if (pokemonAllForms) {
|
||||
results.pokemonAllForms = saveFile(
|
||||
'pokemon-allFormsCostumes.json',
|
||||
pokemonAllForms
|
||||
);
|
||||
}
|
||||
if (moves) {
|
||||
results.moves = saveFile('pokemon-moves.json', moves);
|
||||
}
|
||||
if (raw) {
|
||||
results.raw = saveFile('latest-raw.json', raw);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Files saved successfully',
|
||||
files: results,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving gamemaster data:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to save files',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gamemaster/download/:filename
|
||||
* Download a specific gamemaster file
|
||||
*/
|
||||
router.get('/download/:filename', (req, res) => {
|
||||
const filename = req.params.filename;
|
||||
|
||||
// Validate filename to prevent path traversal
|
||||
const allowedFiles = [
|
||||
'pokemon.json',
|
||||
'pokemon-allFormsCostumes.json',
|
||||
'pokemon-moves.json',
|
||||
'latest-raw.json'
|
||||
];
|
||||
|
||||
if (!allowedFiles.includes(filename)) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filepath = getFilePath(filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.sendFile(filepath);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================================
|
||||
|
||||
ensureDataDir();
|
||||
|
||||
export default router;
|
||||
126
code/websites/pokedex.online/server/middleware/auth.js
Normal file
126
code/websites/pokedex.online/server/middleware/auth.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Authentication Middleware
|
||||
*
|
||||
* Middleware for authenticating and authorizing requests using JWT tokens
|
||||
*/
|
||||
|
||||
import { verifyToken } from '../utils/jwt-utils.js';
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token from Authorization header
|
||||
* Sets req.user if token is valid
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.secret - JWT secret for token verification
|
||||
* @param {boolean} options.optional - If true, missing token is not an error (default: false)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export function authMiddleware({ secret, optional = false } = {}) {
|
||||
return (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
if (optional) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: 'Missing authorization header',
|
||||
code: 'MISSING_AUTH_HEADER'
|
||||
});
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>" format
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid authorization header format. Expected: Bearer <token>',
|
||||
code: 'INVALID_AUTH_FORMAT'
|
||||
});
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token, secret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
const code = err.message.includes('expired')
|
||||
? 'TOKEN_EXPIRED'
|
||||
: 'INVALID_TOKEN';
|
||||
return res.status(401).json({
|
||||
error: err.message,
|
||||
code
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if user has required permissions
|
||||
*
|
||||
* @param {string|string[]} requiredPermissions - Permission or array of permissions required
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export function requirePermission(requiredPermissions) {
|
||||
const permissions = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden: User not authenticated',
|
||||
code: 'NOT_AUTHENTICATED'
|
||||
});
|
||||
}
|
||||
|
||||
const userPermissions = req.user.permissions || [];
|
||||
const hasPermission = permissions.some(perm =>
|
||||
userPermissions.includes(perm)
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({
|
||||
error: `Forbidden: Missing required permissions: ${permissions.join(', ')}`,
|
||||
code: 'INSUFFICIENT_PERMISSIONS'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if user is admin
|
||||
*
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (!req.user || !req.user.isAdmin) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden: Admin access required',
|
||||
code: 'ADMIN_REQUIRED'
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler for authentication errors
|
||||
*
|
||||
* @param {Error} err - Error object
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} res - Express response
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
export function authErrorHandler(err, req, res, next) {
|
||||
if (err.code === 'INVALID_TOKEN' || err.code === 'TOKEN_EXPIRED') {
|
||||
return res.status(401).json({
|
||||
error: err.message,
|
||||
code: err.code
|
||||
});
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
74
code/websites/pokedex.online/server/middleware/csrf.js
Normal file
74
code/websites/pokedex.online/server/middleware/csrf.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { COOKIE_NAMES } from '../utils/cookie-options.js';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function getCookieValuesFromHeader(cookieHeader, name) {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') return [];
|
||||
const values = [];
|
||||
const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g');
|
||||
let match;
|
||||
while ((match = pattern.exec(cookieHeader)) !== null) {
|
||||
values.push(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function csrfMiddleware(options = {}) {
|
||||
const {
|
||||
cookieName = COOKIE_NAMES.csrf,
|
||||
headerName = 'x-csrf-token',
|
||||
requireOriginCheck = false,
|
||||
allowedOrigin = null
|
||||
} = options;
|
||||
|
||||
return function csrf(req, res, next) {
|
||||
if (SAFE_METHODS.has(req.method)) return next();
|
||||
|
||||
// Optional origin check hardening (recommended in production)
|
||||
if (requireOriginCheck && allowedOrigin) {
|
||||
const origin = req.headers.origin;
|
||||
const referer = req.headers.referer;
|
||||
const ok =
|
||||
(origin && origin === allowedOrigin) ||
|
||||
(!origin && referer && referer.startsWith(allowedOrigin));
|
||||
|
||||
if (!ok) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF origin check failed',
|
||||
code: 'CSRF_ORIGIN_FAILED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const csrfCookie = req.cookies?.[cookieName];
|
||||
const csrfHeader = req.headers[headerName];
|
||||
|
||||
// Handle duplicate cookies with the same name (e.g. legacy '/api' path plus
|
||||
// current '/' path). cookie-parser will pick one value, but the browser may
|
||||
// send both. Accept if the header matches ANY provided cookie value.
|
||||
const rawHeader = req.headers?.cookie || '';
|
||||
const rawValues = getCookieValuesFromHeader(rawHeader, cookieName).map(
|
||||
v => {
|
||||
try {
|
||||
return decodeURIComponent(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
);
|
||||
const anyMatch = csrfHeader && rawValues.includes(csrfHeader);
|
||||
|
||||
if (
|
||||
!csrfHeader ||
|
||||
(!csrfCookie && !anyMatch) ||
|
||||
(csrfCookie !== csrfHeader && !anyMatch)
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: 'CSRF validation failed',
|
||||
code: 'CSRF_FAILED'
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
96
code/websites/pokedex.online/server/middleware/sid.js
Normal file
96
code/websites/pokedex.online/server/middleware/sid.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
generateToken,
|
||||
getLegacyCsrfCookieOptions,
|
||||
getLegacySidCookieOptions,
|
||||
getSidCookieOptions
|
||||
} from '../utils/cookie-options.js';
|
||||
|
||||
function signSid(sessionSecret, sid) {
|
||||
return crypto
|
||||
.createHmac('sha256', sessionSecret)
|
||||
.update(sid)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function parseAndVerifySignedSid(sessionSecret, signedValue) {
|
||||
if (!signedValue || typeof signedValue !== 'string') return null;
|
||||
const idx = signedValue.lastIndexOf('.');
|
||||
if (idx <= 0) return null;
|
||||
|
||||
const sid = signedValue.slice(0, idx);
|
||||
const sig = signedValue.slice(idx + 1);
|
||||
if (!sid || !sig) return null;
|
||||
|
||||
const expected = signSid(sessionSecret, sid);
|
||||
try {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sid;
|
||||
}
|
||||
|
||||
function getCookieValuesFromHeader(cookieHeader, name) {
|
||||
if (!cookieHeader || typeof cookieHeader !== 'string') return [];
|
||||
|
||||
// Multiple cookies with the same name can exist if older cookies were scoped
|
||||
// to a different path (e.g. '/api') than newer ones ('/').
|
||||
const values = [];
|
||||
const pattern = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`, 'g');
|
||||
let match;
|
||||
while ((match = pattern.exec(cookieHeader)) !== null) {
|
||||
values.push(match[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function sidMiddleware({ sessionSecret, config }) {
|
||||
if (!sessionSecret) {
|
||||
throw new Error('sidMiddleware requires sessionSecret');
|
||||
}
|
||||
|
||||
return function sid(req, res, next) {
|
||||
// If older cookies (scoped to '/api') exist alongside newer cookies
|
||||
// (scoped to '/'), browsers may send both. Some parsers will then pick the
|
||||
// "wrong" one depending on header order, causing auth to appear connected
|
||||
// in one request and missing in another.
|
||||
const rawCookieHeader = req.headers?.cookie || '';
|
||||
if (rawCookieHeader.includes(`${COOKIE_NAMES.sid}=`)) {
|
||||
res.clearCookie(COOKIE_NAMES.sid, getLegacySidCookieOptions(config));
|
||||
}
|
||||
if (rawCookieHeader.includes(`${COOKIE_NAMES.csrf}=`)) {
|
||||
res.clearCookie(COOKIE_NAMES.csrf, getLegacyCsrfCookieOptions(config));
|
||||
}
|
||||
|
||||
const signedCandidates = getCookieValuesFromHeader(
|
||||
rawCookieHeader,
|
||||
COOKIE_NAMES.sid
|
||||
);
|
||||
const signedFromParser = req.cookies?.[COOKIE_NAMES.sid];
|
||||
if (signedFromParser) signedCandidates.push(signedFromParser);
|
||||
|
||||
// If multiple signed SIDs are present (legacy '/api' cookie + current '/'),
|
||||
// browsers tend to send the more-specific path cookie first.
|
||||
// Prefer the last valid SID to bias towards the newer '/' cookie.
|
||||
let sid = null;
|
||||
for (let i = signedCandidates.length - 1; i >= 0; i -= 1) {
|
||||
const signed = signedCandidates[i];
|
||||
sid = parseAndVerifySignedSid(sessionSecret, signed);
|
||||
if (sid) break;
|
||||
}
|
||||
|
||||
if (!sid) {
|
||||
sid = generateToken(24);
|
||||
const signedSid = `${sid}.${signSid(sessionSecret, sid)}`;
|
||||
res.cookie(COOKIE_NAMES.sid, signedSid, getSidCookieOptions(config));
|
||||
}
|
||||
|
||||
req.sid = sid;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -6,148 +6,125 @@
|
||||
*
|
||||
* Usage:
|
||||
* Development: node server/oauth-proxy.js
|
||||
* Production: Deploy as serverless function or Express app
|
||||
* Production: Deploy with Docker (see docker-compose.production.yml)
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import gamemasterRouter from './gamemaster-api.js';
|
||||
import { createAuthRouter } from './routes/auth.js';
|
||||
import { createOAuthRouter } from './routes/oauth.js';
|
||||
import { createSessionRouter } from './routes/session.js';
|
||||
import { createChallongeProxyRouter } from './routes/challonge.js';
|
||||
import { createDiscordRouter } from './routes/discord.js';
|
||||
import { validateOrExit, getConfig } from './utils/env-validator.js';
|
||||
import logger, { requestLogger, errorLogger } from './utils/logger.js';
|
||||
import {
|
||||
setupGracefulShutdown,
|
||||
createHealthCheckMiddleware
|
||||
} from './utils/graceful-shutdown.js';
|
||||
import { sidMiddleware } from './middleware/sid.js';
|
||||
import { csrfMiddleware } from './middleware/csrf.js';
|
||||
import { createOAuthTokenStore } from './services/oauth-token-store.js';
|
||||
|
||||
// Validate environment variables
|
||||
validateOrExit();
|
||||
|
||||
// Get validated configuration
|
||||
const config = getConfig();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.OAUTH_PROXY_PORT || 3001;
|
||||
|
||||
// Environment variables (set in .env file)
|
||||
const CLIENT_ID = process.env.CHALLONGE_CLIENT_ID;
|
||||
const CLIENT_SECRET = process.env.CHALLONGE_CLIENT_SECRET;
|
||||
const REDIRECT_URI =
|
||||
process.env.CHALLONGE_REDIRECT_URI || 'http://localhost:5173/oauth/callback';
|
||||
|
||||
// Validate required environment variables
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error('❌ Missing required environment variables:');
|
||||
console.error(' CHALLONGE_CLIENT_ID');
|
||||
console.error(' CHALLONGE_CLIENT_SECRET');
|
||||
console.error('\nSet these in your .env file or environment.');
|
||||
process.exit(1);
|
||||
}
|
||||
// Behind nginx reverse proxy in production
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Middleware
|
||||
app.use(
|
||||
cors({
|
||||
origin:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? process.env.FRONTEND_URL
|
||||
: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:5175'
|
||||
]
|
||||
origin: config.cors.origin,
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use(requestLogger);
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* POST /oauth/token
|
||||
*/
|
||||
app.post('/oauth/token', async (req, res) => {
|
||||
const { code } = req.body;
|
||||
// Per-session identity (httpOnly signed SID cookie)
|
||||
app.use(
|
||||
sidMiddleware({
|
||||
sessionSecret: config.session.secret,
|
||||
config
|
||||
})
|
||||
);
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Missing authorization code' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: code,
|
||||
redirect_uri: REDIRECT_URI
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Token exchange failed:', data);
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
console.log('✅ Token exchange successful');
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Token exchange error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Token exchange failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
// Encrypted per-session provider token store
|
||||
const tokenStore = createOAuthTokenStore({
|
||||
sessionSecret: config.session.secret
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
* POST /oauth/refresh
|
||||
*/
|
||||
app.post('/oauth/refresh', async (req, res) => {
|
||||
const { refresh_token } = req.body;
|
||||
// Mount API routes (nginx strips /api/ prefix before forwarding)
|
||||
app.use('/gamemaster', gamemasterRouter);
|
||||
app.use(
|
||||
'/auth',
|
||||
createAuthRouter({
|
||||
secret: config.secret,
|
||||
adminPassword: config.adminPassword
|
||||
})
|
||||
);
|
||||
|
||||
if (!refresh_token) {
|
||||
return res.status(400).json({ error: 'Missing refresh token' });
|
||||
}
|
||||
// Session + CSRF helpers
|
||||
app.use('/session', createSessionRouter({ config, tokenStore }));
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: refresh_token
|
||||
})
|
||||
});
|
||||
// Provider OAuth (server-owned tokens; browser never receives access/refresh tokens)
|
||||
app.use(
|
||||
'/oauth',
|
||||
csrfMiddleware({
|
||||
requireOriginCheck: config.isProduction,
|
||||
allowedOrigin: config.cors.origin
|
||||
})
|
||||
);
|
||||
app.use('/oauth', createOAuthRouter({ config, tokenStore }));
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Token refresh failed:', data);
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
console.log('✅ Token refresh successful');
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Token refresh failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
// Provider API proxies (no split brain)
|
||||
app.use('/challonge', createChallongeProxyRouter({ config, tokenStore }));
|
||||
app.use('/discord', createDiscordRouter({ tokenStore }));
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
* Health check endpoint (with graceful shutdown support)
|
||||
* GET /health
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'oauth-proxy',
|
||||
configured: !!(CLIENT_ID && CLIENT_SECRET)
|
||||
app.get('/health', createHealthCheckMiddleware());
|
||||
|
||||
// Error logging middleware (must be after routes)
|
||||
app.use(errorLogger);
|
||||
|
||||
// Start server
|
||||
const server = app.listen(config.port, () => {
|
||||
logger.info('🔐 OAuth Proxy Server started', {
|
||||
port: config.port,
|
||||
nodeEnv: config.nodeEnv,
|
||||
challongeConfigured: config.challonge.configured
|
||||
});
|
||||
|
||||
if (!config.challonge.configured) {
|
||||
logger.warn(
|
||||
'⚠️ Challonge OAuth not configured - OAuth endpoints disabled'
|
||||
);
|
||||
logger.warn(
|
||||
' Set CHALLONGE_CLIENT_ID and CHALLONGE_CLIENT_SECRET to enable'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('✅ Ready to handle requests');
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🔐 OAuth Proxy Server running on http://localhost:${PORT}`);
|
||||
console.log(`📝 Client ID: ${CLIENT_ID}`);
|
||||
console.log(`🔗 Redirect URI: ${REDIRECT_URI}`);
|
||||
console.log('\n✅ Ready to handle OAuth requests');
|
||||
// Setup graceful shutdown
|
||||
setupGracefulShutdown(server, {
|
||||
timeout: 30000,
|
||||
onShutdown: async () => {
|
||||
logger.info('Running cleanup tasks...');
|
||||
// Add any cleanup tasks here (close DB connections, etc.)
|
||||
}
|
||||
});
|
||||
|
||||
31
code/websites/pokedex.online/server/package.json
Normal file
31
code/websites/pokedex.online/server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "pokedex-online-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Backend server for Pokedex Online - OAuth proxy and Gamemaster API",
|
||||
"main": "oauth-proxy.js",
|
||||
"scripts": {
|
||||
"start": "node oauth-proxy.js",
|
||||
"dev": "DOTENV_CONFIG_PATH=.env.development node oauth-proxy.js",
|
||||
"build": "echo 'Backend is Node.js - no build step required'",
|
||||
"gamemaster": "DOTENV_CONFIG_PATH=.env.development node gamemaster-api.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "echo 'Add ESLint when ready'",
|
||||
"validate": "node utils/env-validator.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^1.6.1",
|
||||
"supertest": "^6.3.4"
|
||||
}
|
||||
}
|
||||
340
code/websites/pokedex.online/server/routes/auth.js
Normal file
340
code/websites/pokedex.online/server/routes/auth.js
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Authentication Routes
|
||||
*
|
||||
* Handles login, logout, token refresh, and user info endpoints
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createToken,
|
||||
verifyToken,
|
||||
decodeToken,
|
||||
getTokenExpiresIn
|
||||
} from '../utils/jwt-utils.js';
|
||||
|
||||
export function createAuthRouter({ secret, adminPassword } = {}) {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
* Login with admin password to receive JWT token
|
||||
*/
|
||||
router.post('/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: 'Password is required',
|
||||
code: 'MISSING_PASSWORD'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (password !== adminPassword) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid password',
|
||||
code: 'INVALID_PASSWORD'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Create token with admin permissions
|
||||
const token = createToken(
|
||||
{
|
||||
isAdmin: true,
|
||||
permissions: ['admin', 'gamemaster-edit'],
|
||||
loginTime: new Date().toISOString()
|
||||
},
|
||||
secret,
|
||||
7 * 24 * 60 * 60 // 7 days
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
expiresIn: 7 * 24 * 60 * 60,
|
||||
user: {
|
||||
isAdmin: true,
|
||||
permissions: ['admin', 'gamemaster-edit']
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: 'Failed to create token',
|
||||
code: 'TOKEN_CREATION_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/verify
|
||||
* Verify that a token is valid
|
||||
*/
|
||||
router.post('/verify', (req, res) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
error: 'Token is required',
|
||||
code: 'MISSING_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token, secret);
|
||||
const expiresIn = getTokenExpiresIn(token);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: {
|
||||
isAdmin: decoded.isAdmin,
|
||||
permissions: decoded.permissions
|
||||
},
|
||||
expiresIn: Math.floor(expiresIn / 1000),
|
||||
expiresAt: new Date(Date.now() + expiresIn)
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
valid: false,
|
||||
error: err.message,
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Refresh an existing token
|
||||
*/
|
||||
router.post('/refresh', (req, res) => {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
error: 'Token is required',
|
||||
code: 'MISSING_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token, secret);
|
||||
|
||||
// Create new token with same payload but extended expiration
|
||||
const newToken = createToken(
|
||||
{
|
||||
isAdmin: decoded.isAdmin,
|
||||
permissions: decoded.permissions,
|
||||
loginTime: decoded.loginTime
|
||||
},
|
||||
secret,
|
||||
7 * 24 * 60 * 60 // 7 days
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: newToken,
|
||||
expiresIn: 7 * 24 * 60 * 60
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
error: err.message,
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/user
|
||||
* Get current user info (requires valid token via middleware)
|
||||
*/
|
||||
router.get('/user', (req, res) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Not authenticated',
|
||||
code: 'NOT_AUTHENTICATED'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
isAdmin: req.user.isAdmin,
|
||||
permissions: req.user.permissions,
|
||||
loginTime: req.user.loginTime
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Logout (token is invalidated on client side)
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /oauth/token
|
||||
* Exchange OAuth authorization code for access token
|
||||
* Supports multiple providers (Discord, Challonge, etc.)
|
||||
*/
|
||||
router.post('/oauth/token', async (req, res) => {
|
||||
const { code, provider } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Authorization code is required',
|
||||
code: 'MISSING_CODE'
|
||||
});
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({
|
||||
error: 'Provider is required',
|
||||
code: 'MISSING_PROVIDER'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle Discord OAuth
|
||||
if (provider === 'discord') {
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
process.env.VITE_DISCORD_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
console.error('Discord OAuth not configured:', {
|
||||
hasClientId: !!clientId,
|
||||
hasClientSecret: !!clientSecret
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'Discord OAuth not configured on server',
|
||||
code: 'OAUTH_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
// Exchange code for token with Discord
|
||||
const tokenResponse = await fetch(
|
||||
'https://discord.com/api/oauth2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.text();
|
||||
console.error('Discord token exchange failed:', errorData);
|
||||
return res.status(tokenResponse.status).json({
|
||||
error: 'Failed to exchange code with Discord',
|
||||
code: 'DISCORD_TOKEN_EXCHANGE_FAILED',
|
||||
details: errorData
|
||||
});
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
// Return tokens to client
|
||||
return res.json({
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
token_type: tokenData.token_type,
|
||||
expires_in: tokenData.expires_in,
|
||||
scope: tokenData.scope
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Challonge OAuth (if needed in the future)
|
||||
if (provider === 'challonge') {
|
||||
// Challonge uses the existing /api/oauth/token endpoint via oauth-proxy.js
|
||||
return res.status(400).json({
|
||||
error: 'Use /api/oauth/token for Challonge OAuth',
|
||||
code: 'WRONG_ENDPOINT'
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown provider
|
||||
return res.status(400).json({
|
||||
error: `Unknown provider: ${provider}`,
|
||||
code: 'UNKNOWN_PROVIDER'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('OAuth token exchange error:', err);
|
||||
return res.status(500).json({
|
||||
error: err.message || 'Failed to exchange OAuth code',
|
||||
code: 'TOKEN_EXCHANGE_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/discord/profile
|
||||
* Fetch Discord user profile using the stored Discord token
|
||||
* Requires: Authorization header with Discord access token
|
||||
*/
|
||||
router.get('/discord/profile', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'Missing or invalid authorization header',
|
||||
code: 'MISSING_AUTH'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring('Bearer '.length);
|
||||
|
||||
// Fetch user profile from Discord API
|
||||
const response = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Discord API error:', response.status);
|
||||
return res.status(response.status).json({
|
||||
error: 'Failed to fetch Discord profile',
|
||||
code: 'DISCORD_API_ERROR'
|
||||
});
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
|
||||
// Return user data
|
||||
res.json({
|
||||
user: {
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
global_name: userData.global_name,
|
||||
discriminator: userData.discriminator,
|
||||
avatar: userData.avatar,
|
||||
email: userData.email,
|
||||
verified: userData.verified
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Discord profile:', err);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch Discord profile',
|
||||
code: 'PROFILE_FETCH_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
270
code/websites/pokedex.online/server/routes/challonge.js
Normal file
270
code/websites/pokedex.online/server/routes/challonge.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
function isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt - 30_000; // 30s buffer
|
||||
}
|
||||
|
||||
async function refreshUserOAuth({ config, refreshToken }) {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const err = new Error('Challonge refresh failed');
|
||||
err.status = response.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function exchangeClientCredentials({ clientId, clientSecret, scope }) {
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
...(scope ? { scope } : {})
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const err = new Error('Challonge client_credentials exchange failed');
|
||||
err.status = response.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function computeExpiresAt(expiresInSeconds) {
|
||||
const ttl = Number(expiresInSeconds || 0);
|
||||
if (!ttl || Number.isNaN(ttl)) return null;
|
||||
return Date.now() + ttl * 1000;
|
||||
}
|
||||
|
||||
export function createChallongeProxyRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Proxy all Challonge requests through backend; auth is derived from SID-stored credentials.
|
||||
router.all('/*', async (req, res) => {
|
||||
try {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challongeRecord =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
// Determine upstream path relative to this router mount
|
||||
// This router is mounted at /challonge, so req.url starts with /v1/... or /v2.1/...
|
||||
const upstreamPath = req.url.replace(/^\/+/, '');
|
||||
const upstreamUrl = new URL(`https://api.challonge.com/${upstreamPath}`);
|
||||
|
||||
const authTypeRaw = req.header('Authorization-Type');
|
||||
const authType = authTypeRaw?.toLowerCase();
|
||||
const wantsApplication = upstreamPath.startsWith('v2.1/application/');
|
||||
|
||||
const isSafeMethod = req.method === 'GET' || req.method === 'HEAD';
|
||||
|
||||
// Build headers
|
||||
const headers = { ...req.headers };
|
||||
delete headers.host;
|
||||
delete headers.connection;
|
||||
delete headers['content-length'];
|
||||
|
||||
// Normalize sensitive/auth headers (avoid duplicate casing like
|
||||
// 'authorization-type' + 'Authorization-Type' which can confuse upstream)
|
||||
delete headers.authorization;
|
||||
delete headers.Authorization;
|
||||
delete headers['authorization-type'];
|
||||
delete headers['Authorization-Type'];
|
||||
|
||||
// Apply auth based on request + stored credentials
|
||||
if (upstreamPath.startsWith('v1/')) {
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge API key not configured for this session',
|
||||
code: 'CHALLONGE_API_KEY_REQUIRED'
|
||||
});
|
||||
}
|
||||
upstreamUrl.searchParams.set('api_key', apiKey);
|
||||
} else if (upstreamPath.startsWith('v2.1/')) {
|
||||
if (wantsApplication) {
|
||||
const app = challongeRecord.client_credentials;
|
||||
if (!app?.client_id || !app?.client_secret) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
'Challonge client credentials not configured for this session',
|
||||
code: 'CHALLONGE_CLIENT_CREDENTIALS_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken = app.access_token;
|
||||
if (!accessToken || isExpired(app.expires_at)) {
|
||||
const exchanged = await exchangeClientCredentials({
|
||||
clientId: app.client_id,
|
||||
clientSecret: app.client_secret,
|
||||
scope: app.scope
|
||||
});
|
||||
challongeRecord.client_credentials = {
|
||||
...app,
|
||||
access_token: exchanged.access_token,
|
||||
token_type: exchanged.token_type,
|
||||
scope: exchanged.scope,
|
||||
expires_at: computeExpiresAt(exchanged.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(
|
||||
req.sid,
|
||||
'challonge',
|
||||
challongeRecord
|
||||
);
|
||||
accessToken = challongeRecord.client_credentials.access_token;
|
||||
}
|
||||
|
||||
headers['authorization'] = `Bearer ${accessToken}`;
|
||||
headers['authorization-type'] = 'v2';
|
||||
} else if (authType === 'v1') {
|
||||
// v2.1 supports legacy API key via Authorization header + Authorization-Type: v1
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge API key not configured for this session',
|
||||
code: 'CHALLONGE_API_KEY_REQUIRED'
|
||||
});
|
||||
}
|
||||
headers['authorization'] = apiKey;
|
||||
headers['authorization-type'] = 'v1';
|
||||
} else {
|
||||
// default to user OAuth (Bearer)
|
||||
const user = challongeRecord.user_oauth;
|
||||
if (!user?.access_token) {
|
||||
return res.status(401).json({
|
||||
error: 'Challonge OAuth not connected for this session',
|
||||
code: 'CHALLONGE_OAUTH_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
let accessToken = user.access_token;
|
||||
if (
|
||||
isExpired(user.expires_at) &&
|
||||
user.refresh_token &&
|
||||
config.challonge.configured
|
||||
) {
|
||||
try {
|
||||
const refreshed = await refreshUserOAuth({
|
||||
config,
|
||||
refreshToken: user.refresh_token
|
||||
});
|
||||
challongeRecord.user_oauth = {
|
||||
...user,
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token || user.refresh_token,
|
||||
token_type: refreshed.token_type,
|
||||
scope: refreshed.scope,
|
||||
expires_at: computeExpiresAt(refreshed.expires_in)
|
||||
};
|
||||
await tokenStore.setProviderRecord(
|
||||
req.sid,
|
||||
'challonge',
|
||||
challongeRecord
|
||||
);
|
||||
accessToken = challongeRecord.user_oauth.access_token;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to refresh Challonge user OAuth token', {
|
||||
status: err.status,
|
||||
payload: err.payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
headers['authorization'] = `Bearer ${accessToken}`;
|
||||
headers['authorization-type'] = 'v2';
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOptions = {
|
||||
method: req.method,
|
||||
headers
|
||||
};
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
if (req.body !== undefined && req.body !== null) {
|
||||
// express.json parsed it already
|
||||
fetchOptions.body = JSON.stringify(req.body);
|
||||
}
|
||||
}
|
||||
|
||||
let upstreamResponse = await fetch(upstreamUrl.toString(), fetchOptions);
|
||||
|
||||
// If user OAuth is present but invalid/revoked, the upstream may return 401/403.
|
||||
// For safe methods, fall back to the stored API key if available.
|
||||
// This helps avoid a confusing "connected" state that still can't query tournaments.
|
||||
if (
|
||||
isSafeMethod &&
|
||||
upstreamPath.startsWith('v2.1/') &&
|
||||
!wantsApplication &&
|
||||
authType !== 'v1' &&
|
||||
(upstreamResponse.status === 401 || upstreamResponse.status === 403)
|
||||
) {
|
||||
const apiKey = challongeRecord.api_key?.token;
|
||||
if (apiKey) {
|
||||
logger.warn(
|
||||
'Challonge v2.1 user OAuth unauthorized; retrying with API key',
|
||||
{
|
||||
status: upstreamResponse.status,
|
||||
path: upstreamPath
|
||||
}
|
||||
);
|
||||
|
||||
const retryHeaders = { ...headers };
|
||||
delete retryHeaders.authorization;
|
||||
delete retryHeaders.Authorization;
|
||||
delete retryHeaders['authorization-type'];
|
||||
delete retryHeaders['Authorization-Type'];
|
||||
retryHeaders['authorization'] = apiKey;
|
||||
retryHeaders['authorization-type'] = 'v1';
|
||||
|
||||
upstreamResponse = await fetch(upstreamUrl.toString(), {
|
||||
...fetchOptions,
|
||||
headers: retryHeaders
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Forward status + headers (minimal)
|
||||
res.status(upstreamResponse.status);
|
||||
const contentType = upstreamResponse.headers.get('content-type');
|
||||
if (contentType) res.setHeader('content-type', contentType);
|
||||
|
||||
const buf = await upstreamResponse.arrayBuffer();
|
||||
return res.send(Buffer.from(buf));
|
||||
} catch (err) {
|
||||
logger.error('Challonge proxy error', { error: err.message });
|
||||
return res.status(502).json({
|
||||
error: 'Challonge proxy failed',
|
||||
code: 'CHALLONGE_PROXY_FAILED'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
41
code/websites/pokedex.online/server/routes/discord.js
Normal file
41
code/websites/pokedex.online/server/routes/discord.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export function createDiscordRouter({ tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/profile', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, 'discord');
|
||||
const accessToken = record?.access_token;
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({
|
||||
error: 'Not connected to Discord',
|
||||
code: 'DISCORD_NOT_CONNECTED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
return res.status(response.status).json({
|
||||
error: 'Failed to fetch Discord profile',
|
||||
code: 'DISCORD_PROFILE_FAILED',
|
||||
details: details.slice(0, 1000)
|
||||
});
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
return res.json({ user });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
519
code/websites/pokedex.online/server/routes/oauth.js
Normal file
519
code/websites/pokedex.online/server/routes/oauth.js
Normal file
@@ -0,0 +1,519 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
function computeExpiresAt(expiresInSeconds) {
|
||||
const ttl = Number(expiresInSeconds || 0);
|
||||
if (!ttl || Number.isNaN(ttl)) return null;
|
||||
return Date.now() + ttl * 1000;
|
||||
}
|
||||
|
||||
function redactProviderRecord(provider, record) {
|
||||
if (!record) {
|
||||
if (provider === 'challonge') {
|
||||
return {
|
||||
provider,
|
||||
connected: false,
|
||||
methods: {
|
||||
user_oauth: {
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
},
|
||||
client_credentials: {
|
||||
stored: false,
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
},
|
||||
api_key: {
|
||||
stored: false,
|
||||
connected: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
return {
|
||||
provider,
|
||||
connected: false,
|
||||
expires_at: null,
|
||||
scope: null
|
||||
};
|
||||
}
|
||||
|
||||
return { provider, connected: false };
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
return {
|
||||
provider,
|
||||
connected: !!record.access_token,
|
||||
expires_at: record.expires_at || null,
|
||||
scope: record.scope || null
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
const user = record.user_oauth;
|
||||
const app = record.client_credentials;
|
||||
const apiKey = record.api_key;
|
||||
|
||||
return {
|
||||
provider,
|
||||
connected: !!(user?.access_token || app?.access_token || apiKey?.token),
|
||||
methods: {
|
||||
user_oauth: {
|
||||
connected: !!user?.access_token,
|
||||
expires_at: user?.expires_at || null,
|
||||
scope: user?.scope || null
|
||||
},
|
||||
client_credentials: {
|
||||
stored: !!(app?.client_id && app?.client_secret),
|
||||
connected: !!app?.access_token,
|
||||
expires_at: app?.expires_at || null,
|
||||
scope: app?.scope || null
|
||||
},
|
||||
api_key: {
|
||||
stored: !!apiKey?.token,
|
||||
connected: !!apiKey?.token
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { provider, connected: true };
|
||||
}
|
||||
|
||||
export function createOAuthRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:provider/status', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, provider);
|
||||
return res.json(redactProviderRecord(provider, record));
|
||||
});
|
||||
|
||||
router.post('/:provider/disconnect', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
await tokenStore.deleteProviderRecord(req.sid, provider);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Exchange authorization code (server stores tokens; frontend never receives them)
|
||||
router.post('/:provider/exchange', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
const { code } = req.body || {};
|
||||
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
error: 'Authorization code is required',
|
||||
code: 'MISSING_CODE'
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
const redirectUri =
|
||||
process.env.DISCORD_REDIRECT_URI ||
|
||||
process.env.VITE_DISCORD_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
return res.status(503).json({
|
||||
error: 'Discord OAuth not configured',
|
||||
code: 'DISCORD_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { raw: text.slice(0, 1000) };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Discord token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Discord token exchange failed',
|
||||
code: 'DISCORD_TOKEN_EXCHANGE_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const record = {
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'discord', record);
|
||||
return res.json(redactProviderRecord('discord', record));
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
if (!config.challonge.configured || !config.challonge.redirectUri) {
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
code: 'CHALLONGE_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
code,
|
||||
redirect_uri: config.challonge.redirectUri
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
logger.warn('Challonge token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge token exchange failed',
|
||||
code: 'CHALLONGE_TOKEN_EXCHANGE_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const user_oauth = {
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
user_oauth
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
error: `Unknown provider: ${provider}`,
|
||||
code: 'UNKNOWN_PROVIDER'
|
||||
});
|
||||
});
|
||||
|
||||
// Store Challonge API key (v1 compatibility) per session
|
||||
router.post('/challonge/api-key', async (req, res) => {
|
||||
let { apiKey } = req.body || {};
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
if (!apiKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'apiKey is required', code: 'MISSING_API_KEY' });
|
||||
}
|
||||
|
||||
apiKey = String(apiKey).trim();
|
||||
if (apiKey.toLowerCase().startsWith('bearer ')) {
|
||||
apiKey = apiKey.slice('bearer '.length).trim();
|
||||
}
|
||||
if (!apiKey) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'apiKey is required', code: 'MISSING_API_KEY' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = {
|
||||
...existing,
|
||||
api_key: {
|
||||
token: apiKey
|
||||
}
|
||||
};
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
router.post('/challonge/api-key/clear', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = { ...existing };
|
||||
if (record.api_key) delete record.api_key;
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Store Challonge client credentials and exchange token per session
|
||||
router.post('/challonge/client-credentials', async (req, res) => {
|
||||
let { clientId, clientSecret, scope } = req.body || {};
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
if (typeof clientId === 'string') clientId = clientId.trim();
|
||||
if (typeof clientSecret === 'string') clientSecret = clientSecret.trim();
|
||||
if (typeof scope === 'string') scope = scope.trim();
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const prev = existing.client_credentials || {};
|
||||
const effectiveClientId = clientId || prev.client_id;
|
||||
const effectiveClientSecret = clientSecret || prev.client_secret;
|
||||
const effectiveScope = scope || prev.scope;
|
||||
|
||||
if (!effectiveClientId || !effectiveClientSecret) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'clientId and clientSecret are required (or must already be stored for this session)',
|
||||
code: 'MISSING_CLIENT_CREDENTIALS'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: effectiveClientId,
|
||||
client_secret: effectiveClientSecret,
|
||||
...(effectiveScope ? { scope: effectiveScope } : {})
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
logger.warn('Challonge client_credentials token exchange failed', {
|
||||
status: response.status,
|
||||
payload
|
||||
});
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge client credentials exchange failed',
|
||||
code: 'CHALLONGE_CLIENT_CREDENTIALS_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
client_credentials: {
|
||||
client_id: effectiveClientId,
|
||||
client_secret: effectiveClientSecret,
|
||||
access_token: payload.access_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
router.post('/challonge/client-credentials/clear', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const record = { ...existing };
|
||||
if (record.client_credentials) delete record.client_credentials;
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Logout client credentials token but retain stored client_id/client_secret
|
||||
router.post('/challonge/client-credentials/logout', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
const creds = existing.client_credentials;
|
||||
if (!creds) {
|
||||
return res.json(redactProviderRecord('challonge', existing));
|
||||
}
|
||||
|
||||
const record = {
|
||||
...existing,
|
||||
client_credentials: {
|
||||
client_id: creds.client_id,
|
||||
client_secret: creds.client_secret
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', record);
|
||||
return res.json(redactProviderRecord('challonge', record));
|
||||
});
|
||||
|
||||
// Refresh stored OAuth tokens (no tokens returned to browser)
|
||||
router.post('/:provider/refresh', async (req, res) => {
|
||||
const { provider } = req.params;
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const record = await tokenStore.getProviderRecord(req.sid, provider);
|
||||
if (!record) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'No stored tokens', code: 'NO_TOKENS' });
|
||||
}
|
||||
|
||||
if (provider === 'discord') {
|
||||
const refreshToken = record.refresh_token;
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({
|
||||
error: 'No refresh token available',
|
||||
code: 'NO_REFRESH_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = process.env.VITE_DISCORD_CLIENT_ID;
|
||||
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
return res.status(503).json({
|
||||
error: 'Discord OAuth not configured',
|
||||
code: 'DISCORD_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Discord refresh failed',
|
||||
code: 'DISCORD_REFRESH_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...record,
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token || record.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'discord', updated);
|
||||
return res.json(redactProviderRecord('discord', updated));
|
||||
}
|
||||
|
||||
if (provider === 'challonge') {
|
||||
const user = record.user_oauth;
|
||||
if (!user?.refresh_token) {
|
||||
return res.status(400).json({
|
||||
error: 'No refresh token available',
|
||||
code: 'NO_REFRESH_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.challonge.configured) {
|
||||
return res.status(503).json({
|
||||
error: 'Challonge OAuth not configured',
|
||||
code: 'CHALLONGE_NOT_CONFIGURED'
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.challonge.com/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.challonge.clientId,
|
||||
client_secret: config.challonge.clientSecret,
|
||||
refresh_token: user.refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Challonge refresh failed',
|
||||
code: 'CHALLONGE_REFRESH_FAILED',
|
||||
details: payload
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRecord = {
|
||||
...record,
|
||||
user_oauth: {
|
||||
...user,
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token || user.refresh_token,
|
||||
token_type: payload.token_type,
|
||||
scope: payload.scope,
|
||||
expires_at: computeExpiresAt(payload.expires_in)
|
||||
}
|
||||
};
|
||||
|
||||
await tokenStore.setProviderRecord(req.sid, 'challonge', updatedRecord);
|
||||
return res.json(redactProviderRecord('challonge', updatedRecord));
|
||||
}
|
||||
|
||||
return res.status(400).json({
|
||||
error: `Unknown provider: ${provider}`,
|
||||
code: 'UNKNOWN_PROVIDER'
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
167
code/websites/pokedex.online/server/routes/session.js
Normal file
167
code/websites/pokedex.online/server/routes/session.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
getCsrfCookieOptions,
|
||||
generateToken
|
||||
} from '../utils/cookie-options.js';
|
||||
|
||||
export function createSessionRouter({ config, tokenStore }) {
|
||||
const router = express.Router();
|
||||
|
||||
async function probeChallonge(url, headers) {
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'GET', headers });
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
let bodyText = '';
|
||||
try {
|
||||
bodyText = await resp.text();
|
||||
} catch {
|
||||
bodyText = '';
|
||||
}
|
||||
|
||||
// Keep response small & safe
|
||||
const snippet = (bodyText || '').slice(0, 500);
|
||||
let json = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
json = bodyText ? JSON.parse(bodyText) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: resp.ok,
|
||||
status: resp.status,
|
||||
contentType,
|
||||
snippet,
|
||||
json
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
contentType: null,
|
||||
snippet: err?.message || 'probe failed',
|
||||
json: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure SID exists (sid middleware should run before this)
|
||||
router.get('/init', async (req, res) => {
|
||||
try {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
await tokenStore.touchSession(req.sid);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
error: err.message || 'Failed to init session',
|
||||
code: 'SESSION_INIT_FAILED'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Issue/refresh CSRF token cookie
|
||||
router.get('/csrf', (req, res) => {
|
||||
const token = generateToken(24);
|
||||
res.cookie(COOKIE_NAMES.csrf, token, getCsrfCookieOptions(config));
|
||||
res.json({ csrfToken: token });
|
||||
});
|
||||
|
||||
// Dev helper: confirm which SID the browser is using and whether provider
|
||||
// credentials are present for that SID. Does not return secrets.
|
||||
router.get('/whoami', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challonge =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
return res.json({
|
||||
sid: req.sid,
|
||||
challonge: {
|
||||
hasApiKey: !!challonge.api_key?.token,
|
||||
hasUserOAuth: !!challonge.user_oauth?.access_token,
|
||||
userOAuthExpiresAt: challonge.user_oauth?.expires_at || null,
|
||||
hasClientCredentials: !!(
|
||||
challonge.client_credentials?.client_id &&
|
||||
challonge.client_credentials?.client_secret
|
||||
),
|
||||
hasClientCredentialsToken: !!challonge.client_credentials?.access_token,
|
||||
clientCredentialsExpiresAt:
|
||||
challonge.client_credentials?.expires_at || null
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dev-only: verify challonge upstream auth for this SID (no secrets returned)
|
||||
router.get('/challonge/verify', async (req, res) => {
|
||||
if (!req.sid) {
|
||||
return res.status(500).json({ error: 'SID middleware not configured' });
|
||||
}
|
||||
|
||||
const challonge =
|
||||
(await tokenStore.getProviderRecord(req.sid, 'challonge')) || {};
|
||||
|
||||
const base =
|
||||
'https://api.challonge.com/v2.1/tournaments.json?page=1&per_page=1&state=pending';
|
||||
const results = {
|
||||
sid: req.sid,
|
||||
endpoints: {
|
||||
userTournamentsSample: base,
|
||||
appTournamentsSample:
|
||||
'https://api.challonge.com/v2.1/application/tournaments.json?page=1&per_page=1&state=pending'
|
||||
},
|
||||
methods: {
|
||||
user_oauth: {
|
||||
present: !!challonge.user_oauth?.access_token,
|
||||
probe: null
|
||||
},
|
||||
api_key: { present: !!challonge.api_key?.token, probe: null },
|
||||
client_credentials: {
|
||||
present: !!challonge.client_credentials?.access_token,
|
||||
probe: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (challonge.user_oauth?.access_token) {
|
||||
results.methods.user_oauth.probe = await probeChallonge(base, {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: `Bearer ${challonge.user_oauth.access_token}`,
|
||||
'authorization-type': 'v2'
|
||||
});
|
||||
}
|
||||
|
||||
if (challonge.api_key?.token) {
|
||||
results.methods.api_key.probe = await probeChallonge(base, {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: challonge.api_key.token,
|
||||
'authorization-type': 'v1'
|
||||
});
|
||||
}
|
||||
|
||||
if (challonge.client_credentials?.access_token) {
|
||||
results.methods.client_credentials.probe = await probeChallonge(
|
||||
results.endpoints.appTournamentsSample,
|
||||
{
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
authorization: `Bearer ${challonge.client_credentials.access_token}`,
|
||||
'authorization-type': 'v2'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return res.json(results);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const STORE_PATH = path.join(__dirname, '..', 'data', 'oauth-tokens.json');
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function getEncryptionKey(sessionSecret) {
|
||||
const raw = process.env.OAUTH_TOKEN_ENC_KEY;
|
||||
if (raw) {
|
||||
// Expect base64 32 bytes. If it's longer, hash it down.
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length === 32) return buf;
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
// Dev fallback: derive from session secret (still better than plaintext)
|
||||
logger.warn(
|
||||
'OAUTH_TOKEN_ENC_KEY not set; deriving key from SESSION_SECRET (dev only).'
|
||||
);
|
||||
return crypto.createHash('sha256').update(sessionSecret).digest();
|
||||
}
|
||||
|
||||
function encryptJson(key, plaintextObj) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const plaintext = Buffer.from(JSON.stringify(plaintextObj), 'utf8');
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
alg: 'aes-256-gcm',
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
function decryptJson(key, envelope) {
|
||||
if (!envelope || envelope.version !== STORE_VERSION) {
|
||||
return { sessions: {}, version: STORE_VERSION };
|
||||
}
|
||||
if (envelope.alg !== 'aes-256-gcm') {
|
||||
throw new Error(`Unsupported store encryption alg: ${envelope.alg}`);
|
||||
}
|
||||
|
||||
const iv = Buffer.from(envelope.iv, 'base64');
|
||||
const tag = Buffer.from(envelope.tag, 'base64');
|
||||
const ciphertext = Buffer.from(envelope.ciphertext, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final()
|
||||
]);
|
||||
return JSON.parse(plaintext.toString('utf8'));
|
||||
}
|
||||
|
||||
async function readStoreFile() {
|
||||
try {
|
||||
const raw = await fs.readFile(STORE_PATH, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeStoreFile(envelope) {
|
||||
await fs.mkdir(path.dirname(STORE_PATH), { recursive: true });
|
||||
const tmp = `${STORE_PATH}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), 'utf8');
|
||||
await fs.rename(tmp, STORE_PATH);
|
||||
} finally {
|
||||
// Best-effort cleanup if something failed before rename.
|
||||
try {
|
||||
await fs.unlink(tmp);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuthTokenStore({ sessionSecret }) {
|
||||
if (!sessionSecret) {
|
||||
throw new Error('createOAuthTokenStore requires sessionSecret');
|
||||
}
|
||||
|
||||
const key = getEncryptionKey(sessionSecret);
|
||||
|
||||
let cache = null;
|
||||
let cacheLoadedAt = 0;
|
||||
|
||||
// Serialize writes to avoid races under concurrent requests.
|
||||
let writeChain = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
if (cache) return cache;
|
||||
|
||||
const envelope = await readStoreFile();
|
||||
if (!envelope) {
|
||||
cache = { version: STORE_VERSION, sessions: {} };
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
}
|
||||
|
||||
try {
|
||||
cache = decryptJson(key, envelope);
|
||||
if (!cache.sessions) cache.sessions = {};
|
||||
cache.version = STORE_VERSION;
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
} catch (err) {
|
||||
logger.error('Failed to decrypt oauth token store; starting fresh', {
|
||||
error: err.message
|
||||
});
|
||||
cache = { version: STORE_VERSION, sessions: {} };
|
||||
cacheLoadedAt = now();
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(state) {
|
||||
const envelope = encryptJson(key, state);
|
||||
const run = async () => {
|
||||
await writeStoreFile(envelope);
|
||||
};
|
||||
|
||||
// Keep the chain alive even if a prior write failed.
|
||||
writeChain = writeChain.then(run, run);
|
||||
await writeChain;
|
||||
}
|
||||
|
||||
function ensureSession(state, sid) {
|
||||
const existing = state.sessions[sid];
|
||||
const ts = now();
|
||||
if (existing) {
|
||||
existing.lastSeenAt = ts;
|
||||
existing.expiresAt = Math.min(
|
||||
existing.createdAt + SEVEN_DAYS_MS,
|
||||
ts + ONE_DAY_MS
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const createdAt = ts;
|
||||
const session = {
|
||||
createdAt,
|
||||
lastSeenAt: ts,
|
||||
expiresAt: Math.min(createdAt + SEVEN_DAYS_MS, ts + ONE_DAY_MS),
|
||||
providers: {}
|
||||
};
|
||||
|
||||
state.sessions[sid] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function sweep(state) {
|
||||
const ts = now();
|
||||
let removed = 0;
|
||||
for (const [sid, session] of Object.entries(state.sessions)) {
|
||||
if (!session?.expiresAt || session.expiresAt <= ts) {
|
||||
delete state.sessions[sid];
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
logger.info('Swept expired OAuth sessions', { removed });
|
||||
}
|
||||
}
|
||||
|
||||
async function touchSession(sid) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
ensureSession(state, sid);
|
||||
await persist(state);
|
||||
}
|
||||
|
||||
async function getProviderRecord(sid, provider) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
await persist(state);
|
||||
return session.providers?.[provider] || null;
|
||||
}
|
||||
|
||||
async function setProviderRecord(sid, provider, record) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
session.providers[provider] = {
|
||||
...record,
|
||||
updatedAt: now()
|
||||
};
|
||||
await persist(state);
|
||||
}
|
||||
|
||||
async function deleteProviderRecord(sid, provider) {
|
||||
const state = await load();
|
||||
sweep(state);
|
||||
const session = ensureSession(state, sid);
|
||||
if (session.providers?.[provider]) {
|
||||
delete session.providers[provider];
|
||||
await persist(state);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
touchSession,
|
||||
getProviderRecord,
|
||||
setProviderRecord,
|
||||
deleteProviderRecord
|
||||
};
|
||||
}
|
||||
79
code/websites/pokedex.online/server/utils/cookie-options.js
Normal file
79
code/websites/pokedex.online/server/utils/cookie-options.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ONE_DAY_SECONDS = 60 * 60 * 24;
|
||||
const SEVEN_DAYS_SECONDS = ONE_DAY_SECONDS * 7;
|
||||
|
||||
export const COOKIE_NAMES = {
|
||||
sid: 'pdx_sid',
|
||||
csrf: 'pdx_csrf'
|
||||
};
|
||||
|
||||
export function getCookieSecurityConfig(config) {
|
||||
const deploymentTarget =
|
||||
config?.deploymentTarget || process.env.DEPLOYMENT_TARGET;
|
||||
const nodeEnv = config?.nodeEnv || process.env.NODE_ENV;
|
||||
|
||||
const isProdTarget =
|
||||
deploymentTarget === 'production' || nodeEnv === 'production';
|
||||
|
||||
return {
|
||||
secure: isProdTarget,
|
||||
sameSite: 'lax'
|
||||
};
|
||||
}
|
||||
|
||||
export function getSidCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: SEVEN_DAYS_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy cookie options used before widening cookie scope to '/'.
|
||||
// Clearing these prevents browsers from sending multiple cookies with the same
|
||||
// name but different paths (e.g. '/api' and '/'), which can cause session
|
||||
// split-brain.
|
||||
export function getLegacySidCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/api',
|
||||
maxAge: SEVEN_DAYS_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function getCsrfCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/',
|
||||
maxAge: ONE_DAY_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function getLegacyCsrfCookieOptions(config) {
|
||||
const { secure, sameSite } = getCookieSecurityConfig(config);
|
||||
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite,
|
||||
path: '/api',
|
||||
maxAge: ONE_DAY_SECONDS * 1000
|
||||
};
|
||||
}
|
||||
|
||||
export function generateToken(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
268
code/websites/pokedex.online/server/utils/env-validator.js
Normal file
268
code/websites/pokedex.online/server/utils/env-validator.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Environment Variable Validation
|
||||
*
|
||||
* Validates required environment variables at startup and provides
|
||||
* helpful error messages for production deployments.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Required environment variables for production
|
||||
*/
|
||||
const REQUIRED_ENV_VARS = {
|
||||
// Deployment Configuration
|
||||
DEPLOYMENT_TARGET: {
|
||||
required: true,
|
||||
description: 'Deployment environment (dev, docker-local, production)',
|
||||
validate: val => ['dev', 'docker-local', 'production'].includes(val)
|
||||
},
|
||||
|
||||
// Server Configuration
|
||||
NODE_ENV: {
|
||||
required: true,
|
||||
description: 'Environment mode (development, production)',
|
||||
validate: val => ['development', 'production', 'test'].includes(val)
|
||||
},
|
||||
PORT: {
|
||||
required: true,
|
||||
description: 'Server port number',
|
||||
validate: val =>
|
||||
!isNaN(parseInt(val)) && parseInt(val) > 0 && parseInt(val) < 65536
|
||||
},
|
||||
|
||||
// Frontend URL for CORS
|
||||
FRONTEND_URL: {
|
||||
required: true,
|
||||
description: 'Frontend URL for CORS',
|
||||
validate: (val, env) => {
|
||||
if (!val) return false;
|
||||
|
||||
// Validate that FRONTEND_URL matches DEPLOYMENT_TARGET (if set)
|
||||
const target = env?.DEPLOYMENT_TARGET;
|
||||
if (!target) return true; // Skip validation if target not set yet
|
||||
|
||||
if (target === 'dev' && !val.includes('localhost:5173')) {
|
||||
console.error(
|
||||
'⚠️ FRONTEND_URL should be http://localhost:5173 for dev target'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (target === 'docker-local' && !val.includes('localhost:8099')) {
|
||||
console.error(
|
||||
'⚠️ FRONTEND_URL should be http://localhost:8099 for docker-local target'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (target === 'production' && !val.includes('app.pokedex.online')) {
|
||||
console.error(
|
||||
'⚠️ FRONTEND_URL should be https://app.pokedex.online for production target'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
// Optional but recommended for production
|
||||
SESSION_SECRET: {
|
||||
required: false,
|
||||
description: 'Secret key for session encryption',
|
||||
warn: val =>
|
||||
!val || val.length < 32
|
||||
? 'SESSION_SECRET should be at least 32 characters for security'
|
||||
: null
|
||||
},
|
||||
|
||||
// Token encryption key (required for server-side OAuth token storage in production)
|
||||
OAUTH_TOKEN_ENC_KEY: {
|
||||
required: false,
|
||||
description:
|
||||
'Base64-encoded 32-byte key for encrypting OAuth tokens at rest (AES-256-GCM)',
|
||||
validate: (val, env) => {
|
||||
const target = env?.DEPLOYMENT_TARGET;
|
||||
if (target !== 'production') return true;
|
||||
if (!val) {
|
||||
console.error(
|
||||
'❌ OAUTH_TOKEN_ENC_KEY is required in production to encrypt OAuth tokens'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Best-effort validation: base64 decode should yield 32 bytes
|
||||
try {
|
||||
const buf = Buffer.from(val, 'base64');
|
||||
return buf.length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Admin auth
|
||||
ADMIN_PASSWORD: {
|
||||
required: false,
|
||||
description: 'Admin password for /auth/login (recommended for production)'
|
||||
},
|
||||
|
||||
// Challonge OAuth (optional)
|
||||
CHALLONGE_CLIENT_ID: {
|
||||
required: false,
|
||||
description: 'Challonge OAuth client ID'
|
||||
},
|
||||
CHALLONGE_CLIENT_SECRET: {
|
||||
required: false,
|
||||
description: 'Challonge OAuth client secret'
|
||||
},
|
||||
CHALLONGE_REDIRECT_URI: {
|
||||
required: false,
|
||||
description: 'OAuth redirect URI'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate environment variables
|
||||
* @returns {Object} Validation result with errors and warnings
|
||||
*/
|
||||
export function validateEnvironment() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const missing = [];
|
||||
|
||||
// Check required variables
|
||||
for (const [key, config] of Object.entries(REQUIRED_ENV_VARS)) {
|
||||
const value = process.env[key];
|
||||
|
||||
// Check if required variable is missing
|
||||
if (config.required && !value) {
|
||||
errors.push(
|
||||
`Missing required environment variable: ${key} - ${config.description}`
|
||||
);
|
||||
missing.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate value if present
|
||||
if (value && config.validate && !config.validate(value)) {
|
||||
errors.push(
|
||||
`Invalid value for ${key}: "${value}" - ${config.description}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for warnings
|
||||
if (config.warn) {
|
||||
const warning = config.warn(value, process.env);
|
||||
if (warning) {
|
||||
warnings.push(`${key}: ${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate environment and exit if critical errors found
|
||||
* @param {boolean} exitOnError - Whether to exit process on validation errors (default: true)
|
||||
*/
|
||||
export function validateOrExit(exitOnError = true) {
|
||||
const result = validateEnvironment();
|
||||
|
||||
// Print validation results
|
||||
console.log('\n🔍 Environment Validation:');
|
||||
console.log(
|
||||
` DEPLOYMENT_TARGET: ${process.env.DEPLOYMENT_TARGET || 'not set'}`
|
||||
);
|
||||
console.log(` NODE_ENV: ${process.env.NODE_ENV || 'not set'}`);
|
||||
console.log(` PORT: ${process.env.PORT || 'not set'}`);
|
||||
console.log(` FRONTEND_URL: ${process.env.FRONTEND_URL || 'not set'}`);
|
||||
|
||||
// Show errors
|
||||
if (result.errors.length > 0) {
|
||||
console.error('\n❌ Environment Validation Errors:');
|
||||
result.errors.forEach(error => console.error(` - ${error}`));
|
||||
|
||||
if (result.missing.length > 0) {
|
||||
console.error('\n💡 Tip: Create a .env file with these variables:');
|
||||
result.missing.forEach(key => {
|
||||
console.error(` ${key}=your_value_here`);
|
||||
});
|
||||
console.error('\n See .env.example for reference');
|
||||
}
|
||||
|
||||
if (exitOnError) {
|
||||
console.error('\n❌ Server cannot start due to environment errors\n');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(' ✅ All required variables present');
|
||||
}
|
||||
|
||||
// Show warnings
|
||||
if (result.warnings.length > 0) {
|
||||
console.warn('\n⚠️ Environment Warnings:');
|
||||
result.warnings.forEach(warning => console.warn(` - ${warning}`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration object with validated environment variables
|
||||
* @returns {Object} Configuration object
|
||||
*/
|
||||
export function getConfig() {
|
||||
const deploymentTarget = process.env.DEPLOYMENT_TARGET || 'dev';
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
|
||||
return {
|
||||
deploymentTarget,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3001'),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
|
||||
// Challonge OAuth
|
||||
challonge: {
|
||||
clientId: process.env.CHALLONGE_CLIENT_ID,
|
||||
clientSecret: process.env.CHALLONGE_CLIENT_SECRET,
|
||||
redirectUri: process.env.CHALLONGE_REDIRECT_URI,
|
||||
configured: !!(
|
||||
process.env.CHALLONGE_CLIENT_ID && process.env.CHALLONGE_CLIENT_SECRET
|
||||
)
|
||||
},
|
||||
|
||||
// CORS - Single origin based on deployment target
|
||||
cors: {
|
||||
origin: frontendUrl,
|
||||
credentials: true
|
||||
},
|
||||
|
||||
// Security
|
||||
session: {
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production'
|
||||
},
|
||||
|
||||
// Admin auth (JWT secret uses session secret for now)
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
adminPassword: process.env.ADMIN_PASSWORD,
|
||||
|
||||
// Discord User Permissions
|
||||
discord: {
|
||||
adminUsers: process.env.DISCORD_ADMIN_USERS
|
||||
? process.env.DISCORD_ADMIN_USERS.split(',').map(u =>
|
||||
u.trim().toLowerCase()
|
||||
)
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Run validation when executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
validateOrExit();
|
||||
}
|
||||
136
code/websites/pokedex.online/server/utils/graceful-shutdown.js
Normal file
136
code/websites/pokedex.online/server/utils/graceful-shutdown.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Graceful Shutdown Handler
|
||||
*
|
||||
* Handles server shutdown gracefully by:
|
||||
* - Closing server to stop accepting new connections
|
||||
* - Waiting for existing connections to finish
|
||||
* - Cleaning up resources
|
||||
* - Exiting with appropriate code
|
||||
*/
|
||||
|
||||
import logger from './logger.js';
|
||||
|
||||
/**
|
||||
* Setup graceful shutdown handlers for Express server
|
||||
* @param {Object} server - HTTP server instance
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
export function setupGracefulShutdown(server, options = {}) {
|
||||
const {
|
||||
timeout = 30000, // 30 seconds
|
||||
onShutdown = null // Optional cleanup function
|
||||
} = options;
|
||||
|
||||
let isShuttingDown = false;
|
||||
const connections = new Set();
|
||||
|
||||
// Track all connections
|
||||
server.on('connection', connection => {
|
||||
connections.add(connection);
|
||||
connection.on('close', () => {
|
||||
connections.delete(connection);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Perform graceful shutdown
|
||||
* @param {string} signal - Signal that triggered shutdown
|
||||
*/
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
logger.warn('Shutdown already in progress, forcing exit');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
logger.info(`Received ${signal}, starting graceful shutdown...`);
|
||||
|
||||
// Stop accepting new connections
|
||||
server.close(() => {
|
||||
logger.info('Server closed, all requests completed');
|
||||
});
|
||||
|
||||
// Set shutdown timeout
|
||||
const shutdownTimeout = setTimeout(() => {
|
||||
logger.error(`Shutdown timeout (${timeout}ms), forcing exit`);
|
||||
connections.forEach(conn => conn.destroy());
|
||||
process.exit(1);
|
||||
}, timeout);
|
||||
|
||||
try {
|
||||
// Run custom cleanup if provided
|
||||
if (onShutdown) {
|
||||
logger.info('Running custom shutdown cleanup...');
|
||||
await onShutdown();
|
||||
}
|
||||
|
||||
// Wait for all connections to close
|
||||
logger.info(`Waiting for ${connections.size} connections to close...`);
|
||||
|
||||
// Close all idle connections
|
||||
connections.forEach(conn => {
|
||||
if (!conn.destroyed) {
|
||||
conn.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Give connections time to finish
|
||||
await new Promise(resolve => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (connections.size === 0) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
clearTimeout(shutdownTimeout);
|
||||
logger.info('Graceful shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown', { error: error.message });
|
||||
clearTimeout(shutdownTimeout);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Register shutdown handlers for various signals
|
||||
const signals = ['SIGTERM', 'SIGINT', 'SIGUSR2'];
|
||||
signals.forEach(signal => {
|
||||
process.on(signal, () => shutdown(signal));
|
||||
});
|
||||
|
||||
logger.info('Graceful shutdown handlers registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check middleware that returns 503 during shutdown
|
||||
*/
|
||||
export function createHealthCheckMiddleware() {
|
||||
let isHealthy = true;
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
isHealthy = false;
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
isHealthy = false;
|
||||
});
|
||||
|
||||
return (req, res) => {
|
||||
if (!isHealthy) {
|
||||
return res.status(503).json({
|
||||
status: 'shutting_down',
|
||||
message: 'Server is shutting down'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
memory: process.memoryUsage(),
|
||||
environment: process.env.NODE_ENV
|
||||
});
|
||||
};
|
||||
}
|
||||
136
code/websites/pokedex.online/server/utils/jwt-utils.js
Normal file
136
code/websites/pokedex.online/server/utils/jwt-utils.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* JWT Authentication Utilities
|
||||
*
|
||||
* Provides functions for creating, validating, and decoding JWT tokens
|
||||
* Used by both backend OAuth proxy and frontend storage/validation
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Try to use native crypto, fallback to import
|
||||
let jwt;
|
||||
try {
|
||||
jwt = await import('jsonwebtoken');
|
||||
} catch (err) {
|
||||
console.warn('jsonwebtoken not installed, using mock implementation');
|
||||
jwt = createMockJWT();
|
||||
}
|
||||
|
||||
function createMockJWT() {
|
||||
return {
|
||||
sign: (payload, secret, options) => {
|
||||
// Mock JWT: base64(header).base64(payload).base64(signature)
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: 'HS256', typ: 'JWT' })
|
||||
).toString('base64');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
const signature = Buffer.from('mock-signature').toString('base64');
|
||||
return `${header}.${body}.${signature}`;
|
||||
},
|
||||
verify: (token, secret) => {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) throw new Error('Invalid token format');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid token: ${err.message}`);
|
||||
}
|
||||
},
|
||||
decode: token => {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT token for admin access
|
||||
* @param {Object} payload - Token payload (user data, permissions, etc.)
|
||||
* @param {string} secret - Secret key for signing
|
||||
* @param {number} expiresIn - Expiration time in seconds (default: 7 days)
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
export function createToken(payload, secret, expiresIn = 7 * 24 * 60 * 60) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresIn
|
||||
},
|
||||
secret,
|
||||
{ algorithm: 'HS256' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @param {string} secret - Secret key for verification
|
||||
* @returns {Object} Decoded token payload
|
||||
* @throws {Error} If token is invalid or expired
|
||||
*/
|
||||
export function verifyToken(token, secret) {
|
||||
return jwt.verify(token, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT token without verification (use with caution)
|
||||
* @param {string} token - JWT token to decode
|
||||
* @returns {Object|null} Decoded token payload or null if invalid
|
||||
*/
|
||||
export function decodeToken(token) {
|
||||
return jwt.decode(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired
|
||||
* @param {string} token - JWT token
|
||||
* @returns {boolean} True if token is expired
|
||||
*/
|
||||
export function isTokenExpired(token) {
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
if (!decoded || !decoded.exp) return true;
|
||||
return Date.now() >= decoded.exp * 1000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time before token expiration
|
||||
* @param {string} token - JWT token
|
||||
* @returns {number} Milliseconds until expiration, or 0 if expired
|
||||
*/
|
||||
export function getTokenExpiresIn(token) {
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
if (!decoded || !decoded.exp) return 0;
|
||||
const remaining = decoded.exp * 1000 - Date.now();
|
||||
return Math.max(0, remaining);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random secret for testing (NOT for production)
|
||||
* @returns {string} Random secret
|
||||
*/
|
||||
export function generateSecret() {
|
||||
return Buffer.from(Math.random().toString()).toString('base64');
|
||||
}
|
||||
150
code/websites/pokedex.online/server/utils/logger.js
Normal file
150
code/websites/pokedex.online/server/utils/logger.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Production Logger with Winston
|
||||
*
|
||||
* Provides structured logging for development and production environments.
|
||||
* - Console logging for development with colors
|
||||
* - File logging for production with rotation
|
||||
* - JSON format for production log aggregation
|
||||
*/
|
||||
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const logLevel = process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug');
|
||||
|
||||
/**
|
||||
* Custom format for development console output
|
||||
*/
|
||||
const devFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let msg = `${timestamp} [${level}] ${message}`;
|
||||
if (Object.keys(meta).length > 0) {
|
||||
msg += ` ${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Format for production (JSON for log aggregation)
|
||||
*/
|
||||
const prodFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
/**
|
||||
* Create Winston logger instance
|
||||
*/
|
||||
const logger = winston.createLogger({
|
||||
level: logLevel,
|
||||
format: isProduction ? prodFormat : devFormat,
|
||||
defaultMeta: {
|
||||
service: 'pokedex-backend',
|
||||
environment: process.env.NODE_ENV
|
||||
},
|
||||
transports: []
|
||||
});
|
||||
|
||||
// Console transport (always enabled)
|
||||
logger.add(
|
||||
new winston.transports.Console({
|
||||
format: isProduction ? prodFormat : devFormat
|
||||
})
|
||||
);
|
||||
|
||||
// File transports for production
|
||||
if (isProduction) {
|
||||
// All logs
|
||||
logger.add(
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../logs/combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
);
|
||||
|
||||
// Error logs
|
||||
logger.add(
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../logs/error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware for HTTP request logging
|
||||
*/
|
||||
export function requestLogger(req, res, next) {
|
||||
const start = Date.now();
|
||||
|
||||
// Log request
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
|
||||
// Log response when finished
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const logLevel = res.statusCode >= 400 ? 'warn' : 'info';
|
||||
|
||||
logger[logLevel]('HTTP Response', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Express error logging middleware
|
||||
*/
|
||||
export function errorLogger(err, req, res, next) {
|
||||
logger.error('Express Error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
body: req.body
|
||||
});
|
||||
|
||||
next(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log uncaught exceptions and unhandled rejections
|
||||
*/
|
||||
process.on('uncaughtException', error => {
|
||||
logger.error('Uncaught Exception', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// Give logger time to write before exiting
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection', {
|
||||
reason: reason instanceof Error ? reason.message : reason,
|
||||
stack: reason instanceof Error ? reason.stack : undefined
|
||||
});
|
||||
});
|
||||
|
||||
export default logger;
|
||||
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<Transition name="view-transition" mode="out-in">
|
||||
<router-view />
|
||||
</Transition>
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition name="view-transition" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
<!-- Developer Tools Panel (development + authenticated production) -->
|
||||
<DeveloperTools />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DeveloperTools from './components/DeveloperTools.vue';
|
||||
|
||||
// App now acts as the router container with transitions
|
||||
</script>
|
||||
|
||||
|
||||
484
code/websites/pokedex.online/src/components/DeveloperTools.vue
Normal file
484
code/websites/pokedex.online/src/components/DeveloperTools.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<!-- Developer Tools Panel -->
|
||||
<Teleport to="body">
|
||||
<transition name="slide-up">
|
||||
<div v-if="isOpen" class="developer-tools">
|
||||
<div class="dev-header">
|
||||
<h3>🛠️ Developer Tools</h3>
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dev-content">
|
||||
<!-- Feature Flags Section -->
|
||||
<div class="section">
|
||||
<h4>Feature Flags</h4>
|
||||
<div class="flags-list">
|
||||
<div v-for="flag in flags" :key="flag.name" class="flag-item">
|
||||
<div class="flag-header">
|
||||
<label class="flag-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="flag.isEnabled"
|
||||
:disabled="!flag.hasPermission"
|
||||
@change="toggleFlag(flag.name)"
|
||||
/>
|
||||
<code>{{ flag.name }}</code>
|
||||
<span v-if="flag.hasOverride" class="override-badge"
|
||||
>override</span
|
||||
>
|
||||
<span
|
||||
v-if="flag.requiresPermission && !flag.hasPermission"
|
||||
class="locked-badge"
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="flag-description">{{ flag.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button @click="resetAll" class="btn btn-secondary">
|
||||
Reset All Overrides
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Info Section -->
|
||||
<div class="section">
|
||||
<h4>Authentication</h4>
|
||||
<div class="info-grid">
|
||||
<div v-if="user" class="info-item">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value">✅ Authenticated</span>
|
||||
</div>
|
||||
<div v-else class="info-item">
|
||||
<span class="label">Status:</span>
|
||||
<span class="value">❌ Not Authenticated</span>
|
||||
</div>
|
||||
|
||||
<div v-if="user" class="info-item">
|
||||
<span class="label">Role:</span>
|
||||
<span class="value">{{
|
||||
user.isAdmin ? '👑 Admin' : '👤 User'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="user?.permissions" class="info-item full-width">
|
||||
<span class="label">Permissions:</span>
|
||||
<div class="tags">
|
||||
<span
|
||||
v-for="perm in user.permissions"
|
||||
:key="perm"
|
||||
class="tag"
|
||||
>
|
||||
{{ perm }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="token" class="info-item full-width">
|
||||
<span class="label">Token (truncated):</span>
|
||||
<code class="token"
|
||||
>{{ token.substring(0, 20) }}...{{
|
||||
token.substring(token.length - 10)
|
||||
}}</code
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Info Section -->
|
||||
<div class="section">
|
||||
<h4>Environment</h4>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Mode:</span>
|
||||
<span class="value">{{ nodeEnv }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">App Version:</span>
|
||||
<span class="value">{{ appVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
v-if="isAvailable"
|
||||
class="dev-trigger"
|
||||
title="Toggle Developer Tools (Ctrl+Shift+D)"
|
||||
@click="toggle"
|
||||
>
|
||||
🛠️
|
||||
</button>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useDiscordOAuth } from '../composables/useDiscordOAuth.js';
|
||||
import { useFeatureFlags } from '../composables/useFeatureFlags.js';
|
||||
|
||||
const { user } = useAuth();
|
||||
const discord = useDiscordOAuth();
|
||||
const {
|
||||
getFlags,
|
||||
toggle: toggleFlagOverride,
|
||||
resetAll: resetAllOverrides
|
||||
} = useFeatureFlags();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
// Show only for:
|
||||
// 1. Development mode
|
||||
// 2. JWT authenticated users with developer_tools.view permission
|
||||
// 3. Discord authenticated users with developer_tools.view permission
|
||||
const isAvailable = computed(() => {
|
||||
// Vite-native dev detection (reliable in the browser).
|
||||
// In production builds, this is always false.
|
||||
const isDev = import.meta.env.DEV === true;
|
||||
|
||||
// Check JWT auth permissions
|
||||
const hasJwtPermission = user.value?.permissions?.includes(
|
||||
'developer_tools.view'
|
||||
);
|
||||
|
||||
// Check Discord OAuth permissions
|
||||
const hasDiscordPermission = discord.hasDevAccess();
|
||||
|
||||
const hasPermission = hasJwtPermission || hasDiscordPermission;
|
||||
|
||||
return isDev || hasPermission;
|
||||
});
|
||||
|
||||
const nodeEnv = computed(() => import.meta.env.MODE || 'unknown');
|
||||
const appVersion = computed(
|
||||
() => import.meta.env.VITE_APP_VERSION || '1.0.0-dev'
|
||||
);
|
||||
|
||||
const flags = computed(() => getFlags.value());
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const toggleFlag = flagName => {
|
||||
toggleFlagOverride(flagName);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
if (confirm('Reset all feature flag overrides?')) {
|
||||
resetAllOverrides();
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+D (only works if user has access)
|
||||
const handleKeyDown = e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyD') {
|
||||
e.preventDefault();
|
||||
if (isAvailable.value) {
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.developer-tools {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 20px;
|
||||
width: 500px;
|
||||
max-height: 70vh;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #00ff00;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
z-index: 9998;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff00;
|
||||
box-shadow: 0 8px 32px rgba(0, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
.dev-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #0a0a0a;
|
||||
border-bottom: 2px solid #00ff00;
|
||||
}
|
||||
|
||||
.dev-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #00ff00;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.dev-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #00ff00;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #00ff00;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Feature Flags */
|
||||
.flags-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.flag-item {
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
|
||||
.flag-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flag-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flag-label input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.flag-label input[type='checkbox']:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.flag-label code {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.override-badge,
|
||||
.locked-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 165, 0, 0.3);
|
||||
color: #ffaa00;
|
||||
border: 1px solid #ffaa00;
|
||||
}
|
||||
|
||||
.locked-badge {
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.flag-description {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
border: 1px solid #333;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 255, 0, 0.02);
|
||||
}
|
||||
|
||||
.info-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
display: block;
|
||||
color: #00ff00;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(0, 255, 0, 0.2);
|
||||
border: 1px solid #00ff00;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.token {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #00ff00;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: #00ff00;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(0, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-color: #888;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(136, 136, 136, 0.2);
|
||||
}
|
||||
|
||||
/* Trigger Button */
|
||||
.dev-trigger {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #00ff00;
|
||||
border: 2px solid #00aa00;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 0, 0.3);
|
||||
}
|
||||
|
||||
.dev-trigger:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
|
||||
.dev-trigger:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.developer-tools {
|
||||
width: calc(100vw - 40px);
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
code/websites/pokedex.online/src/components/FeatureFlag.vue
Normal file
45
code/websites/pokedex.online/src/components/FeatureFlag.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
FeatureFlag Component
|
||||
|
||||
Conditionally renders content based on feature flag state.
|
||||
|
||||
Usage:
|
||||
<FeatureFlag flag="experimental-search">
|
||||
<SearchComponent />
|
||||
</FeatureFlag>
|
||||
|
||||
With fallback:
|
||||
<FeatureFlag flag="dark-mode">
|
||||
<DarkModeUI />
|
||||
<template #fallback>
|
||||
<LightModeUI />
|
||||
</template>
|
||||
</FeatureFlag>
|
||||
-->
|
||||
<template>
|
||||
<template v-if="isEnabled">
|
||||
<slot></slot>
|
||||
</template>
|
||||
<template v-else-if="$slots.fallback">
|
||||
<slot name="fallback"></slot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useFeatureFlags } from '../composables/useFeatureFlags.js';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Feature flag name to check
|
||||
*/
|
||||
flag: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const { isEnabled: checkFlag } = useFeatureFlags();
|
||||
|
||||
const isEnabled = computed(() => checkFlag.value(props.flag));
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="api-version-selector">
|
||||
<!-- API Version Radio Buttons -->
|
||||
<div class="control-group">
|
||||
<label class="control-label">API Version:</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="modelValue === 'v1'"
|
||||
@change="$emit('update:modelValue', 'v1')"
|
||||
/>
|
||||
<span>v1 (Legacy)</span>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="modelValue === 'v2.1'"
|
||||
@change="$emit('update:modelValue', 'v2.1')"
|
||||
/>
|
||||
<span>v2.1 (Current)</span>
|
||||
</label>
|
||||
</div>
|
||||
<span
|
||||
class="version-badge"
|
||||
:class="'badge-' + modelValue.replace('.', '-')"
|
||||
>
|
||||
Using API {{ modelValue }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Results Per Page (v2.1 only) -->
|
||||
<div v-if="modelValue === 'v2.1' && showPerPage" class="control-group">
|
||||
<label class="control-label">Results per page:</label>
|
||||
<select
|
||||
:value="perPage"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
class="select-input"
|
||||
>
|
||||
<option :value="10">10</option>
|
||||
<option :value="25">25</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tournament Scope (v2.1 only) -->
|
||||
<div v-if="modelValue === 'v2.1' && showScope" class="control-group">
|
||||
<label class="control-label">Tournament scope:</label>
|
||||
<select
|
||||
:value="scope"
|
||||
@change="$emit('update:scope', $event.target.value)"
|
||||
class="select-input"
|
||||
>
|
||||
<option value="user">User (default)</option>
|
||||
<option value="app">
|
||||
Application (requires application:manage token)
|
||||
</option>
|
||||
</select>
|
||||
<div class="info-badge" v-if="scope === 'user'">
|
||||
✓ USER scope - tournaments you created or administer
|
||||
</div>
|
||||
<div class="info-badge warning" v-else>
|
||||
ⓘ APPLICATION scope requires a client-credentials token with
|
||||
application:manage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'v2.1',
|
||||
validator: value => ['v1', 'v2.1'].includes(value)
|
||||
},
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'user'
|
||||
},
|
||||
showPerPage: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showScope: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue', 'update:perPage', 'update:scope']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-version-selector {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.radio-option:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.radio-option input[type='radio'] {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.radio-option span {
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.badge-v1 {
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.badge-v2-1 {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.select-input:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.info-badge.warning {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
border-left: 3px solid #ffc107;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.api-version-selector {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!--
|
||||
TournamentDetail Component
|
||||
|
||||
Displays detailed tournament information in an expandable section.
|
||||
|
||||
Features:
|
||||
- Expandable/collapsible details view
|
||||
- JSON display of full tournament data
|
||||
- Loading state for detail fetching
|
||||
- Error handling
|
||||
- Formatted JSON output
|
||||
|
||||
Props:
|
||||
- tournamentDetails: Object containing full tournament data
|
||||
- loading: Boolean for loading state
|
||||
- error: String for error message
|
||||
- isExpanded: Boolean to control visibility
|
||||
|
||||
Usage:
|
||||
<TournamentDetail
|
||||
:tournament-details="details"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:is-expanded="true"
|
||||
/>
|
||||
-->
|
||||
<template>
|
||||
<div v-if="isExpanded" class="tournament-detail">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="detail-status loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading tournament details...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="detail-status error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Details Content -->
|
||||
<div v-else-if="tournamentDetails" class="detail-content">
|
||||
<div class="detail-header">
|
||||
<h4>Full Tournament Details</h4>
|
||||
</div>
|
||||
<pre class="detail-json">{{ formattedDetails }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (details expanded but no data) -->
|
||||
<div v-else class="detail-status empty">
|
||||
<span>No details available</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* Props for TournamentDetail component
|
||||
*/
|
||||
const props = defineProps({
|
||||
tournamentDetails: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: [String, Error],
|
||||
default: null
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Format error message from string or Error object
|
||||
*/
|
||||
const errorMessage = computed(() => {
|
||||
if (!props.error) return '';
|
||||
if (typeof props.error === 'string') return props.error;
|
||||
|
||||
// Try to extract useful error info
|
||||
const err = props.error;
|
||||
if (err.message) {
|
||||
// Check if it's a network error
|
||||
if (err.message.includes('Load failed') || err.message.includes('fetch')) {
|
||||
return 'Failed to load tournament details. Check your network connection or API credentials.';
|
||||
}
|
||||
return err.message;
|
||||
}
|
||||
|
||||
// Check for response errors
|
||||
if (err.response) {
|
||||
const status = err.response.status || err.status;
|
||||
if (status === 401) return 'Authentication failed. Check your API key.';
|
||||
if (status === 403)
|
||||
return 'Access forbidden. You may not have permission to view this tournament.';
|
||||
if (status === 404) return 'Tournament not found.';
|
||||
if (status >= 500)
|
||||
return `Server error (${status}). Please try again later.`;
|
||||
return `Error ${status}: ${err.response.statusText || 'Request failed'}`;
|
||||
}
|
||||
|
||||
// Fallback - try multiple approaches
|
||||
|
||||
// Try to get status code directly
|
||||
if (err.status) {
|
||||
return `Error ${err.status}: Request failed`;
|
||||
}
|
||||
|
||||
// Try to stringify for debugging
|
||||
try {
|
||||
const errorStr = JSON.stringify(err);
|
||||
if (errorStr && errorStr !== '{}') {
|
||||
console.error('Tournament detail error:', err);
|
||||
return 'An error occurred. Check browser console for details.';
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore stringify errors
|
||||
}
|
||||
|
||||
// Use toString if available and not [object Object]
|
||||
const str = err.toString ? err.toString() : String(err);
|
||||
if (str && str !== '[object Object]') {
|
||||
return str;
|
||||
}
|
||||
|
||||
console.error('Unable to parse error:', err);
|
||||
return 'An error occurred loading tournament details. Check console for details.';
|
||||
});
|
||||
|
||||
/**
|
||||
* Format tournament details as pretty-printed JSON
|
||||
*/
|
||||
const formattedDetails = computed(() => {
|
||||
if (!props.tournamentDetails) return '';
|
||||
return JSON.stringify(props.tournamentDetails, null, 2);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tournament-detail {
|
||||
margin-top: 1rem;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
padding-top: 1rem;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.detail-status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.detail-status.empty {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #1976d2;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #282c34;
|
||||
color: #abb2bf;
|
||||
font-family: 'Fira Code', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* JSON Syntax Highlighting (approximate) */
|
||||
.detail-json {
|
||||
/* Numbers */
|
||||
background: linear-gradient(to bottom, #282c34 0%, #282c34 100%);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for detail-json */
|
||||
.detail-json::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.detail-json::-webkit-scrollbar-track {
|
||||
background: #1e2127;
|
||||
}
|
||||
|
||||
.detail-json::-webkit-scrollbar-thumb {
|
||||
background: #4b5362;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-json::-webkit-scrollbar-thumb:hover {
|
||||
background: #5c6370;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.detail-json {
|
||||
font-size: 0.75rem;
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<!--
|
||||
TournamentGrid Component
|
||||
|
||||
Displays a grid of tournaments with search, filtering, and pagination.
|
||||
|
||||
Features:
|
||||
- Tournament card display with state badges
|
||||
- Client-side search filtering
|
||||
- Pagination support (v2.1 only)
|
||||
- Click-to-expand details
|
||||
- Empty state messaging
|
||||
|
||||
Props:
|
||||
- tournaments: Array of tournament objects
|
||||
- loading: Boolean for loading state
|
||||
- loadingMore: Boolean for pagination loading
|
||||
- searchQuery: String for search input (v-model)
|
||||
- filteredTournaments: Computed array of filtered results
|
||||
- expandedTournamentId: ID of currently expanded tournament
|
||||
- hasNextPage: Boolean for pagination availability (v2.1)
|
||||
- apiVersion: String ('v1' or 'v2.1')
|
||||
|
||||
Events:
|
||||
- update:searchQuery: Emitted when search input changes
|
||||
- load-more: Emitted when "Load More" button clicked
|
||||
- toggle-details: Emitted when tournament details button clicked
|
||||
-->
|
||||
<template>
|
||||
<div class="tournament-grid">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="status loading">⏳ Loading tournaments...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="status error">❌ Error: {{ error }}</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="!tournaments || tournaments.length === 0"
|
||||
class="status empty"
|
||||
>
|
||||
No tournaments found. Create one at
|
||||
<a href="https://challonge.com" target="_blank">Challonge.com</a>
|
||||
</div>
|
||||
|
||||
<!-- Tournament List -->
|
||||
<div v-else>
|
||||
<!-- Search Filter -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
:value="searchQuery"
|
||||
@input="$emit('update:searchQuery', $event.target.value)"
|
||||
type="text"
|
||||
placeholder="🔍 Search tournaments by name (client-side)..."
|
||||
class="search-input"
|
||||
/>
|
||||
<span v-if="searchQuery" class="search-info">
|
||||
Showing {{ filteredTournaments.length }} of
|
||||
{{ tournaments.length }} tournaments
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tournament Cards -->
|
||||
<div class="tournament-list">
|
||||
<div
|
||||
v-for="tournament in filteredTournaments"
|
||||
:key="getTournamentId(tournament)"
|
||||
class="tournament-card"
|
||||
>
|
||||
<div class="tournament-header">
|
||||
<h4>{{ getTournamentName(tournament) }}</h4>
|
||||
<span
|
||||
class="tournament-state"
|
||||
:class="getTournamentProp(tournament, 'state')"
|
||||
>
|
||||
{{ getTournamentProp(tournament, 'state') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tournament-details">
|
||||
<p>
|
||||
<strong>URL:</strong>
|
||||
{{ getTournamentProp(tournament, 'url') }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Type:</strong>
|
||||
{{ getTournamentProp(tournament, 'tournament_type') }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Participants:</strong>
|
||||
{{ getTournamentProp(tournament, 'participants_count') }}
|
||||
</p>
|
||||
<p v-if="getTournamentProp(tournament, 'started_at')">
|
||||
<strong>Started:</strong>
|
||||
{{ formatDate(getTournamentProp(tournament, 'started_at')) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('toggle-details', getTournamentId(tournament))"
|
||||
class="btn btn-small"
|
||||
:class="{
|
||||
'btn-active': expandedTournamentId === getTournamentId(tournament)
|
||||
}"
|
||||
>
|
||||
{{
|
||||
expandedTournamentId === getTournamentId(tournament)
|
||||
? 'Hide Details'
|
||||
: 'Load Details'
|
||||
}}
|
||||
</button>
|
||||
|
||||
<!-- Details Slot for expanded content -->
|
||||
<slot
|
||||
name="tournament-details"
|
||||
:tournament="tournament"
|
||||
:is-expanded="expandedTournamentId === getTournamentId(tournament)"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load More Button (v2.1 only) -->
|
||||
<div
|
||||
v-if="apiVersion === 'v2.1' && hasNextPage"
|
||||
class="load-more-section"
|
||||
>
|
||||
<button
|
||||
@click="$emit('load-more')"
|
||||
:disabled="loadingMore"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ loadingMore ? 'Loading...' : 'Load More Tournaments' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Props for TournamentGrid component
|
||||
*/
|
||||
const props = defineProps({
|
||||
tournaments: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
filteredTournaments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
expandedTournamentId: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
hasNextPage: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
apiVersion: {
|
||||
type: String,
|
||||
default: 'v2.1'
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Events emitted by TournamentGrid
|
||||
*/
|
||||
defineEmits(['update:searchQuery', 'load-more', 'toggle-details']);
|
||||
|
||||
/**
|
||||
* Helper to get tournament name (handles both v1 and v2.1 response structures)
|
||||
*/
|
||||
function getTournamentName(tournament) {
|
||||
return tournament.tournament?.name || tournament.name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get tournament ID
|
||||
*/
|
||||
function getTournamentId(tournament) {
|
||||
return tournament.tournament?.id || tournament.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get tournament property
|
||||
*/
|
||||
function getTournamentProp(tournament, prop) {
|
||||
return tournament.tournament?.[prop] || tournament[prop];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string
|
||||
*/
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tournament-grid {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.empty {
|
||||
background: #f5f5f5;
|
||||
color: #616161;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status a {
|
||||
color: #667eea;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-info {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tournament-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tournament-card {
|
||||
background: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tournament-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.tournament-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tournament-header h4 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tournament-state {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tournament-state.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.tournament-state.underway,
|
||||
.tournament-state.in_progress {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.tournament-state.complete,
|
||||
.tournament-state.ended {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.tournament-details {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tournament-details p {
|
||||
margin: 0.5rem 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tournament-details strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-small.btn-active {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.btn-small.btn-active:hover {
|
||||
background: #653c8a;
|
||||
}
|
||||
|
||||
.load-more-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
background: #adb5bd;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div v-if="fileContent" class="action-bar">
|
||||
<button
|
||||
@click="copySelected"
|
||||
:disabled="selectionCount === 0"
|
||||
class="btn-action"
|
||||
>
|
||||
📋 Copy Selected ({{ selectionCount }} lines)
|
||||
</button>
|
||||
<button @click="copyAll" class="btn-action">📋 Copy All</button>
|
||||
<button
|
||||
@click="exportSelected"
|
||||
:disabled="selectionCount === 0"
|
||||
class="btn-action"
|
||||
>
|
||||
💾 Export Selected
|
||||
</button>
|
||||
<button @click="exportAll" class="btn-action">💾 Export All</button>
|
||||
<button @click="shareUrl" class="btn-action">🔗 Share Link</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useLineSelection } from '../../composables/useLineSelection.js';
|
||||
|
||||
const props = defineProps({
|
||||
displayLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
fileContent: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectedFile: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectionState: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const displayLines = toRef(props, 'displayLines');
|
||||
const fileContent = toRef(props, 'fileContent');
|
||||
const selectedFile = toRef(props, 'selectedFile');
|
||||
|
||||
const internalSelectionState = useLineSelection(
|
||||
displayLines,
|
||||
fileContent,
|
||||
selectedFile
|
||||
);
|
||||
const activeSelectionState = props.selectionState || internalSelectionState;
|
||||
|
||||
const {
|
||||
selectionCount,
|
||||
copySelected,
|
||||
copyAll,
|
||||
exportSelected,
|
||||
exportAll,
|
||||
shareUrl
|
||||
} = activeSelectionState;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="file-selector">
|
||||
<label for="file-select">Select File:</label>
|
||||
<select
|
||||
id="file-select"
|
||||
v-model="selectedFile"
|
||||
:disabled="isLoading || uniqueFiles.length === 0"
|
||||
>
|
||||
<option value="">-- Choose a file --</option>
|
||||
<option
|
||||
v-for="file in uniqueFiles"
|
||||
:key="file.filename"
|
||||
:value="getFileType(file.filename)"
|
||||
>
|
||||
{{ formatFileName(file.filename) }} ({{ formatSize(file.size) }})
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<span v-if="fileContent" class="file-info">
|
||||
{{ fileLines.length.toLocaleString() }} lines
|
||||
<template v-if="selectedFileMeta">
|
||||
• {{ formatSize(selectedFileMeta.size) }}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span v-if="isLoading" class="file-status">Loading...</span>
|
||||
<span v-if="fileError" class="file-error">{{ fileError }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useGamemasterFiles } from '../../composables/useGamemasterFiles.js';
|
||||
|
||||
const props = defineProps({
|
||||
client: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
filesState: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const internalFilesState = props.client
|
||||
? useGamemasterFiles(props.client)
|
||||
: null;
|
||||
const activeFilesState = computed(() => props.filesState || internalFilesState);
|
||||
|
||||
const selectedFile = computed({
|
||||
get: () => activeFilesState.value?.selectedFile?.value,
|
||||
set: val => {
|
||||
if (activeFilesState.value?.selectedFile) {
|
||||
activeFilesState.value.selectedFile.value = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
const fileContent = computed(() => activeFilesState.value?.fileContent?.value);
|
||||
const fileLines = computed(
|
||||
() => activeFilesState.value?.fileLines?.value || []
|
||||
);
|
||||
const uniqueFiles = computed(
|
||||
() => activeFilesState.value?.uniqueFiles?.value || []
|
||||
);
|
||||
const isLoading = computed(
|
||||
() => activeFilesState.value?.isLoading?.value || false
|
||||
);
|
||||
const fileError = computed(() => activeFilesState.value?.fileError?.value);
|
||||
|
||||
const formatSize = (...args) => activeFilesState.value?.formatSize?.(...args);
|
||||
const formatFileName = (...args) =>
|
||||
activeFilesState.value?.formatFileName?.(...args);
|
||||
const getFileType = (...args) => activeFilesState.value?.getFileType?.(...args);
|
||||
|
||||
const selectedFileMeta = computed(() => {
|
||||
if (!selectedFile.value) return null;
|
||||
return uniqueFiles.value.find(
|
||||
file => getFileType(file.filename) === selectedFile.value
|
||||
);
|
||||
});
|
||||
|
||||
// Don't call loadStatus here - it's called by the parent component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.file-selector label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-selector select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.file-selector select:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-status {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-error {
|
||||
font-size: 13px;
|
||||
color: #d9534f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div v-if="hasData" class="filter-panel">
|
||||
<div class="filter-row">
|
||||
<label for="property-select">Filter by Property:</label>
|
||||
<select
|
||||
id="property-select"
|
||||
v-model="filterProperty"
|
||||
@change="applyFilter"
|
||||
>
|
||||
<option value="">All Properties</option>
|
||||
<option
|
||||
v-for="path in availablePaths"
|
||||
:key="path.path"
|
||||
:value="path.path"
|
||||
>
|
||||
{{ path.breadcrumb }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="filterProperty" class="filter-row">
|
||||
<label for="filter-mode">Mode:</label>
|
||||
<select id="filter-mode" v-model="filterMode" @change="applyFilter">
|
||||
<option value="equals">Equals</option>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="regex">Regex</option>
|
||||
</select>
|
||||
|
||||
<label for="filter-value" class="filter-value-label">Value:</label>
|
||||
<input
|
||||
id="filter-value"
|
||||
v-model="filterValue"
|
||||
@input="applyFilter"
|
||||
:list="valueListId"
|
||||
type="text"
|
||||
placeholder="Enter value..."
|
||||
class="filter-value-input"
|
||||
/>
|
||||
<datalist :id="valueListId">
|
||||
<option v-for="value in valueSuggestions" :key="value" :value="value" />
|
||||
</datalist>
|
||||
|
||||
<button class="btn-clear" @click="clearFilters" title="Clear filter">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-stats">
|
||||
<span>{{ filterStats.matched }} / {{ filterStats.total }} matched</span>
|
||||
<span v-if="filterStats.total > 0">({{ filterStats.percentage }}%)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filterError" class="filter-error">
|
||||
{{ filterError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import useJsonFilter from '../../composables/useJsonFilter.js';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
filterState: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const internalFilterState = useJsonFilter();
|
||||
const activeFilterState = computed(
|
||||
() => props.filterState || internalFilterState
|
||||
);
|
||||
|
||||
const {
|
||||
filterProperty,
|
||||
filterValue,
|
||||
filterMode,
|
||||
availablePaths,
|
||||
filterError,
|
||||
filterStats,
|
||||
initializeFilter,
|
||||
extractPathsLazy,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
getUniqueValues
|
||||
} = activeFilterState.value;
|
||||
|
||||
const hasData = computed(
|
||||
() => Array.isArray(props.data) && props.data.length > 0
|
||||
);
|
||||
|
||||
const valueSuggestions = computed(() => {
|
||||
if (!filterProperty.value) return [];
|
||||
return getUniqueValues(filterProperty.value).slice(0, 50);
|
||||
});
|
||||
|
||||
const valueListId = computed(
|
||||
() => `filter-values-${filterProperty.value || 'all'}`
|
||||
);
|
||||
|
||||
function applyFilter() {
|
||||
setFilter(filterProperty.value, filterValue.value, filterMode.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
data => {
|
||||
if (Array.isArray(data)) {
|
||||
initializeFilter(data);
|
||||
if (data.length > 100) {
|
||||
extractPathsLazy(data, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-row label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filter-value-label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.filter-value-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
font-size: 13px;
|
||||
color: #d9534f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fileContent"
|
||||
class="content-viewer"
|
||||
:class="{
|
||||
'dark-mode': preferences.darkMode,
|
||||
'line-wrap': preferences.lineWrap
|
||||
}"
|
||||
>
|
||||
<RecycleScroller
|
||||
v-if="displayLines.length > 1000"
|
||||
ref="virtualScroller"
|
||||
class="scroller"
|
||||
:items="displayLines"
|
||||
:item-size="lineHeight"
|
||||
key-field="lineNumber"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
:data-line="item.lineNumber"
|
||||
:class="[
|
||||
'line',
|
||||
{ selected: selectedLines.has(item.lineNumber) },
|
||||
{ 'highlight-match': item.hasMatch },
|
||||
{ 'current-result': isCurrentResult(item.lineNumber) }
|
||||
]"
|
||||
@click="toggleLineSelection(item.lineNumber, $event)"
|
||||
>
|
||||
<span v-if="preferences.showLineNumbers" class="line-number">
|
||||
{{ item.lineNumber }}
|
||||
</span>
|
||||
<pre
|
||||
v-highlight="highlightConfig"
|
||||
><code>{{ item.content }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
|
||||
<div v-else class="lines-container">
|
||||
<div
|
||||
v-for="line in displayLines"
|
||||
:key="line.lineNumber"
|
||||
:data-line="line.lineNumber"
|
||||
:class="[
|
||||
'line',
|
||||
{ selected: selectedLines.has(line.lineNumber) },
|
||||
{ 'highlight-match': line.hasMatch },
|
||||
{ 'current-result': isCurrentResult(line.lineNumber) }
|
||||
]"
|
||||
@click="toggleLineSelection(line.lineNumber, $event)"
|
||||
>
|
||||
<span v-if="preferences.showLineNumbers" class="line-number">
|
||||
{{ line.lineNumber }}
|
||||
</span>
|
||||
<pre v-highlight="highlightConfig"><code>{{ line.content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fileTooLarge" class="warning-banner">
|
||||
⚠️ File exceeds 10,000 lines. Showing first 10,000 lines only. Use search
|
||||
or filters to find specific content.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef, ref } from 'vue';
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import { useLineSelection } from '../../composables/useLineSelection.js';
|
||||
|
||||
const props = defineProps({
|
||||
displayLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
fileContent: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectedFile: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fileTooLarge: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
preferences: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
darkMode: false,
|
||||
lineWrap: false,
|
||||
showLineNumbers: true
|
||||
})
|
||||
},
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentResultIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
highlightConfig: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
theme: 'github',
|
||||
language: 'json'
|
||||
})
|
||||
},
|
||||
lineHeight: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
selectionState: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const displayLines = toRef(props, 'displayLines');
|
||||
const fileContent = toRef(props, 'fileContent');
|
||||
const selectedFile = toRef(props, 'selectedFile');
|
||||
|
||||
const virtualScroller = ref(null);
|
||||
|
||||
const internalSelectionState = useLineSelection(
|
||||
displayLines,
|
||||
fileContent,
|
||||
selectedFile
|
||||
);
|
||||
const activeSelectionState = computed(
|
||||
() => props.selectionState || internalSelectionState
|
||||
);
|
||||
const { selectedLines, toggleLineSelection } = activeSelectionState.value;
|
||||
|
||||
const isCurrentResult = lineNumber => {
|
||||
if (!props.searchResults.length) return false;
|
||||
const currentLine = props.searchResults[props.currentResultIndex] + 1;
|
||||
return lineNumber === currentLine;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
selectedLines,
|
||||
toggleLineSelection,
|
||||
virtualScroller
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-viewer {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode {
|
||||
background: #1e1e1e;
|
||||
border-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.content-viewer.line-wrap pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.scroller,
|
||||
.lines-container {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .scroller,
|
||||
.content-viewer.dark-mode .lines-container {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .line {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.line.selected {
|
||||
background: rgba(102, 126, 234, 0.15) !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .line.selected {
|
||||
background: rgba(102, 126, 234, 0.25) !important;
|
||||
}
|
||||
|
||||
.line.highlight-match {
|
||||
background: rgba(255, 234, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .line.highlight-match {
|
||||
background: rgba(255, 234, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.line.current-result {
|
||||
outline: 2px solid #ff9800;
|
||||
background: rgba(255, 193, 7, 0.3) !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .line.current-result {
|
||||
background: rgba(255, 193, 7, 0.2) !important;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
color: #999;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .line-number {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Force transparent backgrounds on all elements inside lines */
|
||||
.line * {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.line pre {
|
||||
margin: 0;
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.line pre code {
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Light mode syntax highlighting */
|
||||
.content-viewer:not(.dark-mode) .line pre code {
|
||||
color: #24292e !important;
|
||||
}
|
||||
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #24292e !important;
|
||||
}
|
||||
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs-attr),
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs-attribute) {
|
||||
color: #6f42c1 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs-string) {
|
||||
color: #032f62 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs-number) {
|
||||
color: #005a9c !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer:not(.dark-mode) :deep(.hljs-literal) {
|
||||
color: #d73a49 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Dark mode syntax highlighting */
|
||||
.content-viewer.dark-mode :deep(.hljs) {
|
||||
background: transparent !important;
|
||||
color: #e1e4e8 !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode :deep(.hljs-attr),
|
||||
.content-viewer.dark-mode :deep(.hljs-attribute) {
|
||||
color: #79b8ff !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode :deep(.hljs-string) {
|
||||
color: #85e89d !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode :deep(.hljs-number) {
|
||||
color: #79b8ff !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode :deep(.hljs-literal) {
|
||||
color: #f97583 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: #fff3cd;
|
||||
border-top: 1px solid #ffc107;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-viewer.dark-mode .warning-banner {
|
||||
background: #3d3400;
|
||||
color: #ffeb3b;
|
||||
border-color: #ffc107;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
background: #fff3cd;
|
||||
border-top: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search in file... (Ctrl+F)"
|
||||
class="search-input"
|
||||
:disabled="isDisabled"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
class="btn-clear"
|
||||
title="Clear search"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSearchResults" class="search-results">
|
||||
<span :title="currentResultLineTitle">
|
||||
{{ resultCountDisplay }}
|
||||
<template v-if="currentResultLineNumber">
|
||||
(Line {{ currentResultLineNumber }})
|
||||
</template>
|
||||
</span>
|
||||
<button
|
||||
@click="goToPrevResult"
|
||||
class="btn-nav"
|
||||
:disabled="!hasSearchResults"
|
||||
title="Previous result (Shift+Ctrl+G)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
@click="goToNextResult"
|
||||
class="btn-nav"
|
||||
:disabled="!hasSearchResults"
|
||||
title="Next result (Ctrl+G)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="searchHistory.history.value.length > 0" class="search-history">
|
||||
<button
|
||||
v-for="(item, index) in searchHistory.history.value"
|
||||
:key="index"
|
||||
@click="applyHistoryItem(item)"
|
||||
class="history-item"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="searchError" class="search-error">
|
||||
{{ searchError }}
|
||||
</div>
|
||||
|
||||
<div v-if="isSearching" class="search-status">Searching...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { useGamemasterSearch } from '../../composables/useGamemasterSearch.js';
|
||||
|
||||
const props = defineProps({
|
||||
fileLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
displayLines: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
searchState: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const fileLines = toRef(props, 'fileLines');
|
||||
const displayLines = toRef(props, 'displayLines');
|
||||
|
||||
const internalSearchState = useGamemasterSearch(fileLines, displayLines);
|
||||
const activeSearchState = computed(
|
||||
() => props.searchState || internalSearchState
|
||||
);
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
currentResultIndex,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchHistory,
|
||||
clearSearch,
|
||||
goToNextResult,
|
||||
goToPrevResult,
|
||||
applyHistoryItem,
|
||||
currentResultLineNumber,
|
||||
resultCountDisplay,
|
||||
hasSearchResults
|
||||
} = activeSearchState.value;
|
||||
|
||||
const isDisabled = computed(
|
||||
() => props.disabled || fileLines.value.length === 0
|
||||
);
|
||||
|
||||
const currentResultLineTitle = computed(() => {
|
||||
if (!currentResultLineNumber.value) return '';
|
||||
return `Line ${currentResultLineNumber.value}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 35px 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.search-input:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.btn-clear:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-nav:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.search-error {
|
||||
color: #d9534f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-status {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="buttonClasses"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span v-if="loading" class="spinner" aria-hidden="true"></span>
|
||||
<span
|
||||
v-if="icon && iconPosition === 'left' && !loading"
|
||||
class="icon icon-left"
|
||||
>
|
||||
{{ icon }}
|
||||
</span>
|
||||
<span v-if="$slots.default" :class="{ 'sr-only': loading }">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<span
|
||||
v-if="icon && iconPosition === 'right' && !loading"
|
||||
class="icon icon-right"
|
||||
>
|
||||
{{ icon }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Button variant style
|
||||
* @type {'primary' | 'secondary' | 'danger' | 'ghost' | 'icon-only'}
|
||||
*/
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: value =>
|
||||
['primary', 'secondary', 'danger', 'ghost', 'icon-only'].includes(value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Button size
|
||||
* @type {'small' | 'medium' | 'large'}
|
||||
*/
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: value => ['small', 'medium', 'large'].includes(value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether button is in loading state
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether button is disabled
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Icon character or emoji to display
|
||||
*/
|
||||
icon: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Icon position relative to text
|
||||
* @type {'left' | 'right'}
|
||||
*/
|
||||
iconPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: value => ['left', 'right'].includes(value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether button should take full width of container
|
||||
*/
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Button type attribute
|
||||
* @type {'button' | 'submit' | 'reset'}
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
validator: value => ['button', 'submit', 'reset'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const slots = useSlots();
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
'base-button',
|
||||
`base-button--${props.variant}`,
|
||||
`base-button--${props.size}`,
|
||||
{
|
||||
'base-button--loading': props.loading,
|
||||
'base-button--disabled': props.disabled,
|
||||
'base-button--full-width': props.fullWidth,
|
||||
'base-button--icon-only':
|
||||
props.variant === 'icon-only' || (!slots.default && props.icon)
|
||||
}
|
||||
]);
|
||||
|
||||
const handleClick = event => {
|
||||
if (!props.disabled && !props.loading) {
|
||||
emit('click', event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.base-button:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.base-button--small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.base-button--medium {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.base-button--large {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.base-button--primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--primary:hover:not(:disabled) {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.base-button--primary:active:not(:disabled) {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.base-button--secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.base-button--secondary:hover:not(:disabled) {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.base-button--secondary:active:not(:disabled) {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
.base-button--danger {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.base-button--danger:hover:not(:disabled) {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.base-button--danger:active:not(:disabled) {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.base-button--ghost {
|
||||
background-color: transparent;
|
||||
color: #3b82f6;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.base-button--ghost:hover:not(:disabled) {
|
||||
background-color: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.base-button--ghost:active:not(:disabled) {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.base-button--icon-only {
|
||||
padding: 0.5rem;
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.base-button--icon-only:hover:not(:disabled) {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.base-button--icon-only:active:not(:disabled) {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.base-button:disabled,
|
||||
.base-button--disabled,
|
||||
.base-button--loading {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.base-button--full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon spacing */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-left {
|
||||
margin-right: -0.25rem;
|
||||
}
|
||||
|
||||
.icon-right {
|
||||
margin-left: -0.25rem;
|
||||
}
|
||||
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
363
code/websites/pokedex.online/src/components/shared/BaseModal.vue
Normal file
363
code/websites/pokedex.online/src/components/shared/BaseModal.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="modal-overlay"
|
||||
:class="{ 'modal-overlay--persistent': persistent }"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="modal-container"
|
||||
:class="`modal-container--${size}`"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="title ? 'modal-title' : undefined"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div v-if="title || $slots.header || showClose" class="modal-header">
|
||||
<slot name="header">
|
||||
<h2 v-if="title" id="modal-title" class="modal-title">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</slot>
|
||||
<button
|
||||
v-if="showClose"
|
||||
type="button"
|
||||
class="modal-close"
|
||||
aria-label="Close modal"
|
||||
@click="handleClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Whether the modal is visible
|
||||
*/
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
|
||||
/**
|
||||
* Modal title (alternative to header slot)
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Modal size
|
||||
* @type {'small' | 'medium' | 'large' | 'full'}
|
||||
*/
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: value => ['small', 'medium', 'large', 'full'].includes(value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether clicking overlay closes the modal
|
||||
*/
|
||||
closeOnOverlay: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether modal can be closed (hides X button, prevents ESC/overlay close)
|
||||
*/
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether to show the close X button
|
||||
*/
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'open']);
|
||||
|
||||
const modalRef = ref(null);
|
||||
const previousActiveElement = ref(null);
|
||||
|
||||
// Close handlers
|
||||
const handleClose = () => {
|
||||
if (!props.persistent) {
|
||||
emit('update:modelValue', false);
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (props.closeOnOverlay && !props.persistent) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = event => {
|
||||
if (event.key === 'Escape' && props.modelValue && !props.persistent) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus management
|
||||
const getFocusableElements = () => {
|
||||
if (!modalRef.value) return [];
|
||||
|
||||
const focusableSelectors = [
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable="true"]'
|
||||
];
|
||||
|
||||
return Array.from(
|
||||
modalRef.value.querySelectorAll(focusableSelectors.join(','))
|
||||
);
|
||||
};
|
||||
|
||||
const handleTabKey = event => {
|
||||
if (!props.modelValue) return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab: move focus backwards
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab: move focus forwards
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = event => {
|
||||
if (event.key === 'Escape') {
|
||||
handleEscapeKey(event);
|
||||
} else if (event.key === 'Tab') {
|
||||
handleTabKey(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle hooks
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async newValue => {
|
||||
if (newValue) {
|
||||
// Modal opened
|
||||
previousActiveElement.value = document.activeElement;
|
||||
document.body.style.overflow = 'hidden';
|
||||
emit('open');
|
||||
|
||||
// Focus first focusable element after render
|
||||
await nextTick();
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length > 0) {
|
||||
focusableElements[0].focus();
|
||||
} else if (modalRef.value) {
|
||||
modalRef.value.focus();
|
||||
}
|
||||
} else {
|
||||
// Modal closed
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Restore focus to previous element
|
||||
if (previousActiveElement.value) {
|
||||
previousActiveElement.value.focus();
|
||||
previousActiveElement.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Overlay */
|
||||
.modal-overlay {
|
||||
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;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-overlay--persistent {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.modal-container--small {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container--medium {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container--large {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container--full {
|
||||
max-width: 95vw;
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
margin-left: 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.modal-footer {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-container,
|
||||
.modal-leave-to .modal-container {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Shared Components Index
|
||||
*
|
||||
* Centralized exports for all shared components
|
||||
*/
|
||||
|
||||
export { default as BaseButton } from './BaseButton.vue';
|
||||
export { default as BaseModal } from './BaseModal.vue';
|
||||
136
code/websites/pokedex.online/src/composables/useAsyncState.js
Normal file
136
code/websites/pokedex.online/src/composables/useAsyncState.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* useAsyncState Composable
|
||||
*
|
||||
* Manages loading/error/success states for async operations
|
||||
* Consolidates pattern used across 13+ components
|
||||
*
|
||||
* @example
|
||||
* const { execute, loading, error, data, isSuccess } = useAsyncState();
|
||||
* await execute(async () => {
|
||||
* return await fetchSomeData();
|
||||
* });
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export function useAsyncState(options = {}) {
|
||||
const {
|
||||
initialData = null,
|
||||
onSuccess = null,
|
||||
onError = null,
|
||||
maxRetries = 0,
|
||||
retryDelay = 1000
|
||||
} = options;
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const data = ref(initialData);
|
||||
const abortController = ref(null);
|
||||
|
||||
const isSuccess = computed(
|
||||
() => !loading.value && !error.value && data.value !== null
|
||||
);
|
||||
const isError = computed(() => !loading.value && error.value !== null);
|
||||
const isIdle = computed(
|
||||
() => !loading.value && error.value === null && data.value === null
|
||||
);
|
||||
|
||||
/**
|
||||
* Execute an async function with state management
|
||||
* @param {Function} asyncFn - Async function to execute
|
||||
* @param {Object} executeOptions - Options for this execution
|
||||
* @returns {Promise<any>} Result of async function
|
||||
*/
|
||||
async function execute(asyncFn, executeOptions = {}) {
|
||||
const { retries = maxRetries, signal = null } = executeOptions;
|
||||
|
||||
// Create abort controller if not provided
|
||||
if (!signal) {
|
||||
abortController.value = new AbortController();
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
let lastError;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= retries) {
|
||||
try {
|
||||
const result = await asyncFn(signal || abortController.value?.signal);
|
||||
data.value = result;
|
||||
loading.value = false;
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
|
||||
// Don't retry if aborted
|
||||
if (err.name === 'AbortError') {
|
||||
break;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
|
||||
// If more retries remaining, wait before retrying
|
||||
if (attempt <= retries) {
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, retryDelay * attempt)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
error.value = lastError;
|
||||
loading.value = false;
|
||||
|
||||
if (onError) {
|
||||
onError(lastError);
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current async operation
|
||||
*/
|
||||
function cancel() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
function reset() {
|
||||
cancel();
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
data.value = initialData;
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
|
||||
// Computed
|
||||
isSuccess,
|
||||
isError,
|
||||
isIdle,
|
||||
|
||||
// Methods
|
||||
execute,
|
||||
cancel,
|
||||
reset
|
||||
};
|
||||
}
|
||||
193
code/websites/pokedex.online/src/composables/useAuth.js
Normal file
193
code/websites/pokedex.online/src/composables/useAuth.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* useAuth Composable
|
||||
*
|
||||
* Manages authentication state including login, logout, token storage, and user info
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
// Shared state
|
||||
const token = ref(localStorage.getItem('auth_token') || null);
|
||||
const user = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Compute derived states
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
||||
const isAdmin = computed(() => user.value?.isAdmin || false);
|
||||
|
||||
/**
|
||||
* Load and verify stored token on app startup
|
||||
*/
|
||||
async function initializeAuth() {
|
||||
const storedToken = localStorage.getItem('auth_token');
|
||||
|
||||
if (!storedToken) {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/auth/verify', {
|
||||
token: storedToken
|
||||
});
|
||||
token.value = storedToken;
|
||||
user.value = response.user;
|
||||
} catch (err) {
|
||||
// Token is invalid, clear it
|
||||
localStorage.removeItem('auth_token');
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with password
|
||||
* @param {string} password - Admin password
|
||||
* @returns {Promise<Object>} Response with token and user info
|
||||
* @throws {Error} If login fails
|
||||
*/
|
||||
async function login(password) {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
if (!password) {
|
||||
throw new Error('Password is required');
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/auth/login', { password });
|
||||
|
||||
// Store token
|
||||
token.value = response.token;
|
||||
user.value = response.user;
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear authentication
|
||||
*/
|
||||
async function logout() {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout', {});
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
} finally {
|
||||
// Clear token regardless of API response
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the current token
|
||||
* @returns {Promise<Object>} Response with new token
|
||||
* @throws {Error} If refresh fails
|
||||
*/
|
||||
async function refreshToken() {
|
||||
if (!token.value) {
|
||||
throw new Error('No token to refresh');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/auth/refresh', {
|
||||
token: token.value
|
||||
});
|
||||
token.value = response.token;
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
return response;
|
||||
} catch (err) {
|
||||
// If refresh fails, logout
|
||||
await logout();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
* @returns {Promise<Object>} User info
|
||||
*/
|
||||
async function getUserInfo() {
|
||||
try {
|
||||
const response = await apiClient.get('/api/auth/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.value}`
|
||||
}
|
||||
});
|
||||
user.value = response.user;
|
||||
return response.user;
|
||||
} catch (err) {
|
||||
// If getting user info fails, logout
|
||||
await logout();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* @param {string|string[]} permissions - Permission or array of permissions
|
||||
* @returns {boolean} True if user has permission
|
||||
*/
|
||||
function hasPermission(permissions) {
|
||||
if (!user.value) return false;
|
||||
|
||||
const perms = Array.isArray(permissions) ? permissions : [permissions];
|
||||
return perms.some(perm => user.value.permissions?.includes(perm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auth token to API client headers
|
||||
*/
|
||||
export function setupAuthInterceptor() {
|
||||
if (token.value) {
|
||||
apiClient.setDefaultHeader('Authorization', `Bearer ${token.value}`);
|
||||
}
|
||||
|
||||
// Watch for token changes and update header
|
||||
watch(token, newToken => {
|
||||
if (newToken) {
|
||||
apiClient.setDefaultHeader('Authorization', `Bearer ${newToken}`);
|
||||
} else {
|
||||
apiClient.removeDefaultHeader('Authorization');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return {
|
||||
// State
|
||||
token: computed(() => token.value),
|
||||
user: computed(() => user.value),
|
||||
isLoading: computed(() => isLoading.value),
|
||||
error: computed(() => error.value),
|
||||
|
||||
// Computed
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
|
||||
// Methods
|
||||
initializeAuth,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
getUserInfo,
|
||||
hasPermission,
|
||||
setupAuthInterceptor
|
||||
};
|
||||
}
|
||||
@@ -1,58 +1,57 @@
|
||||
/**
|
||||
* useChallongeApiKey Composable
|
||||
* Manages Challonge API key storage in browser localStorage
|
||||
* Works on mobile, desktop, and tablets
|
||||
* Manages Challonge API key storage on the backend per-session (SID cookie)
|
||||
* No-split-brain: API key never lives in browser storage.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
const STORAGE_KEY = 'challonge_api_key';
|
||||
const storedKey = ref(getStoredKey());
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Get API key from localStorage
|
||||
* @returns {string|null} Stored API key or null
|
||||
*/
|
||||
function getStoredKey() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || null;
|
||||
} catch (error) {
|
||||
console.warn('localStorage not available:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key to localStorage
|
||||
* Save API key server-side (per-session)
|
||||
* @param {string} apiKey - The API key to store
|
||||
* @returns {boolean} Success status
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
function saveApiKey(apiKey) {
|
||||
try {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
throw new Error('Invalid API key format');
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, apiKey);
|
||||
storedKey.value = apiKey;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
async function saveApiKey(apiKey) {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
error.value = 'Invalid API key format';
|
||||
return false;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiClient.post('/oauth/challonge/api-key', { apiKey });
|
||||
status.value = data;
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear API key from localStorage
|
||||
* @returns {boolean} Success status
|
||||
* Clear API key server-side (per-session)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
function clearApiKey() {
|
||||
async function clearApiKey() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
storedKey.value = null;
|
||||
const data = await apiClient.post('/oauth/challonge/api-key/clear', {});
|
||||
status.value = data;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear API key:', error);
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,34 +61,54 @@ function clearApiKey() {
|
||||
* @returns {string|null} Masked key or null
|
||||
*/
|
||||
const maskedKey = computed(() => {
|
||||
if (!storedKey.value) return null;
|
||||
const key = storedKey.value;
|
||||
if (key.length < 8) return '••••••••';
|
||||
return `${key.slice(0, 4)}•••••••${key.slice(-4)}`;
|
||||
if (!isKeyStored.value) return null;
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
// Backwards-compat for older views: truthy string when stored
|
||||
const storedKey = computed(() => maskedKey.value);
|
||||
|
||||
/**
|
||||
* Check if API key is stored
|
||||
* @returns {boolean} True if key exists
|
||||
*/
|
||||
const isKeyStored = computed(() => !!storedKey.value);
|
||||
const isKeyStored = computed(() => {
|
||||
return !!status.value?.methods?.api_key?.connected;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the full API key (use with caution)
|
||||
* @returns {string|null} Full API key or null
|
||||
*/
|
||||
function getApiKey() {
|
||||
return storedKey.value;
|
||||
// No-split-brain: never expose raw key to browser
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
status.value = await apiClient.get('/oauth/challonge/status');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useChallongeApiKey() {
|
||||
// Fire-and-forget initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
saveApiKey,
|
||||
clearApiKey,
|
||||
getApiKey,
|
||||
getStoredKey,
|
||||
storedKey: computed(() => storedKey.value),
|
||||
refreshStatus,
|
||||
status: computed(() => status.value),
|
||||
loading: computed(() => loading.value),
|
||||
error: computed(() => error.value),
|
||||
maskedKey,
|
||||
storedKey,
|
||||
isKeyStored
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Challonge Client Composable
|
||||
*
|
||||
* Manages Challonge API client initialization with support for:
|
||||
* - API v1 and v2.1
|
||||
* - Multiple authentication methods (API Key, OAuth, Client Credentials)
|
||||
* - Smart auth selection based on tournament scope
|
||||
* - Reactive client updates
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const {
|
||||
* client,
|
||||
* apiVersion,
|
||||
* tournamentScope,
|
||||
* switchVersion,
|
||||
* setScope
|
||||
* } = useChallongeClient();
|
||||
*
|
||||
* // Use client for API calls
|
||||
* await client.value.tournaments.list();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { useChallongeApiKey } from './useChallongeApiKey.js';
|
||||
import { useChallongeOAuth } from './useChallongeOAuth.js';
|
||||
import { useChallongeClientCredentials } from './useChallongeClientCredentials.js';
|
||||
import {
|
||||
createChallongeV1Client,
|
||||
createChallongeV2Client,
|
||||
AuthType,
|
||||
ScopeType
|
||||
} from '../services/challonge.service.js';
|
||||
|
||||
export function useChallongeClient(options = {}) {
|
||||
const { debug = false } = options;
|
||||
|
||||
// Get authentication sources
|
||||
const { isKeyStored } = useChallongeApiKey();
|
||||
const { isAuthenticated: isOAuthAuthenticated } = useChallongeOAuth();
|
||||
const {
|
||||
isAuthenticated: isClientCredsAuthenticated,
|
||||
accessToken: clientCredsToken
|
||||
} = useChallongeClientCredentials();
|
||||
|
||||
// Configuration state
|
||||
const apiVersion = ref('v2.1'); // 'v1' or 'v2.1'
|
||||
const tournamentScope = ref(ScopeType.USER);
|
||||
const debugMode = ref(debug);
|
||||
|
||||
// No-split-brain: raw keys/tokens are never available in the browser
|
||||
const apiKey = computed(() => null);
|
||||
|
||||
// Masked API key for display
|
||||
const maskedApiKey = computed(() => {
|
||||
if (!isKeyStored.value) return '';
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
/**
|
||||
* Create API client reactively based on version, auth method, and scope
|
||||
*/
|
||||
const client = computed(() => {
|
||||
if (apiVersion.value === 'v1') {
|
||||
// v1 only supports API key
|
||||
if (!isKeyStored.value) return null;
|
||||
return createChallongeV1Client(null);
|
||||
} else {
|
||||
// v2.1 supports OAuth, client credentials, and API key
|
||||
// Smart priority based on scope selection:
|
||||
// - APPLICATION scope: prefer client credentials (designed for this)
|
||||
// - USER scope: prefer OAuth user tokens or API key
|
||||
|
||||
if (tournamentScope.value === ScopeType.APPLICATION) {
|
||||
// APPLICATION scope - prefer client credentials
|
||||
if (isClientCredsAuthenticated.value) {
|
||||
if (debugMode.value) {
|
||||
console.log(
|
||||
'🔐 Using Client Credentials token for APPLICATION scope'
|
||||
);
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: clientCredsToken.value, type: AuthType.OAUTH },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
|
||||
// Backend requires client_credentials for /v2.1/application/*
|
||||
return null;
|
||||
} else {
|
||||
// USER scope - prefer OAuth user tokens or API key
|
||||
if (isOAuthAuthenticated.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔐 Using OAuth user token for USER scope');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: null, type: AuthType.OAUTH },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
} else if (isKeyStored.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔑 Using API Key for USER scope');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: null, type: AuthType.API_KEY },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try API key
|
||||
if (isKeyStored.value) {
|
||||
if (debugMode.value) {
|
||||
console.log('🔑 Using API Key (fallback)');
|
||||
}
|
||||
return createChallongeV2Client(
|
||||
{ token: null, type: AuthType.API_KEY },
|
||||
{ debug: debugMode.value }
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Current authentication type being used
|
||||
*/
|
||||
const authType = computed(() => {
|
||||
if (apiVersion.value === 'v1') {
|
||||
return 'API Key';
|
||||
}
|
||||
|
||||
if (tournamentScope.value === ScopeType.APPLICATION) {
|
||||
return isClientCredsAuthenticated.value ? 'Client Credentials' : 'None';
|
||||
}
|
||||
|
||||
if (isOAuthAuthenticated.value) return 'OAuth';
|
||||
if (isKeyStored.value) return 'API Key';
|
||||
return 'None';
|
||||
});
|
||||
|
||||
/**
|
||||
* Switch API version
|
||||
*/
|
||||
function switchVersion(version) {
|
||||
if (version !== 'v1' && version !== 'v2.1') {
|
||||
throw new Error('Invalid API version. Must be "v1" or "v2.1"');
|
||||
}
|
||||
apiVersion.value = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tournament scope (v2.1 only)
|
||||
*/
|
||||
function setScope(scope) {
|
||||
if (scope !== ScopeType.USER && scope !== ScopeType.APPLICATION) {
|
||||
throw new Error('Invalid scope type');
|
||||
}
|
||||
tournamentScope.value = scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle debug mode
|
||||
*/
|
||||
function setDebugMode(enabled) {
|
||||
debugMode.value = enabled;
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
apiVersion,
|
||||
tournamentScope,
|
||||
debugMode,
|
||||
apiKey,
|
||||
maskedApiKey,
|
||||
client,
|
||||
authType,
|
||||
isOAuthAuthenticated,
|
||||
isClientCredsAuthenticated,
|
||||
|
||||
// Methods
|
||||
switchVersion,
|
||||
setScope,
|
||||
setDebugMode,
|
||||
|
||||
// Constants
|
||||
ScopeType,
|
||||
AuthType
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Challonge Client Credentials Composable (SERVER-SIDE)
|
||||
*
|
||||
* No-split-brain: client credentials and tokens are stored on the backend
|
||||
* per-session (SID cookie) and never returned to the browser.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
function secondsUntil(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
}
|
||||
|
||||
export function useChallongeClientCredentials() {
|
||||
const method = computed(() => status.value?.methods?.client_credentials);
|
||||
|
||||
const hasCredentials = computed(() => {
|
||||
return !!method.value?.stored;
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!method.value?.connected;
|
||||
});
|
||||
|
||||
const maskedClientId = computed(() => {
|
||||
if (!hasCredentials.value) return '';
|
||||
return 'stored on server';
|
||||
});
|
||||
|
||||
const tokenInfo = computed(() => {
|
||||
const expiresAt = method.value?.expires_at;
|
||||
return {
|
||||
expiresAt: expiresAt || null,
|
||||
expiresIn: secondsUntil(expiresAt)
|
||||
};
|
||||
});
|
||||
|
||||
async function refreshStatus() {
|
||||
status.value = await apiClient.get('/oauth/challonge/status');
|
||||
return status.value;
|
||||
}
|
||||
|
||||
async function saveCredentials(clientId, clientSecret, scope) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials',
|
||||
{
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to save credentials';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(scope = 'application:manage') {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials',
|
||||
{ scope }
|
||||
);
|
||||
return true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(scope = 'application:manage') {
|
||||
return authenticate(scope);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials/logout',
|
||||
{}
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Logout failed';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCredentials() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
status.value = await apiClient.post(
|
||||
'/oauth/challonge/client-credentials/clear',
|
||||
{}
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Failed to clear credentials';
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus().catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
hasCredentials,
|
||||
maskedClientId,
|
||||
isAuthenticated,
|
||||
loading: computed(() => loading.value),
|
||||
error: computed({
|
||||
get: () => error.value,
|
||||
set: v => {
|
||||
error.value = v || '';
|
||||
}
|
||||
}),
|
||||
tokenInfo,
|
||||
saveCredentials,
|
||||
clearCredentials,
|
||||
authenticate,
|
||||
refresh,
|
||||
logout,
|
||||
refreshStatus,
|
||||
status: computed(() => status.value)
|
||||
};
|
||||
}
|
||||
@@ -11,45 +11,71 @@
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCsrfCookie() {
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf) return;
|
||||
try {
|
||||
await apiClient.get('/session/csrf', { deduplicate: false });
|
||||
} catch {
|
||||
// Let the POST surface the failure.
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'challonge_oauth_tokens';
|
||||
const CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID;
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_CHALLONGE_REDIRECT_URI ||
|
||||
`${window.location.origin}/oauth/callback`;
|
||||
|
||||
// Shared state across all instances
|
||||
const tokens = ref(null);
|
||||
const status = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Load tokens from localStorage on module initialization
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
tokens.value = JSON.parse(stored);
|
||||
|
||||
// Check if token is expired
|
||||
if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) {
|
||||
console.log('🔄 Token expired, will need to refresh');
|
||||
}
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const data = await apiClient.get('/oauth/challonge/status', {
|
||||
deduplicate: false
|
||||
});
|
||||
if (data) status.value = data;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load OAuth tokens:', err);
|
||||
}
|
||||
|
||||
export function useChallongeOAuth() {
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!tokens.value?.access_token;
|
||||
return !!status.value?.methods?.user_oauth?.connected;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!tokens.value?.expires_at) return false;
|
||||
return Date.now() >= tokens.value.expires_at;
|
||||
const expiresAt = status.value?.methods?.user_oauth?.expires_at;
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt;
|
||||
});
|
||||
|
||||
const expiresIn = computed(() => {
|
||||
const expiresAt = status.value?.methods?.user_oauth?.expires_at;
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
return tokens.value?.access_token || null;
|
||||
// No-split-brain: token never available in browser
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -59,13 +85,18 @@ export function useChallongeOAuth() {
|
||||
* @returns {Object} Object with authUrl and state
|
||||
*/
|
||||
function getAuthorizationUrl(
|
||||
scope = 'tournaments:read tournaments:write',
|
||||
scopeOrOptions = 'tournaments:read tournaments:write',
|
||||
state = null
|
||||
) {
|
||||
if (!CLIENT_ID) {
|
||||
throw new Error('VITE_CHALLONGE_CLIENT_ID not configured');
|
||||
}
|
||||
|
||||
const scope =
|
||||
typeof scopeOrOptions === 'string'
|
||||
? scopeOrOptions
|
||||
: scopeOrOptions?.scope || 'tournaments:read tournaments:write';
|
||||
|
||||
// Generate state if not provided
|
||||
const oauthState = state || generateState();
|
||||
|
||||
@@ -94,6 +125,10 @@ export function useChallongeOAuth() {
|
||||
|
||||
// Store state for CSRF protection
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
sessionStorage.setItem('oauth_provider', 'challonge');
|
||||
if (typeof scope === 'object' && scope?.return_to) {
|
||||
sessionStorage.setItem('oauth_return_to', scope.return_to);
|
||||
}
|
||||
|
||||
console.log('🔐 Starting OAuth flow with state:', state);
|
||||
|
||||
@@ -130,44 +165,14 @@ export function useChallongeOAuth() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token exchange failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
await ensureCsrfCookie();
|
||||
const data = await apiClient.post('/oauth/challonge/exchange', { code });
|
||||
status.value = data;
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
|
||||
console.log('✅ OAuth authentication successful');
|
||||
return tokens.value;
|
||||
return status.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token exchange error:', err);
|
||||
@@ -181,53 +186,14 @@ export function useChallongeOAuth() {
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshToken() {
|
||||
if (!tokens.value?.refresh_token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: tokens.value.refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token refresh failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || tokens.value.refresh_token, // Keep old if not provided
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
refreshed_at: Date.now()
|
||||
};
|
||||
|
||||
// Store updated tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
status.value = await apiClient.post('/oauth/challonge/refresh', {});
|
||||
|
||||
console.log('✅ Token refreshed successfully');
|
||||
return tokens.value;
|
||||
return status.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token refresh error:', err);
|
||||
@@ -244,30 +210,20 @@ export function useChallongeOAuth() {
|
||||
* Get valid access token (refreshes if expired)
|
||||
*/
|
||||
async function getValidToken() {
|
||||
if (!tokens.value) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// If token is expired or about to expire (within 5 minutes), refresh it
|
||||
const expiresIn = tokens.value.expires_at - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (expiresIn < fiveMinutes) {
|
||||
console.log('🔄 Token expired or expiring soon, refreshing...');
|
||||
await refreshToken();
|
||||
}
|
||||
|
||||
return tokens.value.access_token;
|
||||
throw new Error(
|
||||
'No-split-brain: Challonge OAuth token is not accessible in the browser'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear tokens
|
||||
*/
|
||||
function logout() {
|
||||
tokens.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
status.value = null;
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
console.log('👋 Logged out');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
apiClient.post('/oauth/challonge/disconnect', {}).catch(() => {});
|
||||
console.log('👋 Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,9 +239,10 @@ export function useChallongeOAuth() {
|
||||
|
||||
return {
|
||||
// State
|
||||
tokens: computed(() => tokens.value),
|
||||
tokens: computed(() => status.value),
|
||||
isAuthenticated,
|
||||
isExpired,
|
||||
expiresIn,
|
||||
accessToken,
|
||||
loading: computed(() => loading.value),
|
||||
error: computed(() => error.value),
|
||||
@@ -299,3 +256,8 @@ export function useChallongeOAuth() {
|
||||
getAuthorizationUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget initial status load
|
||||
if (!status.value && !loading.value) {
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Challonge Tests Composable
|
||||
*
|
||||
* Manages tournament testing operations including:
|
||||
* - Loading tournament lists with pagination
|
||||
* - Tournament detail fetching
|
||||
* - Search/filtering
|
||||
* - Helper utilities for tournament data access
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const {
|
||||
* tournaments,
|
||||
* loading,
|
||||
* error,
|
||||
* testListTournaments,
|
||||
* loadMoreTournaments,
|
||||
* toggleTournamentDetails
|
||||
* } = useChallongeTests(client, apiVersion, tournamentScope);
|
||||
*
|
||||
* await testListTournaments();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAsyncState } from './useAsyncState.js';
|
||||
import { queryAllTournaments } from '../utilities/tournament-query.js';
|
||||
|
||||
export function useChallongeTests(client, apiVersion, tournamentScope) {
|
||||
// Async state management
|
||||
const tournamentListState = useAsyncState();
|
||||
const loadMoreState = useAsyncState();
|
||||
const tournamentDetailsState = useAsyncState();
|
||||
|
||||
// Destructure for easier access
|
||||
const { data: tournaments, loading, error } = tournamentListState;
|
||||
const { loading: loadingMore } = loadMoreState;
|
||||
|
||||
// Search and filter
|
||||
const searchQuery = ref('');
|
||||
const expandedTournamentId = ref(null);
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1);
|
||||
const perPage = ref(100);
|
||||
const totalTournaments = ref(0);
|
||||
const hasNextPage = ref(false);
|
||||
|
||||
/**
|
||||
* Pagination info string
|
||||
*/
|
||||
const paginationInfo = computed(() => {
|
||||
if (!tournaments.value) return '';
|
||||
const start = (currentPage.value - 1) * perPage.value + 1;
|
||||
const end = Math.min(
|
||||
start + tournaments.value.length - 1,
|
||||
totalTournaments.value || tournaments.value.length
|
||||
);
|
||||
const total = totalTournaments.value || tournaments.value.length;
|
||||
return `Showing ${start}-${end} of ${total}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Filtered tournaments based on search query
|
||||
*/
|
||||
const filteredTournaments = computed(() => {
|
||||
if (!tournaments.value) return null;
|
||||
if (!searchQuery.value.trim()) return tournaments.value;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return tournaments.value.filter(t => {
|
||||
const name = getTournamentName(t).toLowerCase();
|
||||
return name.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tournament details from async state
|
||||
*/
|
||||
const tournamentDetails = computed(() => tournamentDetailsState.data.value);
|
||||
|
||||
/**
|
||||
* Helper to get tournament name (handles both v1 and v2.1 structures)
|
||||
*/
|
||||
function getTournamentName(tournament) {
|
||||
return tournament.tournament?.name || tournament.name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get tournament ID
|
||||
*/
|
||||
function getTournamentId(tournament) {
|
||||
return tournament.tournament?.id || tournament.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get tournament property
|
||||
*/
|
||||
function getTournamentProp(tournament, prop) {
|
||||
return tournament.tournament?.[prop] || tournament[prop];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test listing tournaments with pagination support
|
||||
*/
|
||||
async function testListTournaments(resetPagination = true) {
|
||||
if (!client.value) {
|
||||
tournamentListState.error.value =
|
||||
'No Challonge client available. Configure API key/OAuth/client credentials, and ensure the selected API version + scope is supported.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (resetPagination) {
|
||||
currentPage.value = 1;
|
||||
searchQuery.value = '';
|
||||
expandedTournamentId.value = null;
|
||||
tournamentDetailsState.reset();
|
||||
}
|
||||
|
||||
await tournamentListState.execute(async () => {
|
||||
if (apiVersion.value === 'v1') {
|
||||
// v1 doesn't support pagination
|
||||
const result = await client.value.tournaments.list();
|
||||
totalTournaments.value = result.length;
|
||||
hasNextPage.value = false;
|
||||
return result;
|
||||
} else {
|
||||
// v2.1 - Query all tournament states in parallel
|
||||
const result = await queryAllTournaments(client.value, {
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
scopeType: tournamentScope.value
|
||||
});
|
||||
|
||||
totalTournaments.value = result.length;
|
||||
hasNextPage.value = result.length >= perPage.value;
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more tournaments (pagination)
|
||||
*/
|
||||
async function loadMoreTournaments() {
|
||||
if (apiVersion.value === 'v1') return; // v1 doesn't support pagination
|
||||
if (!client.value) return;
|
||||
|
||||
currentPage.value++;
|
||||
|
||||
const result = await loadMoreState.execute(async () => {
|
||||
const newResults = await queryAllTournaments(client.value, {
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
scopeType: tournamentScope.value
|
||||
});
|
||||
|
||||
hasNextPage.value = newResults.length === perPage.value;
|
||||
return newResults;
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Append new results to existing tournaments
|
||||
tournaments.value = [...tournaments.value, ...result];
|
||||
} else {
|
||||
// Revert page increment on error
|
||||
currentPage.value--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change results per page
|
||||
*/
|
||||
async function changePerPage(newLimit) {
|
||||
perPage.value = newLimit;
|
||||
await testListTournaments(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle tournament details view
|
||||
*/
|
||||
async function toggleTournamentDetails(tournamentId) {
|
||||
if (!client.value) return;
|
||||
|
||||
if (expandedTournamentId.value === tournamentId) {
|
||||
expandedTournamentId.value = null;
|
||||
tournamentDetailsState.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
expandedTournamentId.value = tournamentId;
|
||||
|
||||
await tournamentDetailsState.execute(async () => {
|
||||
if (apiVersion.value === 'v1') {
|
||||
return await client.value.tournaments.get(tournamentId, {
|
||||
includeParticipants: true,
|
||||
includeMatches: true
|
||||
});
|
||||
} else {
|
||||
// v2.1 get tournament
|
||||
return await client.value.tournaments.get(tournamentId);
|
||||
}
|
||||
});
|
||||
|
||||
// Reset expanded state if there was an error
|
||||
if (tournamentDetailsState.error.value) {
|
||||
expandedTournamentId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all test state
|
||||
*/
|
||||
function resetState() {
|
||||
tournamentListState.reset();
|
||||
loadMoreState.reset();
|
||||
tournamentDetailsState.reset();
|
||||
searchQuery.value = '';
|
||||
expandedTournamentId.value = null;
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string
|
||||
*/
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tournaments,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
searchQuery,
|
||||
expandedTournamentId,
|
||||
currentPage,
|
||||
perPage,
|
||||
totalTournaments,
|
||||
hasNextPage,
|
||||
tournamentDetails,
|
||||
tournamentDetailsState, // Expose for direct access to loading/error
|
||||
|
||||
// Computed
|
||||
paginationInfo,
|
||||
filteredTournaments,
|
||||
|
||||
// Methods
|
||||
testListTournaments,
|
||||
loadMoreTournaments,
|
||||
changePerPage,
|
||||
toggleTournamentDetails,
|
||||
resetState,
|
||||
|
||||
// Helpers
|
||||
getTournamentName,
|
||||
getTournamentId,
|
||||
getTournamentProp,
|
||||
formatDate
|
||||
};
|
||||
}
|
||||
65
code/websites/pokedex.online/src/composables/useClipboard.js
Normal file
65
code/websites/pokedex.online/src/composables/useClipboard.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Clipboard Composable
|
||||
* Handles clipboard operations with feedback
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Clipboard operations composable
|
||||
* @returns {Object} Clipboard functions and state
|
||||
*/
|
||||
export function useClipboard() {
|
||||
const copied = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
const copyToClipboard = async text => {
|
||||
error.value = null;
|
||||
copied.value = false;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Read text from clipboard
|
||||
* @returns {Promise<string|null>} Clipboard text or null
|
||||
*/
|
||||
const readFromClipboard = async () => {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return text;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Failed to read from clipboard:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
copied,
|
||||
error,
|
||||
copyToClipboard,
|
||||
readFromClipboard
|
||||
};
|
||||
}
|
||||
115
code/websites/pokedex.online/src/composables/useDiscordOAuth.js
Normal file
115
code/websites/pokedex.online/src/composables/useDiscordOAuth.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Discord OAuth Composable
|
||||
*
|
||||
* Thin wrapper around useOAuth for Discord-specific flows
|
||||
* Handles Discord user profile fetching and username access
|
||||
*
|
||||
* Usage:
|
||||
* const discord = useDiscordOAuth();
|
||||
* discord.login();
|
||||
* // ... OAuth flow ...
|
||||
* const username = discord.discordUsername;
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { useOAuth } from './useOAuth.js';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
// Shared Discord user profile data
|
||||
const discordUser = ref(null);
|
||||
|
||||
export function useDiscordOAuth() {
|
||||
const oauth = useOAuth('discord');
|
||||
|
||||
const hasDiscordAuth = computed(() => oauth.isAuthenticated.value);
|
||||
|
||||
const discordUsername = computed(() => {
|
||||
return discordUser.value?.username || null;
|
||||
});
|
||||
|
||||
const discordId = computed(() => {
|
||||
return discordUser.value?.id || null;
|
||||
});
|
||||
|
||||
const discordTag = computed(() => {
|
||||
if (!discordUser.value) return null;
|
||||
// Format: username#discriminator or just username (newer Discord)
|
||||
return discordUser.value.discriminator
|
||||
? `${discordUser.value.username}#${discordUser.value.discriminator}`
|
||||
: discordUser.value.username;
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch Discord user profile from backend
|
||||
* Backend will use the stored Discord token to fetch from Discord API
|
||||
*
|
||||
* @returns {Promise<Object>} Discord user profile
|
||||
* @throws {Error} If fetch fails
|
||||
*/
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
const data = await apiClient.get('/discord/profile');
|
||||
discordUser.value = data.user;
|
||||
|
||||
console.log(`✅ Loaded Discord profile: ${data.user.username}`);
|
||||
return data.user;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Discord profile:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Discord
|
||||
* Uses identify scope only for minimal permissions
|
||||
*
|
||||
* @param {Object} options - Optional options (return_to, etc.)
|
||||
*/
|
||||
function login(options = {}) {
|
||||
oauth.login({
|
||||
...options,
|
||||
scope: 'identify'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout from Discord
|
||||
*/
|
||||
function logout() {
|
||||
oauth.logout();
|
||||
discordUser.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is allowed to access developer tools
|
||||
* Checks permissions returned from backend during OAuth
|
||||
*
|
||||
* @returns {boolean} True if user has developer access
|
||||
*/
|
||||
function hasDevAccess() {
|
||||
// No-split-brain: permissions are not surfaced via OAuth token exchange anymore.
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
hasDiscordAuth,
|
||||
discordUser: computed(() => discordUser.value),
|
||||
discordUsername,
|
||||
discordId,
|
||||
discordTag,
|
||||
isExpired: oauth.isExpired,
|
||||
expiresIn: oauth.expiresIn,
|
||||
loading: oauth.loading,
|
||||
error: oauth.error,
|
||||
|
||||
// Methods
|
||||
login,
|
||||
logout,
|
||||
exchangeCode: oauth.exchangeCode,
|
||||
refreshToken: oauth.refreshToken,
|
||||
getValidToken: oauth.getValidToken,
|
||||
fetchUserProfile,
|
||||
hasDevAccess
|
||||
};
|
||||
}
|
||||
189
code/websites/pokedex.online/src/composables/useFeatureFlags.js
Normal file
189
code/websites/pokedex.online/src/composables/useFeatureFlags.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Feature Flags Composable
|
||||
*
|
||||
* Manages runtime feature flag state with support for:
|
||||
* - Local overrides (developer mode)
|
||||
* - Permission-based flags (requires auth)
|
||||
* - Backend feature flag queries (future)
|
||||
*
|
||||
* Usage:
|
||||
* ```javascript
|
||||
* const { isEnabled, toggle, getFlags } = useFeatureFlags();
|
||||
* if (isEnabled('experimental-search')) { ... }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ref, computed, readonly } from 'vue';
|
||||
import { useAuth } from './useAuth.js';
|
||||
import {
|
||||
FEATURE_FLAGS,
|
||||
getFlag,
|
||||
getFlagPermission
|
||||
} from '../config/feature-flags.js';
|
||||
|
||||
// Local storage key for overrides
|
||||
const LOCAL_OVERRIDES_KEY = 'feature_flag_overrides';
|
||||
|
||||
/**
|
||||
* Load flag overrides from localStorage
|
||||
* @returns {Object} Overrides object
|
||||
*/
|
||||
function loadLocalOverrides() {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCAL_OVERRIDES_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save flag overrides to localStorage
|
||||
* @param {Object} overrides - Overrides to save
|
||||
*/
|
||||
function saveLocalOverrides(overrides) {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
||||
} catch {
|
||||
console.warn('Failed to save feature flag overrides');
|
||||
}
|
||||
}
|
||||
|
||||
// Shared state
|
||||
const localOverrides = ref(loadLocalOverrides());
|
||||
const backendFlags = ref({});
|
||||
|
||||
/**
|
||||
* useFeatureFlags Composable
|
||||
*
|
||||
* @returns {Object} Feature flags interface
|
||||
* - isEnabled(flagName) - Check if flag is enabled
|
||||
* - toggle(flagName) - Toggle flag override in dev mode
|
||||
* - reset(flagName) - Clear override for specific flag
|
||||
* - resetAll() - Clear all overrides
|
||||
* - getFlags() - Get all flags with status
|
||||
* - setBackendFlags(flags) - Set flags from backend response
|
||||
*/
|
||||
export function useFeatureFlags() {
|
||||
const { user, hasPermission } = useAuth();
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled
|
||||
* Checks in order: local override, permission requirement, default value
|
||||
*/
|
||||
const isEnabled = computed(() => {
|
||||
return flagName => {
|
||||
// Check local override first
|
||||
if (flagName in localOverrides.value) {
|
||||
return localOverrides.value[flagName];
|
||||
}
|
||||
|
||||
// Get flag definition
|
||||
const flag = getFlag(flagName);
|
||||
if (!flag) {
|
||||
console.warn(`Feature flag not found: ${flagName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check permission requirement
|
||||
if (flag.requiredPermission && !hasPermission(flag.requiredPermission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check backend flag (if available)
|
||||
if (flagName in backendFlags.value) {
|
||||
return backendFlags.value[flagName];
|
||||
}
|
||||
|
||||
// Use default
|
||||
return flag.enabled;
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle a flag override
|
||||
* Works in development and production for developer users
|
||||
*/
|
||||
const toggle = flagName => {
|
||||
const current =
|
||||
localOverrides.value[flagName] !== undefined
|
||||
? localOverrides.value[flagName]
|
||||
: (getFlag(flagName)?.enabled ?? false);
|
||||
|
||||
localOverrides.value[flagName] = !current;
|
||||
saveLocalOverrides(localOverrides.value);
|
||||
|
||||
return !current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset a specific flag override
|
||||
*/
|
||||
const reset = flagName => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
console.warn('Feature flag overrides only available in development mode');
|
||||
return;
|
||||
}
|
||||
|
||||
delete localOverrides.value[flagName];
|
||||
saveLocalOverrides(localOverrides.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset all flag overrides
|
||||
*/
|
||||
const resetAll = () => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
console.warn('Feature flag overrides only available in development mode');
|
||||
return;
|
||||
}
|
||||
|
||||
localOverrides.value = {};
|
||||
saveLocalOverrides({});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all flags with their current status
|
||||
*/
|
||||
const getFlags = computed(() => {
|
||||
return () => {
|
||||
return Object.values(FEATURE_FLAGS).map(flag => ({
|
||||
...flag,
|
||||
isEnabled: isEnabled.value(flag.name),
|
||||
hasOverride: flag.name in localOverrides.value,
|
||||
override: localOverrides.value[flag.name],
|
||||
requiresPermission: !!flag.requiredPermission,
|
||||
hasPermission:
|
||||
!flag.requiredPermission || hasPermission(flag.requiredPermission)
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Set flags from backend response
|
||||
* Called after fetching flags from backend
|
||||
*/
|
||||
const setBackendFlags = flags => {
|
||||
backendFlags.value = flags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch flags from backend (requires admin permission)
|
||||
* Future: Implement backend endpoint
|
||||
*/
|
||||
const fetchFromBackend = async () => {
|
||||
// TODO: Implement backend endpoint
|
||||
// const response = await apiClient.get('/api/feature-flags');
|
||||
// setBackendFlags(response.flags);
|
||||
};
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
toggle,
|
||||
reset,
|
||||
resetAll,
|
||||
getFlags,
|
||||
setBackendFlags,
|
||||
fetchFromBackend
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { perfMonitor } from '@/utilities/performance-utils.js';
|
||||
import {
|
||||
extractJsonPaths,
|
||||
extractJsonPathsLazy
|
||||
} from '@/utilities/json-utils.js';
|
||||
import { useLocalStorage } from '@/composables/useLocalStorage.js';
|
||||
|
||||
/**
|
||||
* useGamemasterFiles - Composable for managing gamemaster file operations
|
||||
*
|
||||
* Handles:
|
||||
* - File selection and loading
|
||||
* - File parsing and content management
|
||||
* - JSON path extraction for filtering
|
||||
* - Display line management (with pagination for large files)
|
||||
* - File size validation
|
||||
* - Preference persistence
|
||||
*
|
||||
* @param {Object} client - GamemasterClient instance
|
||||
* @returns {Object} Files composable API
|
||||
*/
|
||||
export function useGamemasterFiles(client) {
|
||||
// File state
|
||||
const selectedFile = ref('');
|
||||
const fileContent = ref('');
|
||||
const fileLines = ref([]);
|
||||
const displayLines = ref([]);
|
||||
const jsonPaths = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const fileError = ref(null);
|
||||
|
||||
// Status state
|
||||
const status = ref({});
|
||||
|
||||
// Preferences (persisted)
|
||||
const preferences = useLocalStorage('gamemaster-explorer-prefs', {
|
||||
darkMode: false,
|
||||
lineWrap: false,
|
||||
showLineNumbers: true,
|
||||
performanceMode: 'auto',
|
||||
lastFile: ''
|
||||
});
|
||||
|
||||
// Display configuration
|
||||
const LINES_TO_DISPLAY = 10000; // Initially display 10K lines
|
||||
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Get available files from status
|
||||
*/
|
||||
const availableFiles = computed(() => status.value.available || []);
|
||||
|
||||
/**
|
||||
* Get unique file list, sorted by type
|
||||
*/
|
||||
const uniqueFiles = computed(() => {
|
||||
const seen = new Set();
|
||||
const order = { pokemon: 1, allForms: 2, moves: 3, raw: 4 };
|
||||
|
||||
return availableFiles.value
|
||||
.filter(file => {
|
||||
const type = getFileType(file.filename);
|
||||
if (seen.has(type)) return false;
|
||||
seen.add(type);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const typeA = getFileType(a.filename);
|
||||
const typeB = getFileType(b.filename);
|
||||
return (order[typeA] ?? 999) - (order[typeB] ?? 999);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if there are any files available
|
||||
*/
|
||||
const hasFiles = computed(() => availableFiles.value.length > 0);
|
||||
|
||||
/**
|
||||
* Check if currently displayed file exceeds line limit
|
||||
*/
|
||||
const fileTooLarge = computed(() => {
|
||||
return fileLines.value.length > LINES_TO_DISPLAY;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get file size in human-readable format
|
||||
*/
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file type from filename
|
||||
*/
|
||||
function getFileType(filename) {
|
||||
if (filename.includes('AllForms') || filename.includes('allForms'))
|
||||
return 'allForms';
|
||||
if (filename.includes('moves')) return 'moves';
|
||||
if (filename.includes('pokemon')) return 'pokemon';
|
||||
if (filename.includes('raw')) return 'raw';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format filename for display
|
||||
*/
|
||||
function formatFileName(filename) {
|
||||
if (filename.includes('AllForms') || filename.includes('allForms'))
|
||||
return 'Pokemon All Forms';
|
||||
if (filename.includes('moves')) return 'Moves';
|
||||
if (filename.includes('pokemon')) return 'Pokemon';
|
||||
if (filename.includes('raw')) return 'Raw Gamemaster';
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load server status (file list)
|
||||
*/
|
||||
async function loadStatus() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
fileError.value = null;
|
||||
|
||||
status.value = await perfMonitor('Load Status', async () => {
|
||||
return await client.getStatus();
|
||||
});
|
||||
|
||||
// Auto-load last file if set
|
||||
if (preferences.value.lastFile && !selectedFile.value) {
|
||||
selectedFile.value = preferences.value.lastFile;
|
||||
await loadFile();
|
||||
}
|
||||
} catch (err) {
|
||||
fileError.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load selected file from server
|
||||
*/
|
||||
async function loadFile() {
|
||||
if (!selectedFile.value) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
fileError.value = null;
|
||||
|
||||
// Validate raw file size
|
||||
if (selectedFile.value === 'raw') {
|
||||
const rawFile = status.value.available?.find(f =>
|
||||
f.filename.includes('raw')
|
||||
);
|
||||
if (rawFile && rawFile.size > MAX_RAW_FILE_SIZE) {
|
||||
fileError.value =
|
||||
'⚠️ Raw gamemaster file is very large (' +
|
||||
formatSize(rawFile.size) +
|
||||
'). It may be slow to load. Try a specific file type instead (Pokemon, All Forms, or Moves).';
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch file content based on type
|
||||
let data;
|
||||
switch (selectedFile.value) {
|
||||
case 'pokemon':
|
||||
data = await perfMonitor('Load Pokemon', () => client.getPokemon());
|
||||
break;
|
||||
case 'allForms':
|
||||
data = await perfMonitor('Load All Forms', () =>
|
||||
client.getAllForms()
|
||||
);
|
||||
break;
|
||||
case 'moves':
|
||||
data = await perfMonitor('Load Moves', () => client.getMoves());
|
||||
break;
|
||||
case 'raw':
|
||||
data = await perfMonitor('Load Raw', () => client.getRaw());
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown file type');
|
||||
}
|
||||
|
||||
// Process file content
|
||||
fileContent.value = JSON.stringify(data, null, 2);
|
||||
fileLines.value = fileContent.value.split('\n');
|
||||
|
||||
// Display limited lines initially (10K) but keep full content for searching
|
||||
const linesToDisplay = fileLines.value.slice(0, LINES_TO_DISPLAY);
|
||||
displayLines.value = linesToDisplay.map((content, index) => ({
|
||||
lineNumber: index + 1,
|
||||
content,
|
||||
hasMatch: false
|
||||
}));
|
||||
|
||||
// Extract JSON paths for filtering (in background)
|
||||
jsonPaths.value = extractJsonPaths(data);
|
||||
extractJsonPathsLazy(data, paths => {
|
||||
jsonPaths.value = paths;
|
||||
});
|
||||
|
||||
// Save preference
|
||||
preferences.value.lastFile = selectedFile.value;
|
||||
|
||||
isLoading.value = false;
|
||||
} catch (err) {
|
||||
fileError.value = err.message;
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear file selection and related state
|
||||
*/
|
||||
function clearFileSelection() {
|
||||
selectedFile.value = '';
|
||||
fileContent.value = '';
|
||||
fileLines.value = [];
|
||||
displayLines.value = [];
|
||||
jsonPaths.value = [];
|
||||
fileError.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update displayed lines (for pagination/virtual scrolling)
|
||||
*/
|
||||
function updateDisplayLines(startIndex = 0, endIndex = LINES_TO_DISPLAY) {
|
||||
const linesToDisplay = fileLines.value.slice(startIndex, endIndex);
|
||||
displayLines.value = linesToDisplay.map((content, index) => ({
|
||||
lineNumber: startIndex + index + 1,
|
||||
content,
|
||||
hasMatch: false
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand display lines to include a specific line number
|
||||
*/
|
||||
function expandDisplayLinesToInclude(lineNumber) {
|
||||
if (lineNumber <= displayLines.value.length) {
|
||||
return; // Already visible
|
||||
}
|
||||
|
||||
const newEndIndex = Math.min(lineNumber + 1000, fileLines.value.length);
|
||||
updateDisplayLines(0, newEndIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for file selection changes
|
||||
*/
|
||||
watch(selectedFile, async newFile => {
|
||||
if (newFile) {
|
||||
await loadFile();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedFile,
|
||||
fileContent,
|
||||
fileLines,
|
||||
displayLines,
|
||||
jsonPaths,
|
||||
isLoading,
|
||||
fileError,
|
||||
status,
|
||||
preferences,
|
||||
|
||||
// Computed
|
||||
availableFiles,
|
||||
uniqueFiles,
|
||||
hasFiles,
|
||||
fileTooLarge,
|
||||
|
||||
// Constants
|
||||
LINES_TO_DISPLAY,
|
||||
MAX_RAW_FILE_SIZE,
|
||||
|
||||
// Methods
|
||||
loadStatus,
|
||||
loadFile,
|
||||
clearFileSelection,
|
||||
updateDisplayLines,
|
||||
expandDisplayLinesToInclude,
|
||||
formatSize,
|
||||
formatFileName,
|
||||
getFileType
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { debounce } from '@/utilities/performance-utils.js';
|
||||
import { useSearchHistory } from '@/composables/useLocalStorage.js';
|
||||
|
||||
/**
|
||||
* useGamemasterSearch - Composable for managing gamemaster file search operations
|
||||
*
|
||||
* Handles:
|
||||
* - Search query state management
|
||||
* - Search results tracking (line numbers with matches)
|
||||
* - Result navigation (next/prev)
|
||||
* - Search history
|
||||
* - Fuzzy matching and regex pattern support
|
||||
* - Web worker integration for large file searches
|
||||
*
|
||||
* @param {Ref<string>} fileLines - Full file content split into lines
|
||||
* @param {Ref<Array>} displayLines - Currently displayed lines with metadata
|
||||
* @returns {Object} Search composable API
|
||||
*/
|
||||
export function useGamemasterSearch(fileLines, displayLines) {
|
||||
// Search state
|
||||
const searchQuery = ref('');
|
||||
const searchResults = ref([]);
|
||||
const currentResultIndex = ref(0);
|
||||
const isSearching = ref(false);
|
||||
const searchError = ref(null);
|
||||
|
||||
// Search history
|
||||
const searchHistory = useSearchHistory();
|
||||
|
||||
// Web worker for large file searches
|
||||
let searchWorker = null;
|
||||
let searchWorkerRequestId = 0;
|
||||
|
||||
/**
|
||||
* Initialize Web Worker for search operations
|
||||
* Prevents blocking UI on large files
|
||||
*/
|
||||
function initSearchWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (searchWorker) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
searchWorker = new Worker('/workers/search-worker.js');
|
||||
|
||||
searchWorker.onmessage = event => {
|
||||
const { id, results, error } = event.data;
|
||||
|
||||
// Ignore stale results
|
||||
if (id !== searchWorkerRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
searchError.value = error;
|
||||
isSearching.value = false;
|
||||
} else {
|
||||
searchResults.value = results;
|
||||
currentResultIndex.value = 0;
|
||||
|
||||
// Update display lines with match indicators
|
||||
displayLines.value?.forEach(line => {
|
||||
line.hasMatch = results.includes(line.lineNumber - 1);
|
||||
});
|
||||
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
searchWorker.onerror = error => {
|
||||
console.error('Search worker error:', error);
|
||||
searchError.value = error.message;
|
||||
isSearching.value = false;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
searchError.value = error.message;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform search with debouncing to avoid excessive processing
|
||||
* Supports both simple text and regex patterns
|
||||
*/
|
||||
const performSearch = debounce(async () => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
clearSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
searchError.value = null;
|
||||
isSearching.value = true;
|
||||
|
||||
// Initialize worker if needed
|
||||
await initSearchWorker();
|
||||
|
||||
if (!searchWorker) {
|
||||
throw new Error('Search worker not available');
|
||||
}
|
||||
|
||||
// Send search to worker
|
||||
searchWorkerRequestId++;
|
||||
const searchTerm = searchQuery.value.toLowerCase();
|
||||
const plainLines = Array.from(fileLines.value);
|
||||
|
||||
searchWorker.postMessage({
|
||||
lines: plainLines,
|
||||
searchTerm: searchTerm,
|
||||
id: searchWorkerRequestId
|
||||
});
|
||||
|
||||
// Add to search history
|
||||
searchHistory.addToHistory(searchQuery.value);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
searchError.value = error.message;
|
||||
isSearching.value = false;
|
||||
|
||||
// Fallback to synchronous search for small files
|
||||
if (fileLines.value.length < 5000) {
|
||||
performSynchronousSearch();
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* Fallback synchronous search for small files
|
||||
* Used when Web Worker is unavailable
|
||||
*/
|
||||
function performSynchronousSearch() {
|
||||
const results = [];
|
||||
const searchTerm = searchQuery.value.toLowerCase();
|
||||
const escapedTerm = searchTerm.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`
|
||||
);
|
||||
|
||||
try {
|
||||
// Try regex pattern first
|
||||
const regex = new RegExp(escapedTerm, 'gi');
|
||||
fileLines.value.forEach((line, index) => {
|
||||
if (regex.test(line)) {
|
||||
results.push(index);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Fall back to simple text search
|
||||
fileLines.value.forEach((line, index) => {
|
||||
if (line.toLowerCase().includes(searchTerm)) {
|
||||
results.push(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchResults.value = results;
|
||||
currentResultIndex.value = 0;
|
||||
|
||||
// Update display lines with match indicators
|
||||
displayLines.value?.forEach(line => {
|
||||
line.hasMatch = results.includes(line.lineNumber - 1);
|
||||
});
|
||||
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute search when query changes
|
||||
*/
|
||||
const executeSearch = async query => {
|
||||
searchQuery.value = query;
|
||||
await performSearch();
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all search results
|
||||
*/
|
||||
function clearSearchResults() {
|
||||
searchResults.value = [];
|
||||
currentResultIndex.value = 0;
|
||||
displayLines.value?.forEach(line => {
|
||||
line.hasMatch = false;
|
||||
});
|
||||
searchError.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search query and results
|
||||
*/
|
||||
function clearSearch() {
|
||||
searchQuery.value = '';
|
||||
clearSearchResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next search result
|
||||
*/
|
||||
function goToNextResult() {
|
||||
if (searchResults.value.length === 0) return;
|
||||
currentResultIndex.value =
|
||||
(currentResultIndex.value + 1) % searchResults.value.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous search result
|
||||
*/
|
||||
function goToPrevResult() {
|
||||
if (searchResults.value.length === 0) return;
|
||||
currentResultIndex.value =
|
||||
(currentResultIndex.value - 1 + searchResults.value.length) %
|
||||
searchResults.value.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current result line number (1-based)
|
||||
*/
|
||||
const currentResultLineNumber = computed(() => {
|
||||
if (searchResults.value.length === 0) return null;
|
||||
return searchResults.value[currentResultIndex.value] + 1;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get result count display text
|
||||
*/
|
||||
const resultCountDisplay = computed(() => {
|
||||
if (searchResults.value.length === 0) return '0 results';
|
||||
return `${currentResultIndex.value + 1} / ${searchResults.value.length}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if search is active
|
||||
*/
|
||||
const hasSearchResults = computed(() => searchResults.value.length > 0);
|
||||
|
||||
/**
|
||||
* Get highlighted HTML for search term in text
|
||||
*/
|
||||
function getHighlightedContent(lineContent) {
|
||||
if (!searchQuery.value.trim()) return lineContent;
|
||||
|
||||
const searchTerm = searchQuery.value;
|
||||
const escapedTerm = searchTerm.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`
|
||||
);
|
||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||
|
||||
return lineContent.replaceAll(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filters and rerun search
|
||||
* Can support fuzzy matching and advanced filters in future
|
||||
*/
|
||||
function updateFilters(options = {}) {
|
||||
// Extensible for fuzzy matching, regex flags, etc.
|
||||
if (options.fuzzy !== undefined) {
|
||||
// Future: implement fuzzy matching
|
||||
}
|
||||
if (options.wholeWord !== undefined) {
|
||||
// Future: implement whole word matching
|
||||
}
|
||||
if (options.caseSensitive !== undefined) {
|
||||
// Future: implement case-sensitive matching
|
||||
}
|
||||
// Trigger search with updated filters
|
||||
performSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply search history item
|
||||
*/
|
||||
function applyHistoryItem(item) {
|
||||
searchQuery.value = item;
|
||||
performSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for manual searchQuery changes
|
||||
*/
|
||||
watch(searchQuery, () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
performSearch();
|
||||
} else {
|
||||
clearSearchResults();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
searchResults,
|
||||
currentResultIndex,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchHistory,
|
||||
|
||||
// Computed
|
||||
currentResultLineNumber,
|
||||
resultCountDisplay,
|
||||
hasSearchResults,
|
||||
|
||||
// Methods
|
||||
executeSearch,
|
||||
clearSearch,
|
||||
clearSearchResults,
|
||||
goToNextResult,
|
||||
goToPrevResult,
|
||||
updateFilters,
|
||||
applyHistoryItem,
|
||||
getHighlightedContent,
|
||||
performSearch
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* useGroupPrintLayout
|
||||
* --------------------------------------------------
|
||||
* Vue 3 composable for group sizing and
|
||||
* print-safe layout generation.
|
||||
*
|
||||
* Uses only Vue reactivity + vanilla JS.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {number} options.rowsPerPage
|
||||
* @param {number} options.headerRows
|
||||
* @param {number} options.spacerRows
|
||||
*/
|
||||
export function useGroupPrintLayout(options) {
|
||||
// -----------------------------
|
||||
// Reactive state
|
||||
// -----------------------------
|
||||
const players = ref([]);
|
||||
const groupIds = ref([]);
|
||||
const droppedPlayerIds = ref([]);
|
||||
|
||||
// -----------------------------
|
||||
// Internal helpers (pure JS)
|
||||
// -----------------------------
|
||||
function calculateGroupSizes(totalPlayers, ids) {
|
||||
const baseSize = Math.floor(totalPlayers / ids.length);
|
||||
const remainder = totalPlayers % ids.length;
|
||||
|
||||
return ids.map((id, index) => ({
|
||||
id,
|
||||
players: [],
|
||||
size: baseSize + (index < remainder ? 1 : 0)
|
||||
}));
|
||||
}
|
||||
|
||||
function assignPlayersToGroups(playerList, groups) {
|
||||
let index = 0;
|
||||
|
||||
groups.forEach(group => {
|
||||
for (let i = 0; i < group.size; i++) {
|
||||
if (index >= playerList.length) break;
|
||||
|
||||
const player = { ...playerList[index] };
|
||||
player.groupId = group.id;
|
||||
group.players.push(player);
|
||||
index++;
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function applyDrops(groups, dropIds) {
|
||||
if (!dropIds.length) return groups;
|
||||
|
||||
const dropSet = new Set(dropIds);
|
||||
groups.forEach(group => {
|
||||
group.players = group.players.filter(player => !dropSet.has(player.id));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function calculateGroupPrintRows(group) {
|
||||
return options.headerRows + group.players.length + options.spacerRows;
|
||||
}
|
||||
|
||||
function generatePrintLayout(groups) {
|
||||
const pages = [];
|
||||
let pageNumber = 1;
|
||||
|
||||
let currentPage = {
|
||||
pageNumber,
|
||||
groups: [],
|
||||
usedRows: 0
|
||||
};
|
||||
|
||||
groups.forEach(group => {
|
||||
const groupRows = calculateGroupPrintRows(group);
|
||||
|
||||
if (currentPage.usedRows + groupRows > options.rowsPerPage) {
|
||||
pages.push(currentPage);
|
||||
pageNumber++;
|
||||
|
||||
currentPage = {
|
||||
pageNumber,
|
||||
groups: [],
|
||||
usedRows: 0
|
||||
};
|
||||
}
|
||||
|
||||
currentPage.groups.push(group);
|
||||
currentPage.usedRows += groupRows;
|
||||
});
|
||||
|
||||
pages.push(currentPage);
|
||||
return pages;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Computed pipeline
|
||||
// -----------------------------
|
||||
const groupedData = computed(() => {
|
||||
if (!players.value.length || !groupIds.value.length) return [];
|
||||
|
||||
let groups = calculateGroupSizes(players.value.length, groupIds.value);
|
||||
|
||||
groups = assignPlayersToGroups(players.value, groups);
|
||||
groups = applyDrops(groups, droppedPlayerIds.value);
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const printPages = computed(() => {
|
||||
if (!groupedData.value.length) return [];
|
||||
return generatePrintLayout(groupedData.value);
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// Public API
|
||||
// -----------------------------
|
||||
function setPlayers(list) {
|
||||
players.value = Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
function setGroups(ids) {
|
||||
groupIds.value = Array.isArray(ids) ? ids : [];
|
||||
}
|
||||
|
||||
function dropPlayer(id) {
|
||||
if (!droppedPlayerIds.value.includes(id)) {
|
||||
droppedPlayerIds.value.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
function restorePlayer(id) {
|
||||
droppedPlayerIds.value = droppedPlayerIds.value.filter(pid => pid !== id);
|
||||
}
|
||||
|
||||
function resetDrops() {
|
||||
droppedPlayerIds.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
players,
|
||||
groupIds,
|
||||
droppedPlayerIds,
|
||||
|
||||
// derived
|
||||
groupedData,
|
||||
printPages,
|
||||
|
||||
// actions
|
||||
setPlayers,
|
||||
setGroups,
|
||||
dropPlayer,
|
||||
restorePlayer,
|
||||
resetDrops
|
||||
};
|
||||
}
|
||||
348
code/websites/pokedex.online/src/composables/useJsonFilter.js
Normal file
348
code/websites/pokedex.online/src/composables/useJsonFilter.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* useJsonFilter Composable
|
||||
*
|
||||
* Manages JSON path filtering and property-based data filtering.
|
||||
* Handles path extraction from JSON objects and filtering by property values.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { getValueByPath } from '../utilities/json-utils.js';
|
||||
|
||||
/**
|
||||
* Get value from object using dot notation path
|
||||
* @param {Object} obj - Object to get value from
|
||||
* @param {string} path - Dot notation path
|
||||
* @returns {any} Value at path
|
||||
*/
|
||||
function extractValueByPath(obj, path) {
|
||||
return getValueByPath(obj, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique paths from an object recursively
|
||||
* @param {Object} obj - Object to extract from
|
||||
* @param {string} prefix - Current path prefix
|
||||
* @param {Set} paths - Set to accumulate paths
|
||||
* @param {number} maxDepth - Maximum recursion depth
|
||||
* @param {number} currentDepth - Current depth
|
||||
*/
|
||||
function extractPathsRecursive(
|
||||
obj,
|
||||
prefix = '',
|
||||
paths = new Set(),
|
||||
maxDepth = 5,
|
||||
currentDepth = 0
|
||||
) {
|
||||
if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
paths.add(path);
|
||||
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
extractPathsRecursive(obj[key], path, paths, maxDepth, currentDepth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function useJsonFilter() {
|
||||
// State
|
||||
const filterProperty = ref('');
|
||||
const filterValue = ref('');
|
||||
const filterMode = ref('equals'); // equals, contains, regex
|
||||
const availablePaths = ref([]);
|
||||
const isFiltering = ref(false);
|
||||
const filterError = ref(null);
|
||||
|
||||
// Data to filter (referenced, not copied)
|
||||
let rawData = null;
|
||||
|
||||
/**
|
||||
* Initialize filter with raw data
|
||||
* @param {Array} data - Array of objects to filter
|
||||
* @param {number} sampleSize - Number of items to sample for path extraction
|
||||
*/
|
||||
function initializeFilter(data, sampleSize = 100) {
|
||||
rawData = data;
|
||||
extractPathsFromData(data, sampleSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract paths from data array
|
||||
* @param {Array} data - Data to extract from
|
||||
* @param {number} sampleSize - Sample size for extraction
|
||||
*/
|
||||
function extractPathsFromData(data, sampleSize = 100) {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
availablePaths.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = new Set();
|
||||
const sample = data.slice(0, Math.min(sampleSize, data.length));
|
||||
|
||||
sample.forEach(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
extractPathsRecursive(item, '', paths);
|
||||
}
|
||||
});
|
||||
|
||||
availablePaths.value = Array.from(paths)
|
||||
.sort()
|
||||
.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue extracting paths from remaining items (lazy)
|
||||
* @param {Array} data - Full data array
|
||||
* @param {number} startIndex - Where to start
|
||||
* @param {Function} callback - Callback with new paths
|
||||
* @param {number} chunkSize - Items per chunk
|
||||
*/
|
||||
function extractPathsLazy(data, startIndex = 100, callback, chunkSize = 100) {
|
||||
if (!Array.isArray(data) || startIndex >= data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPaths = new Set(availablePaths.value.map(p => p.path));
|
||||
|
||||
const processChunk = index => {
|
||||
const end = Math.min(index + chunkSize, data.length);
|
||||
const chunk = data.slice(index, end);
|
||||
const newPaths = new Set(existingPaths);
|
||||
|
||||
chunk.forEach(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
extractPathsRecursive(item, '', newPaths);
|
||||
}
|
||||
});
|
||||
|
||||
const addedPaths = Array.from(newPaths).filter(
|
||||
p => !existingPaths.has(p)
|
||||
);
|
||||
|
||||
if (addedPaths.length > 0) {
|
||||
addedPaths.forEach(p => existingPaths.add(p));
|
||||
availablePaths.value = Array.from(existingPaths)
|
||||
.sort()
|
||||
.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}));
|
||||
|
||||
if (callback) {
|
||||
callback(
|
||||
addedPaths.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (end < data.length) {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => processChunk(end));
|
||||
} else {
|
||||
setTimeout(() => processChunk(end), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => processChunk(startIndex));
|
||||
} else {
|
||||
setTimeout(() => processChunk(startIndex), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data by property value
|
||||
* @returns {Array} Filtered items
|
||||
*/
|
||||
const filteredData = computed(() => {
|
||||
if (!rawData || !Array.isArray(rawData)) {
|
||||
return rawData || [];
|
||||
}
|
||||
|
||||
// Return all data if no filter is set or if there's a filter error
|
||||
if (!filterProperty.value || filterError.value) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
return rawData.filter(item => {
|
||||
try {
|
||||
const value = extractValueByPath(item, filterProperty.value);
|
||||
return matchesFilter(value, filterValue.value, filterMode.value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if value matches filter criteria
|
||||
* @param {any} value - Value to check
|
||||
* @param {string} filterVal - Filter value
|
||||
* @param {string} mode - Filter mode (equals, contains, regex)
|
||||
* @returns {boolean} Whether value matches
|
||||
*/
|
||||
function matchesFilter(value, filterVal, mode) {
|
||||
if (!filterVal) return true;
|
||||
|
||||
const strValue = String(value).toLowerCase();
|
||||
const strFilter = String(filterVal).toLowerCase();
|
||||
|
||||
switch (mode) {
|
||||
case 'equals':
|
||||
return strValue === strFilter;
|
||||
case 'contains':
|
||||
return strValue.includes(strFilter);
|
||||
case 'regex':
|
||||
try {
|
||||
const regex = new RegExp(filterVal, 'i');
|
||||
return regex.test(strValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique values for a property
|
||||
* @param {string} property - Property path
|
||||
* @returns {Array} Sorted unique values
|
||||
*/
|
||||
function getUniqueValues(property) {
|
||||
if (!rawData || !Array.isArray(rawData) || !property) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = new Set();
|
||||
rawData.forEach(item => {
|
||||
try {
|
||||
const value = extractValueByPath(item, property);
|
||||
if (value !== undefined && value !== null) {
|
||||
values.add(String(value));
|
||||
}
|
||||
} catch {
|
||||
// Silently skip items where path doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
function clearFilters() {
|
||||
filterProperty.value = '';
|
||||
filterValue.value = '';
|
||||
filterMode.value = 'equals';
|
||||
filterError.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter from user input
|
||||
* @param {string} property - Property to filter by
|
||||
* @param {string} value - Value to filter
|
||||
* @param {string} mode - Filter mode
|
||||
*/
|
||||
function setFilter(property, value, mode = 'equals') {
|
||||
try {
|
||||
filterProperty.value = property;
|
||||
filterValue.value = value;
|
||||
filterMode.value = mode;
|
||||
filterError.value = null;
|
||||
|
||||
// Validate regex if mode is regex
|
||||
if (mode === 'regex') {
|
||||
new RegExp(value);
|
||||
}
|
||||
} catch (err) {
|
||||
filterError.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filter is active
|
||||
*/
|
||||
const hasActiveFilter = computed(() => {
|
||||
return filterProperty.value && (filterValue.value || filterMode.value);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get filter description for display
|
||||
*/
|
||||
const filterDescription = computed(() => {
|
||||
if (!hasActiveFilter.value) {
|
||||
return 'No filter applied';
|
||||
}
|
||||
|
||||
const property = filterProperty.value;
|
||||
const value = filterValue.value || '*';
|
||||
const mode = filterMode.value;
|
||||
|
||||
const modeLabel =
|
||||
{
|
||||
equals: '=',
|
||||
contains: '∋',
|
||||
regex: '~'
|
||||
}[mode] || mode;
|
||||
|
||||
return `${property} ${modeLabel} ${value}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get filter statistics
|
||||
*/
|
||||
const filterStats = computed(() => {
|
||||
const total = rawData?.length || 0;
|
||||
const filtered = filteredData.value.length;
|
||||
|
||||
return {
|
||||
total,
|
||||
filtered,
|
||||
matched: filtered,
|
||||
filtered: total - filtered,
|
||||
percentage: total > 0 ? Math.round((filtered / total) * 100) : 0
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
filterProperty,
|
||||
filterValue,
|
||||
filterMode,
|
||||
availablePaths,
|
||||
isFiltering,
|
||||
filterError,
|
||||
|
||||
// Computed
|
||||
filteredData,
|
||||
hasActiveFilter,
|
||||
filterDescription,
|
||||
filterStats,
|
||||
|
||||
// Methods
|
||||
initializeFilter,
|
||||
extractPathsFromData,
|
||||
extractPathsLazy,
|
||||
matchesFilter,
|
||||
getUniqueValues,
|
||||
clearFilters,
|
||||
setFilter
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Keyboard Shortcuts Composable
|
||||
* Manages keyboard event listeners and shortcuts
|
||||
*/
|
||||
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* Register keyboard shortcuts
|
||||
* @param {Object} shortcuts - Map of key combinations to handlers
|
||||
* @returns {Object} Control functions
|
||||
*
|
||||
* Example shortcuts object:
|
||||
* {
|
||||
* 'ctrl+f': () => focusSearch(),
|
||||
* 'ctrl+c': () => copySelected(),
|
||||
* 'escape': () => clearSelection()
|
||||
* }
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts = {}) {
|
||||
const handleKeyDown = event => {
|
||||
const key = event.key.toLowerCase();
|
||||
const ctrl = event.ctrlKey || event.metaKey; // Support both Ctrl and Cmd
|
||||
const shift = event.shiftKey;
|
||||
const alt = event.altKey;
|
||||
|
||||
// Build combination string
|
||||
let combination = '';
|
||||
if (ctrl) combination += 'ctrl+';
|
||||
if (shift) combination += 'shift+';
|
||||
if (alt) combination += 'alt+';
|
||||
combination += key;
|
||||
|
||||
// Also check without modifiers
|
||||
const simpleKey = key;
|
||||
|
||||
// Try to find and execute handler
|
||||
const handler = shortcuts[combination] || shortcuts[simpleKey];
|
||||
|
||||
if (handler) {
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
// Can add control functions here if needed
|
||||
};
|
||||
}
|
||||
206
code/websites/pokedex.online/src/composables/useLineSelection.js
Normal file
206
code/websites/pokedex.online/src/composables/useLineSelection.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useClipboard } from '@/composables/useClipboard.js';
|
||||
|
||||
/**
|
||||
* useLineSelection - Composable for managing line selection operations
|
||||
*
|
||||
* Handles:
|
||||
* - Single, range, and multi-line selection with Shift/Ctrl modifiers
|
||||
* - Copy selected/all lines to clipboard
|
||||
* - Export selected/all lines to JSON file
|
||||
* - URL sharing
|
||||
* - Selection state management
|
||||
*
|
||||
* @param {Ref<Set>} displayLines - Current displayed lines with metadata
|
||||
* @param {Ref<string>} fileContent - Full file content for export
|
||||
* @param {Ref<string>} selectedFile - Current file name for export naming
|
||||
* @returns {Object} Line selection composable API
|
||||
*/
|
||||
export function useLineSelection(displayLines, fileContent, selectedFile) {
|
||||
// Clipboard composable
|
||||
const clipboard = useClipboard();
|
||||
|
||||
// Selection state - use Set for O(1) lookups
|
||||
const selectedLines = ref(new Set());
|
||||
|
||||
/**
|
||||
* Check if any lines are selected
|
||||
*/
|
||||
const hasSelection = computed(() => selectedLines.value.size > 0);
|
||||
|
||||
/**
|
||||
* Get count of selected lines
|
||||
*/
|
||||
const selectionCount = computed(() => selectedLines.value.size);
|
||||
|
||||
/**
|
||||
* Get selected line numbers as sorted array
|
||||
*/
|
||||
const selectedLineNumbers = computed(() => {
|
||||
return [...selectedLines.value].sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle line selection with modifiers
|
||||
*
|
||||
* Supports:
|
||||
* - Single click: Select only that line
|
||||
* - Shift+click: Select range from last selected to current
|
||||
* - Ctrl/Cmd+click: Toggle individual line
|
||||
*/
|
||||
function toggleLineSelection(lineNumber, event) {
|
||||
if (event.shiftKey && selectedLines.value.size > 0) {
|
||||
// Range selection
|
||||
const lastSelected = Math.max(...selectedLines.value);
|
||||
const start = Math.min(lastSelected, lineNumber);
|
||||
const end = Math.max(lastSelected, lineNumber);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
selectedLines.value.add(i);
|
||||
}
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Toggle individual line
|
||||
if (selectedLines.value.has(lineNumber)) {
|
||||
selectedLines.value.delete(lineNumber);
|
||||
} else {
|
||||
selectedLines.value.add(lineNumber);
|
||||
}
|
||||
} else {
|
||||
// Single selection
|
||||
selectedLines.value.clear();
|
||||
selectedLines.value.add(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
function clearSelection() {
|
||||
selectedLines.value.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content of selected lines
|
||||
*/
|
||||
function getSelectedContent() {
|
||||
if (selectedLines.value.size === 0) return '';
|
||||
|
||||
const lines = selectedLineNumbers.value;
|
||||
const content = lines
|
||||
.map(lineNum => {
|
||||
return (
|
||||
displayLines.value.find(l => l.lineNumber === lineNum)?.content || ''
|
||||
);
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy selected lines to clipboard
|
||||
*/
|
||||
async function copySelected() {
|
||||
if (selectedLines.value.size === 0) return;
|
||||
|
||||
const content = getSelectedContent();
|
||||
await clipboard.copyToClipboard(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all content to clipboard
|
||||
*/
|
||||
async function copyAll() {
|
||||
await clipboard.copyToClipboard(fileContent.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export selected lines to JSON file
|
||||
*/
|
||||
function exportSelected() {
|
||||
if (selectedLines.value.size === 0) return;
|
||||
|
||||
const content = getSelectedContent();
|
||||
downloadFile(content, `${selectedFile.value}-selected-${Date.now()}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all content to JSON file
|
||||
*/
|
||||
function exportAll() {
|
||||
downloadFile(fileContent.value, `${selectedFile.value}-${Date.now()}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Download content as file
|
||||
*/
|
||||
function downloadFile(content, filename) {
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share current URL (copy to clipboard)
|
||||
*/
|
||||
async function shareUrl() {
|
||||
const url = globalThis.location.href;
|
||||
await clipboard.copyToClipboard(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all available lines
|
||||
*/
|
||||
function selectAll() {
|
||||
displayLines.value.forEach(line => {
|
||||
selectedLines.value.add(line.lineNumber);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert selection (select all not selected, deselect all selected)
|
||||
*/
|
||||
function invertSelection() {
|
||||
const allLineNumbers = new Set(
|
||||
displayLines.value.map(line => line.lineNumber)
|
||||
);
|
||||
const newSelection = new Set();
|
||||
|
||||
allLineNumbers.forEach(lineNum => {
|
||||
if (!selectedLines.value.has(lineNum)) {
|
||||
newSelection.add(lineNum);
|
||||
}
|
||||
});
|
||||
|
||||
selectedLines.value.clear();
|
||||
newSelection.forEach(lineNum => selectedLines.value.add(lineNum));
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedLines,
|
||||
|
||||
// Computed
|
||||
hasSelection,
|
||||
selectionCount,
|
||||
selectedLineNumbers,
|
||||
|
||||
// Methods
|
||||
toggleLineSelection,
|
||||
clearSelection,
|
||||
getSelectedContent,
|
||||
copySelected,
|
||||
copyAll,
|
||||
exportSelected,
|
||||
exportAll,
|
||||
shareUrl,
|
||||
selectAll,
|
||||
invertSelection
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* LocalStorage Composable
|
||||
* Type-safe localStorage operations with Vue reactivity
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* Use localStorage with reactivity
|
||||
* @param {string} key - Storage key
|
||||
* @param {any} defaultValue - Default value
|
||||
* @returns {Ref} Reactive ref synced with localStorage
|
||||
*/
|
||||
export function useLocalStorage(key, defaultValue) {
|
||||
// Try to load from localStorage
|
||||
const loadValue = () => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item !== null) {
|
||||
return JSON.parse(item);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${key} from localStorage:`, error);
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const storedValue = ref(loadValue());
|
||||
|
||||
// Watch for changes and save to localStorage
|
||||
watch(
|
||||
storedValue,
|
||||
newValue => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
} catch (error) {
|
||||
console.error(`Error saving ${key} to localStorage:`, error);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return storedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage search history in localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {number} maxItems - Maximum number of items to store
|
||||
* @returns {Object} History management functions
|
||||
*/
|
||||
export function useSearchHistory(key = 'searchHistory', maxItems = 5) {
|
||||
const history = useLocalStorage(key, []);
|
||||
|
||||
const addToHistory = query => {
|
||||
if (!query || query.trim() === '') return;
|
||||
|
||||
// Remove duplicates and add to front
|
||||
const newHistory = [
|
||||
query,
|
||||
...history.value.filter(item => item !== query)
|
||||
].slice(0, maxItems);
|
||||
|
||||
history.value = newHistory;
|
||||
};
|
||||
|
||||
const removeFromHistory = query => {
|
||||
history.value = history.value.filter(item => item !== query);
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
history.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
history,
|
||||
addToHistory,
|
||||
removeFromHistory,
|
||||
clearHistory
|
||||
};
|
||||
}
|
||||
381
code/websites/pokedex.online/src/composables/useOAuth.js
Normal file
381
code/websites/pokedex.online/src/composables/useOAuth.js
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Unified OAuth Composable
|
||||
*
|
||||
* Handles OAuth flow for multiple providers (Challonge, Discord, etc.)
|
||||
*
|
||||
* Features:
|
||||
* - Multi-provider token storage with localStorage persistence
|
||||
* - Authorization URL generation with return_to support
|
||||
* - CSRF protection via state parameter
|
||||
* - Code exchange with provider routing
|
||||
* - Automatic token refresh with 5-minute expiry buffer
|
||||
* - Token validation and cleanup
|
||||
* - Comprehensive error handling
|
||||
*
|
||||
* Usage:
|
||||
* const oauth = useOAuth('challonge');
|
||||
* oauth.login({ scope: 'tournaments:read tournaments:write', return_to: '/challonge-test' });
|
||||
* // ... user redirected to OAuth provider ...
|
||||
* await oauth.exchangeCode(code, state); // called from callback
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { PLATFORMS } from '../config/platforms.js';
|
||||
import { apiClient } from '../utilities/api-client.js';
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCsrfCookie() {
|
||||
// The backend requires double-submit CSRF for all unsafe methods.
|
||||
// The OAuth callback page can be loaded directly after provider redirect,
|
||||
// before the app's normal startup has fetched /session/csrf.
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf) return;
|
||||
|
||||
try {
|
||||
await apiClient.get('/session/csrf', { deduplicate: false });
|
||||
} catch {
|
||||
// If this fails, the subsequent POST will surface the real error.
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-provider status storage (shared across all instances)
|
||||
const statusStores = new Map();
|
||||
|
||||
/**
|
||||
* Initialize OAuth state for a provider
|
||||
* @param {string} provider - Provider name (e.g., 'challonge', 'discord')
|
||||
* @returns {Object} OAuth state for this provider
|
||||
* @throws {Error} If platform not found
|
||||
*/
|
||||
function initializeProvider(provider) {
|
||||
// Return existing state if already initialized
|
||||
if (statusStores.has(provider)) {
|
||||
return statusStores.get(provider);
|
||||
}
|
||||
|
||||
// Validate platform exists
|
||||
const platformConfig = PLATFORMS[provider];
|
||||
if (!platformConfig) {
|
||||
throw new Error(`Platform not found: ${provider}`);
|
||||
}
|
||||
|
||||
const oauthConfig = platformConfig.auth.oauth;
|
||||
if (!oauthConfig?.enabled) {
|
||||
throw new Error(`OAuth not enabled for ${provider}`);
|
||||
}
|
||||
|
||||
// Create provider-specific state
|
||||
const state = {
|
||||
tokens: ref(null),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
provider
|
||||
};
|
||||
|
||||
// Best-effort initial status fetch
|
||||
apiClient
|
||||
.get(`/oauth/${provider}/status`, { deduplicate: false })
|
||||
.then(data => {
|
||||
if (data) state.tokens.value = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
statusStores.set(provider, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main composable for OAuth authentication
|
||||
* @param {string} provider - Provider name (default: 'challonge')
|
||||
* @returns {Object} OAuth composable API
|
||||
*/
|
||||
export function useOAuth(provider = 'challonge') {
|
||||
const state = initializeProvider(provider);
|
||||
const platformConfig = PLATFORMS[provider];
|
||||
const oauthConfig = platformConfig.auth.oauth;
|
||||
|
||||
// Computed properties for token state
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!state.tokens.value?.connected;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
const expiresAt = state.tokens.value?.expires_at;
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= expiresAt;
|
||||
});
|
||||
|
||||
const expiresIn = computed(() => {
|
||||
const expiresAt = state.tokens.value?.expires_at;
|
||||
if (!expiresAt) return null;
|
||||
const diff = expiresAt - Date.now();
|
||||
return diff > 0 ? Math.floor(diff / 1000) : 0;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
// No-split-brain: tokens are never available in the browser
|
||||
return null;
|
||||
});
|
||||
|
||||
const refreshToken = computed(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate authorization URL for OAuth flow
|
||||
*
|
||||
* @param {string|Object} scopeOrOptions - Scope string or options object
|
||||
* @param {Object} options - Additional options (scope, return_to)
|
||||
* @returns {Object} {authUrl, state, returnTo}
|
||||
* @throws {Error} If OAuth credentials not configured
|
||||
*/
|
||||
function getAuthorizationUrl(scopeOrOptions, options = {}) {
|
||||
const clientId = import.meta.env[
|
||||
`VITE_${provider.toUpperCase()}_CLIENT_ID`
|
||||
];
|
||||
const redirectUri = import.meta.env[
|
||||
`VITE_${provider.toUpperCase()}_REDIRECT_URI`
|
||||
];
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error(
|
||||
`OAuth credentials not configured for ${provider}. ` +
|
||||
`Check VITE_${provider.toUpperCase()}_CLIENT_ID and VITE_${provider.toUpperCase()}_REDIRECT_URI in .env`
|
||||
);
|
||||
}
|
||||
|
||||
// Parse arguments (support both string scope and options object)
|
||||
let scope = oauthConfig.scopes.join(' ');
|
||||
let returnTo = null;
|
||||
|
||||
if (typeof scopeOrOptions === 'string') {
|
||||
scope = scopeOrOptions;
|
||||
returnTo = options.return_to;
|
||||
} else if (typeof scopeOrOptions === 'object') {
|
||||
scope = scopeOrOptions.scope || scope;
|
||||
returnTo = scopeOrOptions.return_to;
|
||||
}
|
||||
|
||||
// Generate CSRF state
|
||||
const oauthState = generateState();
|
||||
|
||||
// Build authorization URL
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scope,
|
||||
state: oauthState
|
||||
});
|
||||
|
||||
// Add provider-specific parameters if needed
|
||||
if (provider === 'discord') {
|
||||
params.append('prompt', 'none'); // Don't show consent screen if already authorized
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl: `${oauthConfig.endpoint}?${params.toString()}`,
|
||||
state: oauthState,
|
||||
returnTo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authorization flow
|
||||
* Redirects user to OAuth provider
|
||||
*
|
||||
* @param {Object} options - Options including scope and return_to
|
||||
* @throws {Error} If OAuth credentials missing
|
||||
*/
|
||||
function login(options = {}) {
|
||||
try {
|
||||
const { authUrl, state, returnTo } = getAuthorizationUrl(options);
|
||||
|
||||
// Store state and provider for CSRF validation in callback
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
sessionStorage.setItem('oauth_provider', provider);
|
||||
if (returnTo) {
|
||||
sessionStorage.setItem('oauth_return_to', returnTo);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔐 Starting ${provider} OAuth flow with state:`,
|
||||
state.substring(0, 8) + '...'
|
||||
);
|
||||
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = authUrl;
|
||||
} catch (err) {
|
||||
state.error.value = err.message;
|
||||
console.error(`${provider} OAuth login error:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* Called from OAuth callback page
|
||||
*
|
||||
* @param {string} code - Authorization code from OAuth provider
|
||||
* @param {string} stateParam - State parameter for CSRF validation
|
||||
* @returns {Promise<Object>} Tokens object {access_token, refresh_token, expires_at, ...}
|
||||
* @throws {Error} If CSRF validation fails or token exchange fails
|
||||
*/
|
||||
async function exchangeCode(code, stateParam) {
|
||||
// Verify CSRF state parameter
|
||||
const storedState = sessionStorage.getItem('oauth_state');
|
||||
const storedProvider = sessionStorage.getItem('oauth_provider');
|
||||
|
||||
if (stateParam !== storedState) {
|
||||
const err = new Error('Invalid state parameter - possible CSRF attack');
|
||||
state.error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (storedProvider !== provider) {
|
||||
const err = new Error(
|
||||
`Provider mismatch: expected ${storedProvider}, got ${provider}`
|
||||
);
|
||||
state.error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
|
||||
state.loading.value = true;
|
||||
state.error.value = null;
|
||||
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
const data = await apiClient.post(oauthConfig.tokenEndpoint, { code });
|
||||
state.tokens.value = data;
|
||||
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
sessionStorage.removeItem('oauth_return_to');
|
||||
|
||||
console.log(`✅ ${provider} OAuth authentication successful`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const backendCode = err?.data?.code;
|
||||
const backendError = err?.data?.error;
|
||||
const details = err?.data?.details;
|
||||
|
||||
// Prefer a helpful, user-visible message over a generic HTTP status.
|
||||
let message = err?.message || 'Token exchange failed';
|
||||
if (backendError) message = backendError;
|
||||
if (backendCode) message = `${message} (${backendCode})`;
|
||||
|
||||
// If Challonge returns structured OAuth error info, surface it.
|
||||
const detailText =
|
||||
details?.error_description || details?.error || details?.message;
|
||||
if (detailText) message = `${message}: ${detailText}`;
|
||||
|
||||
state.error.value = message;
|
||||
console.error(`${provider} token exchange error:`, err);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
state.loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
* Called when token is expired or about to expire
|
||||
*
|
||||
* @returns {Promise<Object>} Updated tokens object
|
||||
* @throws {Error} If no refresh token available or refresh fails
|
||||
*/
|
||||
async function refreshTokenFn() {
|
||||
state.loading.value = true;
|
||||
state.error.value = null;
|
||||
|
||||
try {
|
||||
const data = await apiClient.post(oauthConfig.refreshEndpoint, {});
|
||||
state.tokens.value = data;
|
||||
|
||||
console.log(`✅ ${provider} token refreshed`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
state.error.value = err.message;
|
||||
console.error(`${provider} token refresh error:`, err);
|
||||
|
||||
// If refresh fails, clear authentication
|
||||
logout();
|
||||
throw err;
|
||||
} finally {
|
||||
state.loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid access token, refreshing if necessary
|
||||
* Automatically refreshes tokens expiring within 5 minutes
|
||||
*
|
||||
* @returns {Promise<string>} Valid access token
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function getValidToken() {
|
||||
throw new Error(
|
||||
`No-split-brain: ${provider} OAuth token is not accessible in the browser`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear all tokens
|
||||
* Removes tokens from storage and session
|
||||
*/
|
||||
function logout() {
|
||||
state.tokens.value = null;
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_provider');
|
||||
sessionStorage.removeItem('oauth_return_to');
|
||||
if (oauthConfig.disconnectEndpoint) {
|
||||
apiClient.post(oauthConfig.disconnectEndpoint, {}).catch(() => {});
|
||||
}
|
||||
console.log(`👋 ${provider} logged out`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
* Uses crypto.getRandomValues for secure randomness
|
||||
*
|
||||
* @returns {string} 64-character hex string
|
||||
*/
|
||||
function generateState() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tokens: computed(() => state.tokens.value),
|
||||
isAuthenticated,
|
||||
isExpired,
|
||||
expiresIn,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
loading: computed(() => state.loading.value),
|
||||
error: computed(() => state.error.value),
|
||||
|
||||
// Methods
|
||||
login,
|
||||
logout,
|
||||
exchangeCode,
|
||||
refreshToken: refreshTokenFn,
|
||||
getValidToken,
|
||||
getAuthorizationUrl
|
||||
};
|
||||
}
|
||||
84
code/websites/pokedex.online/src/composables/useUrlState.js
Normal file
84
code/websites/pokedex.online/src/composables/useUrlState.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* URL State Composable
|
||||
* Synchronizes component state with URL query parameters
|
||||
*/
|
||||
|
||||
import { watch, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Sync state with URL query parameters
|
||||
* @param {Object} stateRefs - Object of refs to sync {key: ref}
|
||||
* @returns {Object} Control functions
|
||||
*/
|
||||
export function useUrlState(stateRefs) {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
/**
|
||||
* Update URL with current state
|
||||
*/
|
||||
const updateUrl = () => {
|
||||
const query = {};
|
||||
|
||||
Object.entries(stateRefs).forEach(([key, ref]) => {
|
||||
const value = ref.value;
|
||||
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
query[key] = value.join(',');
|
||||
} else {
|
||||
query[key] = String(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
/**
|
||||
* Load state from URL
|
||||
*/
|
||||
const loadFromUrl = () => {
|
||||
Object.entries(stateRefs).forEach(([key, ref]) => {
|
||||
const value = route.query[key];
|
||||
|
||||
if (value) {
|
||||
if (Array.isArray(ref.value)) {
|
||||
ref.value = value.split(',');
|
||||
} else if (typeof ref.value === 'number') {
|
||||
ref.value = parseInt(value, 10) || 0;
|
||||
} else if (typeof ref.value === 'boolean') {
|
||||
ref.value = value === 'true';
|
||||
} else {
|
||||
ref.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle browser back/forward
|
||||
*/
|
||||
const handlePopState = () => {
|
||||
loadFromUrl();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Load initial state from URL
|
||||
loadFromUrl();
|
||||
|
||||
// Listen for browser back/forward
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
});
|
||||
|
||||
// Watch for state changes and update URL
|
||||
Object.values(stateRefs).forEach(ref => {
|
||||
watch(ref, updateUrl, { deep: true });
|
||||
});
|
||||
|
||||
return {
|
||||
updateUrl,
|
||||
loadFromUrl
|
||||
};
|
||||
}
|
||||
141
code/websites/pokedex.online/src/config/feature-flags.js
Normal file
141
code/websites/pokedex.online/src/config/feature-flags.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Feature Flags Configuration
|
||||
*
|
||||
* Defines all feature flags with metadata.
|
||||
* Uses build-time obfuscation for production security.
|
||||
*
|
||||
* Pattern:
|
||||
* - Development: Flags easily toggled via Developer Tools (Ctrl+Shift+D)
|
||||
* - Production: Flags obfuscated and require permission-based backend query
|
||||
* - All flags checked at runtime via useFeatureFlags composable
|
||||
*
|
||||
* Usage in components:
|
||||
* ```javascript
|
||||
* const { isEnabled } = useFeatureFlags();
|
||||
* if (isEnabled('experimental-search')) { ... }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const FEATURE_FLAGS = {
|
||||
// Gamemaster Features
|
||||
GAMEMASTER_FEATURES: {
|
||||
name: 'gamemaster-features',
|
||||
description: 'Enable Gamemaster Manager and Explorer tools',
|
||||
enabled: false,
|
||||
requiredPermission: null,
|
||||
tags: ['gamemaster', 'tools']
|
||||
},
|
||||
|
||||
// Gamemaster Explorer Features
|
||||
EXPERIMENTAL_SEARCH: {
|
||||
name: 'experimental-search',
|
||||
description: 'Enable experimental search with regex support',
|
||||
enabled: false,
|
||||
requiredPermission: null,
|
||||
tags: ['gamemaster', 'search']
|
||||
},
|
||||
|
||||
GAMEMASTER_DIFF_VIEWER: {
|
||||
name: 'gamemaster-diff-viewer',
|
||||
description: 'Enable diff viewer for gamemaster file comparisons',
|
||||
enabled: false,
|
||||
requiredPermission: 'gamemaster-advanced',
|
||||
tags: ['gamemaster', 'ui']
|
||||
},
|
||||
|
||||
GAMEMASTER_BOOKMARKS: {
|
||||
name: 'gamemaster-bookmarks',
|
||||
description: 'Enable bookmarking feature for favorite gamemasters',
|
||||
enabled: false,
|
||||
requiredPermission: null,
|
||||
tags: ['gamemaster', 'storage']
|
||||
},
|
||||
|
||||
// Challonge Test Features
|
||||
CHALLONGE_CACHED_TOURNAMENTS: {
|
||||
name: 'challonge-cached-tournaments',
|
||||
description: 'Cache tournament data for faster loading',
|
||||
enabled: false,
|
||||
requiredPermission: 'challonge-advanced',
|
||||
tags: ['challonge', 'performance']
|
||||
},
|
||||
|
||||
CHALLONGE_EXPORT_CSV: {
|
||||
name: 'challonge-export-csv',
|
||||
description: 'Allow exporting tournament data to CSV',
|
||||
enabled: false,
|
||||
requiredPermission: null,
|
||||
tags: ['challonge', 'export']
|
||||
},
|
||||
|
||||
// Developer/Admin Features
|
||||
DEVELOPER_TOOLS: {
|
||||
name: 'developer-tools',
|
||||
description: 'Show developer tools panel (Ctrl+Shift+D)',
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
requiredPermission: null,
|
||||
tags: ['developer']
|
||||
},
|
||||
|
||||
ADMIN_PANEL: {
|
||||
name: 'admin-panel',
|
||||
description: 'Show admin panel with system information',
|
||||
enabled: false,
|
||||
requiredPermission: 'admin',
|
||||
tags: ['admin', 'maintenance']
|
||||
},
|
||||
|
||||
// Performance Features
|
||||
ENABLE_CACHING: {
|
||||
name: 'enable-caching',
|
||||
description: 'Enable client-side caching for API responses',
|
||||
enabled: true,
|
||||
requiredPermission: null,
|
||||
tags: ['performance']
|
||||
},
|
||||
|
||||
// UI Features
|
||||
DARK_MODE: {
|
||||
name: 'dark-mode',
|
||||
description: 'Enable dark mode theme',
|
||||
enabled: false,
|
||||
requiredPermission: null,
|
||||
tags: ['ui', 'theme']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags
|
||||
* @returns {Object} All feature flags
|
||||
*/
|
||||
export function getAllFlags() {
|
||||
return FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flag by name
|
||||
* @param {string} name - Flag name
|
||||
* @returns {Object|null} Flag object or null if not found
|
||||
*/
|
||||
export function getFlag(name) {
|
||||
return Object.values(FEATURE_FLAGS).find(flag => flag.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all flags for a specific tag
|
||||
* @param {string} tag - Tag name
|
||||
* @returns {Object[]} Array of flags with the tag
|
||||
*/
|
||||
export function getFlagsByTag(tag) {
|
||||
return Object.values(FEATURE_FLAGS).filter(flag => flag.tags.includes(tag));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if flag requires specific permission
|
||||
* @param {string} name - Flag name
|
||||
* @returns {string|null} Required permission or null if no permission required
|
||||
*/
|
||||
export function getFlagPermission(name) {
|
||||
const flag = getFlag(name);
|
||||
return flag ? flag.requiredPermission : null;
|
||||
}
|
||||
116
code/websites/pokedex.online/src/config/platforms.js
Normal file
116
code/websites/pokedex.online/src/config/platforms.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Platform Registry
|
||||
*
|
||||
* Centralized configuration for OAuth providers and authentication methods
|
||||
* Supports: Challonge (OAuth, API Key, Client Credentials), Discord (OAuth)
|
||||
*
|
||||
* Add new platforms by extending PLATFORMS object with name, label, icon, and auth methods
|
||||
*/
|
||||
|
||||
export const PLATFORMS = {
|
||||
challonge: {
|
||||
name: 'challonge',
|
||||
label: 'Challonge',
|
||||
icon: '🏆',
|
||||
description: 'Tournament management and API access',
|
||||
helpUrl: 'https://challonge.com/settings/developer',
|
||||
auth: {
|
||||
apiKey: {
|
||||
enabled: true,
|
||||
label: 'API Key',
|
||||
description: 'Direct API key authentication for v1 and v2.1',
|
||||
storageKey: 'challonge_api_key'
|
||||
},
|
||||
oauth: {
|
||||
enabled: true,
|
||||
label: 'OAuth 2.0',
|
||||
description: 'User token authentication for v2.1 API',
|
||||
endpoint: 'https://api.challonge.com/oauth/authorize',
|
||||
tokenEndpoint: '/oauth/challonge/exchange',
|
||||
refreshEndpoint: '/oauth/challonge/refresh',
|
||||
disconnectEndpoint: '/oauth/challonge/disconnect',
|
||||
scopes: ['tournaments:read', 'tournaments:write'],
|
||||
storageKey: 'challonge_oauth_tokens'
|
||||
},
|
||||
clientCredentials: {
|
||||
enabled: true,
|
||||
label: 'Client Credentials',
|
||||
description: 'For APPLICATION scope access',
|
||||
tokenEndpoint: 'https://api.challonge.com/oauth/token',
|
||||
storageKey: 'challonge_client_credentials'
|
||||
}
|
||||
}
|
||||
},
|
||||
discord: {
|
||||
name: 'discord',
|
||||
label: 'Discord',
|
||||
icon: '🎮',
|
||||
description: 'Personal identity verification and access control',
|
||||
helpUrl: 'https://discord.com/developers/applications',
|
||||
auth: {
|
||||
oauth: {
|
||||
enabled: true,
|
||||
label: 'OAuth 2.0',
|
||||
description: 'Verify your Discord identity',
|
||||
endpoint: 'https://discord.com/api/oauth2/authorize',
|
||||
tokenEndpoint: '/oauth/discord/exchange',
|
||||
refreshEndpoint: '/oauth/discord/refresh',
|
||||
disconnectEndpoint: '/oauth/discord/disconnect',
|
||||
scopes: ['identify'],
|
||||
storageKey: 'discord_oauth_tokens',
|
||||
userEndpoint: 'https://discord.com/api/users/@me'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get platform configuration by name
|
||||
* @param {string} name - Platform name (e.g., 'challonge', 'discord')
|
||||
* @returns {Object} Platform configuration or throws error if not found
|
||||
*/
|
||||
export function getPlatform(name) {
|
||||
const platform = PLATFORMS[name];
|
||||
if (!platform) {
|
||||
throw new Error(`Platform not found: ${name}`);
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platforms
|
||||
* @returns {Object[]} Array of all platform configurations
|
||||
*/
|
||||
export function getAllPlatforms() {
|
||||
return Object.values(PLATFORMS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a platform has a specific auth method
|
||||
* @param {string} platformName - Platform name
|
||||
* @param {string} methodName - Auth method name (e.g., 'oauth', 'apiKey')
|
||||
* @returns {boolean} True if method is enabled
|
||||
*/
|
||||
export function hasAuthMethod(platformName, methodName) {
|
||||
try {
|
||||
const platform = getPlatform(platformName);
|
||||
return platform.auth[methodName]?.enabled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth method configuration
|
||||
* @param {string} platformName - Platform name
|
||||
* @param {string} methodName - Auth method name
|
||||
* @returns {Object} Auth method configuration
|
||||
*/
|
||||
export function getAuthMethod(platformName, methodName) {
|
||||
const platform = getPlatform(platformName);
|
||||
const method = platform.auth[methodName];
|
||||
if (!method || !method.enabled) {
|
||||
throw new Error(`Auth method not found: ${platformName}.${methodName}`);
|
||||
}
|
||||
return method;
|
||||
}
|
||||
107
code/websites/pokedex.online/src/directives/highlight.js
Normal file
107
code/websites/pokedex.online/src/directives/highlight.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* v-highlight directive
|
||||
* Lazy loads syntax highlighting for code blocks using highlight.js
|
||||
* Only highlights when element enters viewport (IntersectionObserver)
|
||||
*/
|
||||
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
|
||||
// Register JSON language
|
||||
hljs.registerLanguage('json', json);
|
||||
|
||||
// Configure highlight.js to suppress HTML warnings (our content is safe/internal)
|
||||
hljs.configure({
|
||||
ignoreUnescapedHTML: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply syntax highlighting to a code element
|
||||
* @param {HTMLElement} el - The code element to highlight
|
||||
* @param {string} theme - Theme name ('github' or 'github-dark')
|
||||
*/
|
||||
function applyHighlight(el, theme = 'github-dark') {
|
||||
if (el.classList.contains('hljs')) {
|
||||
// Already highlighted
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
hljs.highlightElement(el);
|
||||
// Remove any existing theme classes
|
||||
el.className = el.className.replace(/hljs-theme-\w+/, '');
|
||||
el.classList.add(`hljs-theme-${theme}`);
|
||||
} catch (error) {
|
||||
console.error('Highlight.js error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IntersectionObserver for lazy highlighting
|
||||
* @param {HTMLElement} el - Element to observe
|
||||
* @param {string} theme - Theme to apply
|
||||
* @returns {IntersectionObserver}
|
||||
*/
|
||||
function createHighlightObserver(el, theme) {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
applyHighlight(entry.target, theme);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '50px', // Start highlighting slightly before entering viewport
|
||||
threshold: 0.01
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* v-highlight directive
|
||||
* Usage: <code v-highlight="{ theme: 'github', language: 'json' }">...</code>
|
||||
*/
|
||||
export const vHighlight = {
|
||||
mounted(el, binding) {
|
||||
const config = binding.value || {};
|
||||
const theme =
|
||||
typeof config === 'string' ? config : config.theme || 'github-dark';
|
||||
|
||||
// Store observer on element for cleanup
|
||||
el._highlightObserver = createHighlightObserver(el, theme);
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const newConfig = binding.value || {};
|
||||
const newTheme =
|
||||
typeof newConfig === 'string'
|
||||
? newConfig
|
||||
: newConfig.theme || 'github-dark';
|
||||
const oldConfig = binding.oldValue || {};
|
||||
const oldTheme =
|
||||
typeof oldConfig === 'string'
|
||||
? oldConfig
|
||||
: oldConfig.theme || 'github-dark';
|
||||
|
||||
// If theme changed, re-highlight
|
||||
if (newTheme !== oldTheme && el.classList.contains('hljs')) {
|
||||
el.classList.remove(`hljs-theme-${oldTheme}`);
|
||||
el.classList.add(`hljs-theme-${newTheme}`);
|
||||
}
|
||||
},
|
||||
|
||||
unmounted(el) {
|
||||
// Cleanup observer
|
||||
if (el._highlightObserver) {
|
||||
el._highlightObserver.disconnect();
|
||||
delete el._highlightObserver;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default vHighlight;
|
||||
@@ -3,4 +3,31 @@ import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
// Virtual scroller for large lists
|
||||
import VueVirtualScroller from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
// Highlight.js themes
|
||||
import 'highlight.js/styles/github.css';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
// Custom directives
|
||||
import { vHighlight } from './directives/highlight.js';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.use(VueVirtualScroller);
|
||||
app.directive('highlight', vHighlight);
|
||||
|
||||
// Prime session + CSRF cookies (server uses SID cookies and double-submit CSRF)
|
||||
(async () => {
|
||||
try {
|
||||
await fetch('/api/session/init', { credentials: 'include' });
|
||||
await fetch('/api/session/csrf', { credentials: 'include' });
|
||||
} catch (err) {
|
||||
console.warn('Failed to initialize session/CSRF cookies:', err);
|
||||
} finally {
|
||||
app.mount('#app');
|
||||
}
|
||||
})();
|
||||
|
||||
82
code/websites/pokedex.online/src/router/guards.js
Normal file
82
code/websites/pokedex.online/src/router/guards.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Route Guards
|
||||
*
|
||||
* Navigation guards for protecting admin routes and feature-flagged routes
|
||||
*/
|
||||
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useFeatureFlags } from '../composables/useFeatureFlags.js';
|
||||
|
||||
/**
|
||||
* Create router guards with auth and feature flag checks
|
||||
* @param {Router} router - Vue Router instance
|
||||
*/
|
||||
export function setupAuthGuards(router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { isAuthenticated, initializeAuth } = useAuth();
|
||||
const { isEnabled } = useFeatureFlags();
|
||||
|
||||
// Initialize auth from stored token
|
||||
if (!isAuthenticated.value) {
|
||||
await initializeAuth();
|
||||
}
|
||||
|
||||
// Check if route is behind a feature flag
|
||||
if (to.meta.featureFlag) {
|
||||
const flagEnabled = isEnabled.value(to.meta.featureFlag);
|
||||
if (!flagEnabled) {
|
||||
console.warn(
|
||||
`[Router] Feature flag "${to.meta.featureFlag}" is disabled`
|
||||
);
|
||||
next({ name: 'Home', replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if route requires admin access
|
||||
if (to.meta.requiresAdmin) {
|
||||
if (!isAuthenticated.value) {
|
||||
// Redirect to login
|
||||
next({
|
||||
name: 'admin-login',
|
||||
query: { redirect: to.fullPath }
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
// Optional: Log navigation for debugging
|
||||
if (to.meta.requiresAdmin) {
|
||||
console.log(`[Auth] Navigated to protected route: ${to.path}`);
|
||||
}
|
||||
if (to.meta.featureFlag) {
|
||||
console.log(
|
||||
`[FeatureFlag] Navigated to flagged route: ${to.path} (flag: ${to.meta.featureFlag})`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route requires authentication
|
||||
* @param {RouteLocationNormalized} route - Route object
|
||||
* @returns {boolean} True if route requires authentication
|
||||
*/
|
||||
export function requiresAuthentication(route) {
|
||||
return route.meta.requiresAdmin === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect path after login
|
||||
* @param {Router} router - Vue Router instance
|
||||
* @returns {string} Path to redirect to
|
||||
*/
|
||||
export function getPostLoginRedirect(router) {
|
||||
const redirect = router.currentRoute.value.query.redirect;
|
||||
return redirect || '/';
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Home from '../views/Home.vue';
|
||||
import GamemasterManager from '../views/GamemasterManager.vue';
|
||||
import GamemasterExplorer from '../views/GamemasterExplorer.vue';
|
||||
import ChallongeTest from '../views/ChallongeTest.vue';
|
||||
import ApiKeyManager from '../views/ApiKeyManager.vue';
|
||||
import AuthenticationHub from '../views/AuthenticationHub.vue';
|
||||
import ClientCredentialsManager from '../views/ClientCredentialsManager.vue';
|
||||
import OAuthCallback from '../views/OAuthCallback.vue';
|
||||
|
||||
const routes = [
|
||||
@@ -16,20 +18,39 @@ const routes = [
|
||||
name: 'GamemasterManager',
|
||||
component: GamemasterManager
|
||||
},
|
||||
{
|
||||
path: '/gamemaster-explorer',
|
||||
name: 'GamemasterExplorer',
|
||||
component: GamemasterExplorer
|
||||
},
|
||||
{
|
||||
path: '/challonge-test',
|
||||
name: 'ChallongeTest',
|
||||
component: ChallongeTest
|
||||
},
|
||||
{
|
||||
path: '/api-key-manager',
|
||||
name: 'ApiKeyManager',
|
||||
component: ApiKeyManager
|
||||
path: '/auth',
|
||||
name: 'AuthenticationHub',
|
||||
component: AuthenticationHub
|
||||
},
|
||||
{
|
||||
path: '/client-credentials',
|
||||
name: 'ClientCredentialsManager',
|
||||
component: ClientCredentialsManager
|
||||
},
|
||||
{
|
||||
path: '/oauth/callback',
|
||||
name: 'OAuthCallback',
|
||||
component: OAuthCallback
|
||||
},
|
||||
// Legacy redirects for backwards compatibility
|
||||
{
|
||||
path: '/api-key-manager',
|
||||
redirect: '/auth'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
redirect: '/auth'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -12,16 +12,10 @@ import { API_CONFIG } from '../utilities/constants.js';
|
||||
|
||||
/**
|
||||
* Get the appropriate base URL based on environment
|
||||
* Development: Use Vite proxy to avoid CORS
|
||||
* Production: Use direct API (requires backend proxy or CORS handling)
|
||||
* Always use nginx proxy to avoid CORS issues
|
||||
*/
|
||||
function getBaseURL() {
|
||||
// In development, use Vite proxy
|
||||
if (import.meta.env.DEV) {
|
||||
return '/api/challonge/v1/';
|
||||
}
|
||||
// In production, use direct API (will need backend proxy for CORS)
|
||||
return API_CONFIG.CHALLONGE_BASE_URL;
|
||||
return '/api/challonge/v1/';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +37,8 @@ export function createChallongeV1Client(apiKey) {
|
||||
? endpoint.slice(1)
|
||||
: endpoint;
|
||||
const url = new URL(`${baseURL}${cleanEndpoint}`, window.location.origin);
|
||||
url.searchParams.append('api_key', apiKey);
|
||||
// No-split-brain: do not send api_key from the browser.
|
||||
// Backend proxy injects the per-session stored API key.
|
||||
|
||||
if (options.params) {
|
||||
Object.entries(options.params).forEach(([key, value]) => {
|
||||
|
||||
@@ -15,12 +15,10 @@
|
||||
|
||||
/**
|
||||
* Get the appropriate base URL based on environment
|
||||
* Always use nginx proxy to avoid CORS issues
|
||||
*/
|
||||
function getBaseURL() {
|
||||
if (import.meta.env.DEV) {
|
||||
return '/api/challonge/v2.1';
|
||||
}
|
||||
return 'https://api.challonge.com/v2.1';
|
||||
return '/api/challonge/v2.1';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,14 +50,10 @@ export const ScopeType = {
|
||||
* @returns {Object} API client with methods
|
||||
*/
|
||||
export function createChallongeV2Client(auth, options = {}) {
|
||||
const { token, type = AuthType.API_KEY } = auth;
|
||||
const { token, type = AuthType.API_KEY } = auth || {};
|
||||
const { communityId: defaultCommunityId, debug = false } = options;
|
||||
const baseURL = getBaseURL();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token is required');
|
||||
}
|
||||
|
||||
// Request tracking for debug mode
|
||||
let requestCount = 0;
|
||||
|
||||
@@ -111,16 +105,15 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
...headers
|
||||
};
|
||||
|
||||
// Add authorization header
|
||||
if (type === AuthType.OAUTH) {
|
||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||
} else {
|
||||
requestHeaders['Authorization'] = token;
|
||||
}
|
||||
// No-split-brain: never send Challonge tokens from the browser.
|
||||
// Backend proxy derives auth from the per-session SID cookie and the Authorization-Type hint.
|
||||
// (Token is intentionally ignored here.)
|
||||
|
||||
const fetchOptions = {
|
||||
method,
|
||||
headers: requestHeaders
|
||||
headers: requestHeaders,
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
@@ -151,13 +144,28 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse response body (prefer JSON when declared)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
if (contentType.includes('application/json')) {
|
||||
data = await response.json();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
// Best-effort: if it's actually JSON but wrong content-type, parse it.
|
||||
data = text;
|
||||
if (text && (text.startsWith('{') || text.startsWith('['))) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
// keep as text
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If JSON parsing fails, create an error with the status
|
||||
if (debug)
|
||||
if (debug) {
|
||||
console.error('[Challonge v2.1 JSON Parse Error]', parseError);
|
||||
}
|
||||
const error = new Error(
|
||||
`HTTP ${response.status}: Failed to parse response`
|
||||
);
|
||||
@@ -188,14 +196,25 @@ export function createChallongeV2Client(auth, options = {}) {
|
||||
.join('\n');
|
||||
|
||||
const error = new Error(errorMessage);
|
||||
error.status = response.status;
|
||||
error.errors = errorDetails;
|
||||
error.response = data;
|
||||
throw error;
|
||||
}
|
||||
// Handle non-JSON:API error format
|
||||
const error = new Error(
|
||||
`HTTP ${response.status}: ${data.message || response.statusText}`
|
||||
);
|
||||
const messageFromBody =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: data?.error || data?.message || response.statusText;
|
||||
|
||||
const fallbackMessage = response.statusText || 'Request failed';
|
||||
const finalMessage =
|
||||
typeof messageFromBody === 'string' &&
|
||||
messageFromBody.trim().length === 0
|
||||
? fallbackMessage
|
||||
: messageFromBody || fallbackMessage;
|
||||
|
||||
const error = new Error(`HTTP ${response.status}: ${finalMessage}`);
|
||||
error.status = response.status;
|
||||
error.response = data;
|
||||
throw error;
|
||||
|
||||
251
code/websites/pokedex.online/src/utilities/api-client.js
Normal file
251
code/websites/pokedex.online/src/utilities/api-client.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* API Client Utility
|
||||
*
|
||||
* Centralized fetch wrapper with:
|
||||
* - Automatic error handling
|
||||
* - Retry logic with exponential backoff
|
||||
* - Request/response interceptors
|
||||
* - Request deduplication
|
||||
* - Timeout support
|
||||
*
|
||||
* @example
|
||||
* const client = createApiClient({ baseURL: '/api' });
|
||||
* const data = await client.get('/users');
|
||||
*/
|
||||
|
||||
const activeRequests = new Map();
|
||||
|
||||
function getCookie(name) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const parts = document.cookie.split(';').map(p => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Create an API client with configuration
|
||||
* @param {Object} config - Client configuration
|
||||
* @returns {Object} API client with methods
|
||||
*/
|
||||
export function createApiClient(config = {}) {
|
||||
const {
|
||||
baseURL = '',
|
||||
timeout = 30000,
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
headers: defaultHeaders = {},
|
||||
onRequest = null,
|
||||
onResponse = null,
|
||||
onError = null
|
||||
} = config;
|
||||
|
||||
/**
|
||||
* Make HTTP request
|
||||
* @param {string} url - Request URL
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<any>} Response data
|
||||
*/
|
||||
async function request(url, options = {}) {
|
||||
const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`;
|
||||
const requestKey = `${options.method || 'GET'}:${fullURL}`;
|
||||
|
||||
// Check for duplicate request
|
||||
if (options.deduplicate !== false && activeRequests.has(requestKey)) {
|
||||
return activeRequests.get(requestKey);
|
||||
}
|
||||
|
||||
const requestPromise = makeRequest(fullURL, options);
|
||||
|
||||
// Store active request
|
||||
if (options.deduplicate !== false) {
|
||||
activeRequests.set(requestKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
return result;
|
||||
} finally {
|
||||
activeRequests.delete(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the actual HTTP request with retries
|
||||
*/
|
||||
async function makeRequest(url, options) {
|
||||
const { retries = maxRetries, ...fetchOptions } = options;
|
||||
|
||||
const method = (fetchOptions.method || 'GET').toUpperCase();
|
||||
|
||||
// Merge headers
|
||||
const headers = {
|
||||
...defaultHeaders,
|
||||
...fetchOptions.headers
|
||||
};
|
||||
|
||||
// Default JSON content type unless caller overrides / uses FormData
|
||||
const hasBody =
|
||||
fetchOptions.body !== undefined && fetchOptions.body !== null;
|
||||
const isFormData =
|
||||
typeof FormData !== 'undefined' && fetchOptions.body instanceof FormData;
|
||||
if (hasBody && !isFormData && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Double-submit CSRF: mirror cookie into header for state-changing requests
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const csrf = getCookie('pdx_csrf');
|
||||
if (csrf && !headers['X-CSRF-Token']) {
|
||||
headers['X-CSRF-Token'] = csrf;
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
let lastError;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= retries) {
|
||||
try {
|
||||
// Call request interceptor
|
||||
let requestOptions = {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
credentials: fetchOptions.credentials || 'include',
|
||||
cache: fetchOptions.cache || 'no-store',
|
||||
signal: controller.signal
|
||||
};
|
||||
if (onRequest) {
|
||||
requestOptions =
|
||||
(await onRequest(url, requestOptions)) || requestOptions;
|
||||
}
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Call response interceptor
|
||||
let processedResponse = response;
|
||||
if (onResponse) {
|
||||
processedResponse = (await onResponse(response.clone())) || response;
|
||||
}
|
||||
|
||||
// Handle HTTP errors
|
||||
if (!processedResponse.ok) {
|
||||
const error = new Error(
|
||||
`HTTP ${processedResponse.status}: ${processedResponse.statusText}`
|
||||
);
|
||||
error.status = processedResponse.status;
|
||||
error.response = processedResponse;
|
||||
|
||||
// Try to parse error body
|
||||
try {
|
||||
const contentType = processedResponse.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
error.data = await processedResponse.json();
|
||||
} else {
|
||||
error.data = await processedResponse.text();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Some endpoints may return 204/304 (no body). Avoid JSON parse errors.
|
||||
if (
|
||||
processedResponse.status === 204 ||
|
||||
processedResponse.status === 304
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const contentType = processedResponse.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await processedResponse.json();
|
||||
}
|
||||
return await processedResponse.text();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = error;
|
||||
|
||||
// Don't retry on abort
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutError = new Error('Request timeout');
|
||||
timeoutError.isTimeout = true;
|
||||
throw timeoutError;
|
||||
}
|
||||
|
||||
// Don't retry on 4xx errors (client errors)
|
||||
if (error.status && error.status >= 400 && error.status < 500) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
|
||||
// If more retries remaining, wait before retrying
|
||||
if (attempt <= retries) {
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, retryDelay * attempt)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if (onError) {
|
||||
onError(lastError);
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
return {
|
||||
request,
|
||||
get: (url, options = {}) => request(url, { ...options, method: 'GET' }),
|
||||
post: (url, data, options = {}) =>
|
||||
request(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
put: (url, data, options = {}) =>
|
||||
request(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
patch: (url, data, options = {}) =>
|
||||
request(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
delete: (url, options = {}) =>
|
||||
request(url, { ...options, method: 'DELETE' }),
|
||||
|
||||
// Header management
|
||||
setDefaultHeader: (name, value) => {
|
||||
defaultHeaders[name] = value;
|
||||
},
|
||||
removeDefaultHeader: name => {
|
||||
delete defaultHeaders[name];
|
||||
},
|
||||
getDefaultHeaders: () => ({ ...defaultHeaders })
|
||||
};
|
||||
}
|
||||
|
||||
// Export default client instance
|
||||
export const apiClient = createApiClient({
|
||||
baseURL: '/api'
|
||||
});
|
||||
192
code/websites/pokedex.online/src/utilities/gamemaster-client.js
Normal file
192
code/websites/pokedex.online/src/utilities/gamemaster-client.js
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Gamemaster Client Utility
|
||||
* Use this in any app on the site to access gamemaster data
|
||||
*
|
||||
* Usage:
|
||||
* import { GamemasterClient } from './gamemaster-client.js';
|
||||
* const gm = new GamemasterClient('/api/gamemaster');
|
||||
*
|
||||
* // Get filtered pokemon
|
||||
* const pokemon = await gm.getPokemon();
|
||||
*
|
||||
* // Get all forms with costumes
|
||||
* const allForms = await gm.getAllForms();
|
||||
*
|
||||
* // Get moves
|
||||
* const moves = await gm.getMoves();
|
||||
*
|
||||
* // Get raw unmodified data
|
||||
* const raw = await gm.getRaw();
|
||||
*
|
||||
* // Check what's available
|
||||
* const status = await gm.getStatus();
|
||||
*/
|
||||
|
||||
export class GamemasterClient {
|
||||
constructor(baseUrl = '/api/gamemaster') {
|
||||
this.baseUrl = baseUrl;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the gamemaster API
|
||||
* @private
|
||||
* @param {string} endpoint
|
||||
* @param {Object} options
|
||||
* @returns {Promise<Object|Array>}
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Gamemaster API Error [${endpoint}]:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of available files
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getStatus() {
|
||||
return this.request('/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered pokemon data (base forms + regional variants)
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.useCache - Use cached data if available
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getPokemon(options = {}) {
|
||||
if (options.useCache && this.cache.has('pokemon')) {
|
||||
return this.cache.get('pokemon');
|
||||
}
|
||||
|
||||
const data = await this.request('/pokemon');
|
||||
this.cache.set('pokemon', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pokemon forms including costumes, shadows, events, etc.
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.useCache - Use cached data if available
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getAllForms(options = {}) {
|
||||
if (options.useCache && this.cache.has('allForms')) {
|
||||
return this.cache.get('allForms');
|
||||
}
|
||||
|
||||
const data = await this.request('/pokemon/allForms');
|
||||
this.cache.set('allForms', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pokemon moves
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.useCache - Use cached data if available
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getMoves(options = {}) {
|
||||
if (options.useCache && this.cache.has('moves')) {
|
||||
return this.cache.get('moves');
|
||||
}
|
||||
|
||||
const data = await this.request('/moves');
|
||||
this.cache.set('moves', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw unmodified gamemaster data
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.useCache - Use cached data if available
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getRaw(options = {}) {
|
||||
if (options.useCache && this.cache.has('raw')) {
|
||||
return this.cache.get('raw');
|
||||
}
|
||||
|
||||
const data = await this.request('/raw');
|
||||
this.cache.set('raw', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file directly
|
||||
* @param {string} filename - One of: pokemon.json, pokemon-allFormsCostumes.json, pokemon-moves.json, latest-raw.json
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async downloadFile(filename) {
|
||||
const url = `${this.baseUrl}/download/${filename}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${filename}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached data
|
||||
* @param {string} key - Optional specific key to clear, or clears all if not provided
|
||||
*/
|
||||
clearCache(key = null) {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific pokemon from the filtered dataset
|
||||
* @param {string} pokemonId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getPokemonById(pokemonId) {
|
||||
const pokemon = await this.getPokemon({ useCache: true });
|
||||
return (
|
||||
pokemon.find(p => p.data?.pokemonSettings?.pokemonId === pokemonId) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific move
|
||||
* @param {string} moveId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getMoveById(moveId) {
|
||||
const moves = await this.getMoves({ useCache: true });
|
||||
return moves.find(m => m.data?.moveSettings?.movementId === moveId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance - use this for most cases
|
||||
*/
|
||||
export const gamemasterClient = new GamemasterClient();
|
||||
@@ -48,12 +48,16 @@ export function breakUpGamemaster(gamemaster) {
|
||||
acc.pokemonAllForms.push(item);
|
||||
|
||||
// Add to pokemon (filtered - first occurrence OR regional forms)
|
||||
// Check if this is a regional form by looking at the form string
|
||||
const form = pokemonSettings?.form;
|
||||
const isRegionalForm =
|
||||
form && typeof form === 'string'
|
||||
? regionCheck.includes(form.split('_')[1]?.toLowerCase())
|
||||
: false;
|
||||
|
||||
if (
|
||||
!acc.pokemonSeen.has(pokemonId) ||
|
||||
(acc.pokemonSeen.has(pokemonId) &&
|
||||
regionCheck.includes(
|
||||
pokemonSettings?.form?.split('_')[1]?.toLowerCase()
|
||||
))
|
||||
(acc.pokemonSeen.has(pokemonId) && isRegionalForm)
|
||||
) {
|
||||
acc.pokemonSeen.add(pokemonId);
|
||||
acc.pokemon.push(item);
|
||||
|
||||
149
code/websites/pokedex.online/src/utilities/group-size-util.js
Normal file
149
code/websites/pokedex.online/src/utilities/group-size-util.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* groupSizeUtils.js
|
||||
* -----------------------------------------
|
||||
* Utilities for group size calculation and
|
||||
* print-safe layout generation.
|
||||
*
|
||||
* Vanilla ECMAScript, framework-agnostic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create group definitions with base sizes
|
||||
* and remainder distribution.
|
||||
*
|
||||
* @param {number} totalPlayers
|
||||
* @param {Array<string>} groupIds
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function calculateGroupSizes(totalPlayers, groupIds) {
|
||||
if (!Array.isArray(groupIds) || groupIds.length === 0) {
|
||||
throw new Error('groupIds must be a non-empty array');
|
||||
}
|
||||
|
||||
const totalGroups = groupIds.length;
|
||||
const baseSize = Math.floor(totalPlayers / totalGroups);
|
||||
const remainder = totalPlayers % totalGroups;
|
||||
|
||||
return groupIds.map(function (id, index) {
|
||||
return {
|
||||
id: id,
|
||||
size: baseSize + (index < remainder ? 1 : 0),
|
||||
players: []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign players to groups sequentially
|
||||
* based on calculated group sizes.
|
||||
*
|
||||
* @param {Array<Object>} players
|
||||
* @param {Array<Object>} groups
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function assignPlayersToGroups(players, groups) {
|
||||
var playerIndex = 0;
|
||||
|
||||
groups.forEach(function (group) {
|
||||
for (var i = 0; i < group.size; i++) {
|
||||
if (playerIndex >= players.length) break;
|
||||
|
||||
var player = players[playerIndex];
|
||||
player.groupId = group.id;
|
||||
group.players.push(player);
|
||||
|
||||
playerIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dropped players without rebalancing groups.
|
||||
*
|
||||
* @param {Array<Object>} groups
|
||||
* @param {Array<string|number>} droppedPlayerIds
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function applyDrops(groups, droppedPlayerIds) {
|
||||
if (!Array.isArray(droppedPlayerIds)) return groups;
|
||||
|
||||
var dropSet = new Set(droppedPlayerIds);
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.players = group.players.filter(function (player) {
|
||||
return !dropSet.has(player.id);
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate print rows for a group.
|
||||
*
|
||||
* @param {Object} group
|
||||
* @param {number} headerRows
|
||||
* @param {number} spacerRows
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateGroupPrintRows(group, headerRows, spacerRows) {
|
||||
return headerRows + group.players.length + spacerRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate print layout with page breaks.
|
||||
*
|
||||
* @param {Array<Object>} groups
|
||||
* @param {Object} options
|
||||
* @param {number} options.rowsPerPage
|
||||
* @param {number} options.headerRows
|
||||
* @param {number} options.spacerRows
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function generatePrintLayout(groups, options) {
|
||||
var rowsPerPage = options.rowsPerPage;
|
||||
var headerRows = options.headerRows || 1;
|
||||
var spacerRows = options.spacerRows || 1;
|
||||
|
||||
var pages = [];
|
||||
var currentPage = {
|
||||
pageNumber: 1,
|
||||
groups: [],
|
||||
usedRows: 0
|
||||
};
|
||||
|
||||
groups.forEach(function (group) {
|
||||
var groupRows = calculateGroupPrintRows(group, headerRows, spacerRows);
|
||||
|
||||
if (currentPage.usedRows + groupRows > rowsPerPage) {
|
||||
pages.push(currentPage);
|
||||
currentPage = {
|
||||
pageNumber: currentPage.pageNumber + 1,
|
||||
groups: [],
|
||||
usedRows: 0
|
||||
};
|
||||
}
|
||||
|
||||
currentPage.groups.push(group);
|
||||
currentPage.usedRows += groupRows;
|
||||
});
|
||||
|
||||
pages.push(currentPage);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level helper that runs the full pipeline.
|
||||
*
|
||||
* @param {Array<Object>} players
|
||||
* @param {Array<string>} groupIds
|
||||
* @param {Object} printOptions
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export function buildGroupPrintPages(players, groupIds, printOptions) {
|
||||
var groups = calculateGroupSizes(players.length, groupIds);
|
||||
assignPlayersToGroups(players, groups);
|
||||
return generatePrintLayout(groups, printOptions);
|
||||
}
|
||||
152
code/websites/pokedex.online/src/utilities/json-utils.js
Normal file
152
code/websites/pokedex.online/src/utilities/json-utils.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* JSON Path Extraction Utility
|
||||
* Extracts property paths from JSON objects for filtering
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract all JSON paths from an object recursively
|
||||
* @param {Object} obj - Object to extract paths from
|
||||
* @param {string} prefix - Current path prefix
|
||||
* @param {Set} paths - Accumulated paths
|
||||
* @param {number} maxDepth - Maximum recursion depth
|
||||
* @param {number} currentDepth - Current recursion depth
|
||||
*/
|
||||
function extractPathsRecursive(
|
||||
obj,
|
||||
prefix = '',
|
||||
paths = new Set(),
|
||||
maxDepth = 5,
|
||||
currentDepth = 0
|
||||
) {
|
||||
if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
paths.add(path);
|
||||
|
||||
if (
|
||||
typeof obj[key] === 'object' &&
|
||||
obj[key] !== null &&
|
||||
!Array.isArray(obj[key])
|
||||
) {
|
||||
extractPathsRecursive(obj[key], path, paths, maxDepth, currentDepth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON paths from an array of objects
|
||||
* @param {Array} data - Array of objects
|
||||
* @param {number} sampleSize - Number of items to sample (default: 100)
|
||||
* @returns {Array<Object>} Array of {path, breadcrumb} objects
|
||||
*/
|
||||
export function extractJsonPaths(data, sampleSize = 100) {
|
||||
const paths = new Set();
|
||||
const sample = data.slice(0, Math.min(sampleSize, data.length));
|
||||
|
||||
sample.forEach(item => {
|
||||
extractPathsRecursive(item, '', paths);
|
||||
});
|
||||
|
||||
return Array.from(paths)
|
||||
.sort()
|
||||
.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue extracting paths from remaining items (lazy loading)
|
||||
* @param {Array} data - Full data array
|
||||
* @param {number} startIndex - Where to start processing
|
||||
* @param {Set} existingPaths - Existing paths to add to
|
||||
* @param {Function} callback - Callback when new paths found
|
||||
* @param {number} chunkSize - Items to process per chunk
|
||||
*/
|
||||
export function extractJsonPathsLazy(
|
||||
data,
|
||||
startIndex,
|
||||
existingPaths,
|
||||
callback,
|
||||
chunkSize = 100
|
||||
) {
|
||||
if (startIndex >= data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processChunk = index => {
|
||||
const end = Math.min(index + chunkSize, data.length);
|
||||
const chunk = data.slice(index, end);
|
||||
const newPaths = new Set(existingPaths);
|
||||
|
||||
chunk.forEach(item => {
|
||||
extractPathsRecursive(item, '', newPaths);
|
||||
});
|
||||
|
||||
const addedPaths = Array.from(newPaths).filter(p => !existingPaths.has(p));
|
||||
|
||||
if (addedPaths.length > 0) {
|
||||
addedPaths.forEach(p => existingPaths.add(p));
|
||||
callback(
|
||||
addedPaths.map(path => ({
|
||||
path,
|
||||
breadcrumb: path.replace(/\./g, ' › ')
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (end < data.length) {
|
||||
requestIdleCallback(() => processChunk(end));
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(() => processChunk(startIndex));
|
||||
} else {
|
||||
setTimeout(() => processChunk(startIndex), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from object using dot notation path
|
||||
* @param {Object} obj - Object to get value from
|
||||
* @param {string} path - Dot notation path (e.g., 'data.pokemonSettings.pokemonId')
|
||||
* @returns {any} Value at path or undefined
|
||||
*/
|
||||
export function getValueByPath(obj, path) {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text in the middle
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} matchIndex - Index of match for context
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
export function truncateMiddle(text, matchIndex = 0, maxLength = 100) {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const halfLength = Math.floor(maxLength / 2);
|
||||
const start = Math.max(0, matchIndex - halfLength);
|
||||
const end = Math.min(text.length, matchIndex + halfLength);
|
||||
|
||||
let result = '';
|
||||
|
||||
if (start > 0) {
|
||||
result += '...';
|
||||
}
|
||||
|
||||
result += text.substring(start, end);
|
||||
|
||||
if (end < text.length) {
|
||||
result += '...';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Performance Monitoring Utility
|
||||
* Wraps operations with console.time/timeEnd for development and production feature flag
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if performance monitoring is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPerfMonitoringEnabled() {
|
||||
return (
|
||||
import.meta.env.DEV ||
|
||||
localStorage.getItem('enablePerfMonitoring') === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor performance of an operation
|
||||
* @param {string} label - Operation label
|
||||
* @param {Function} fn - Function to monitor
|
||||
* @returns {Promise<any>|any} Result of the function
|
||||
*/
|
||||
export async function perfMonitor(label, fn) {
|
||||
if (!isPerfMonitoringEnabled()) {
|
||||
return typeof fn === 'function' ? await fn() : fn;
|
||||
}
|
||||
|
||||
const perfLabel = `⚡ ${label}`;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const result = typeof fn === 'function' ? await fn() : fn;
|
||||
const duration = performance.now() - startTime;
|
||||
console.debug(`${perfLabel}: ${duration.toFixed(3)}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(`${perfLabel} failed after ${duration.toFixed(3)}ms:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect device performance characteristics
|
||||
* @returns {Object} Performance info
|
||||
*/
|
||||
export function getDevicePerformance() {
|
||||
const cores = navigator.hardwareConcurrency || 4;
|
||||
const memory = navigator.deviceMemory || 4; // GB
|
||||
|
||||
return {
|
||||
cores,
|
||||
memory,
|
||||
isHighPerformance: cores >= 8 && memory >= 8,
|
||||
isMediumPerformance: cores >= 4 && memory >= 4,
|
||||
isLowPerformance: cores < 4 || memory < 4,
|
||||
recommendedChunkSize: cores >= 8 ? 1000 : cores >= 4 ? 500 : 250
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for performance optimization
|
||||
* @param {Function} fn - Function to debounce
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
export function debounce(fn, delay = 300) {
|
||||
let timeoutId;
|
||||
|
||||
return function (...args) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function for performance optimization
|
||||
* @param {Function} fn - Function to throttle
|
||||
* @param {number} limit - Limit in milliseconds
|
||||
* @returns {Function} Throttled function
|
||||
*/
|
||||
export function throttle(fn, limit = 100) {
|
||||
let inThrottle;
|
||||
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
fn.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,17 +39,9 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
communityId,
|
||||
page = 1,
|
||||
per_page = 25,
|
||||
states = [
|
||||
'pending',
|
||||
'checking_in',
|
||||
'checked_in',
|
||||
'accepting_predictions',
|
||||
'group_stages_underway',
|
||||
'group_stages_finalized',
|
||||
'underway',
|
||||
'awaiting_review',
|
||||
'complete'
|
||||
],
|
||||
// Challonge v2.1 tournament list supports these canonical states.
|
||||
// (Older v1-style states like "checking_in" are not accepted.)
|
||||
states = ['pending', 'in_progress', 'ended'],
|
||||
includeCommunities = false
|
||||
} = options;
|
||||
|
||||
@@ -61,6 +53,8 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
per_page
|
||||
};
|
||||
|
||||
let firstAuthError = null;
|
||||
|
||||
// Query all states in parallel
|
||||
const promises = states.map(state =>
|
||||
client.tournaments
|
||||
@@ -69,6 +63,10 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
state
|
||||
})
|
||||
.catch(err => {
|
||||
const status = err?.status || err?.errors?.[0]?.status;
|
||||
if ((status === 401 || status === 403) && !firstAuthError) {
|
||||
firstAuthError = err;
|
||||
}
|
||||
console.error(`Error querying ${state} tournaments:`, err);
|
||||
return [];
|
||||
})
|
||||
@@ -77,6 +75,16 @@ export async function queryAllTournaments(client, options = {}) {
|
||||
// Wait for all requests
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// If we hit an auth error and fetched nothing at all, surface the auth error
|
||||
// so the UI can prompt to connect/reconnect Challonge.
|
||||
const totalCount = results.reduce(
|
||||
(sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0),
|
||||
0
|
||||
);
|
||||
if (firstAuthError && totalCount === 0) {
|
||||
throw firstAuthError;
|
||||
}
|
||||
|
||||
// Flatten and deduplicate by tournament ID
|
||||
const tournamentMap = new Map();
|
||||
results.forEach(tournamentArray => {
|
||||
|
||||
308
code/websites/pokedex.online/src/views/AdminLogin.vue
Normal file
308
code/websites/pokedex.online/src/views/AdminLogin.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="admin-login">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>Admin Login</h1>
|
||||
<p class="description">
|
||||
Enter the admin password to access protected features
|
||||
</p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password">Admin Password</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="Enter admin password"
|
||||
class="form-input"
|
||||
:disabled="isLoading"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password"
|
||||
@click="showPassword = !showPassword"
|
||||
:title="showPassword ? 'Hide password' : 'Show password'"
|
||||
>
|
||||
{{ showPassword ? '👁️' : '👁️🗨️' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-login"
|
||||
:disabled="!password || isLoading"
|
||||
>
|
||||
<span v-if="isLoading">Logging in...</span>
|
||||
<span v-else>Login</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back to Home -->
|
||||
<div class="footer">
|
||||
<router-link to="/" class="link">← Back to Home</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="info-section">
|
||||
<div class="info-card">
|
||||
<h3>🔐 Protected Access</h3>
|
||||
<p>
|
||||
Admin login provides access to protected features like the
|
||||
Gamemaster Manager and other administration tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>🔒 Security</h3>
|
||||
<p>
|
||||
Your session is protected with JWT authentication. Tokens expire
|
||||
after 7 days of inactivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>📱 Device Specific</h3>
|
||||
<p>
|
||||
Your login is stored securely in your browser's local storage. Each
|
||||
device requires its own login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
|
||||
const router = useRouter();
|
||||
const { login, isLoading, error } = useAuth();
|
||||
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
await login(password.value);
|
||||
// Redirect to home or admin panel
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
// Error is automatically set in useAuth
|
||||
console.error('Login failed:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-login {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
margin: 0 0 2rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid #dc2626;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background-color: #f3f4f6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0.25rem;
|
||||
color: #6b7280;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-password:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #5568d3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.info-section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.info-card p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user