Compare commits

593 Commits

Author SHA1 Message Date
8775f8b1fe Refactor code for improved readability and consistency
- Updated CSRF middleware to enhance cookie value decoding.
- Reformatted OAuth proxy token store initialization for better clarity.
- Adjusted Challonge proxy router for consistent line breaks and readability.
- Enhanced OAuth router error handling and response formatting.
- Improved session router for better readability and consistency in fetching provider records.
- Refactored OAuth token store to improve key derivation logging.
- Cleaned up cookie options utility for better readability.
- Enhanced Challonge client credentials composable for consistent API calls.
- Streamlined OAuth composable for improved logging.
- Refactored main.js for better readability in session initialization.
- Improved Challonge v2.1 service error handling for better clarity.
- Cleaned up API client utility for improved readability.
- Enhanced ApiKeyManager.vue for better text formatting.
- Refactored ChallongeTest.vue for improved readability in composable usage.
2026-02-03 12:50:25 -05:00
700c1cbbbe Refactor authentication handling and improve API client security
- Updated OAuth endpoints for Challonge and Discord in platforms configuration.
- Implemented session and CSRF cookie initialization in main application entry.
- Enhanced Challonge API client to avoid sending sensitive API keys from the browser.
- Modified tournament querying to handle new state definitions and improved error handling.
- Updated UI components to reflect server-side storage of authentication tokens.
- Improved user experience in API Key Manager and Authentication Hub with clearer messaging.
- Refactored client credentials management to support asynchronous operations.
- Adjusted API client tests to validate new request configurations.
- Updated Vite configuration to support session and CSRF handling through proxies.
2026-02-03 12:50:11 -05:00
161b758a1b Add support ticket documentation and relevant attachments
- Created new markdown file for Support Ticket - 3224942 with a link to the support page.
- Added a separate markdown file for Supprt Tickets with the same link.
- Updated workspace files to include new markdown files and attachments.
- Added various attachments related to the support ticket, including images and PDFs.
2026-02-02 13:03:28 -05:00
84f1fcb42a 🗑️ Deprecate deploy-pokedex.js in favor of deploy.sh and update environment variable validation messages 2026-01-30 11:29:28 -05:00
fee8fe2551 Add Docker Compose configuration and environment files for local and production setups
- Created docker-compose.docker-local.yml for local testing of frontend and backend services.
- Added .env.development for development environment configuration.
- Introduced .env.docker-local for local Docker environment settings.
- Added .env.production for production environment configuration for Synology deployment.
2026-01-30 11:29:17 -05:00
4d14f9ba9c 🗑️ Remove outdated backup files 2026-01-30 13:44:39 +00:00
56ef1223c1 🗑️ Remove outdated backup and temporary Docker Compose configuration files 2026-01-30 05:53:41 +00:00
33ded78edc 🔧 Update feature flag toggle to allow overrides in both development and production for developer users 2026-01-30 05:52:04 +00:00
347dd44dcd 🐛 Fix incorrect method call for checking gamemaster features 2026-01-30 05:49:27 +00:00
852c9d31b1 Improve readability by adjusting spacing in permission checks 2026-01-30 05:47:55 +00:00
8776d645a5 🔒 Enhance developer tools access control with JWT and Discord OAuth permissions 2026-01-30 05:47:50 +00:00
70ad4a82fa 🎨 Improve readability by reformatting router-link elements in Home.vue 2026-01-30 05:47:02 +00:00
06b1ae6aa5 🔒 Add conditional rendering for Gamemaster tools based on visibility flag 2026-01-30 05:46:57 +00:00
fffaa01a73 Add feature flag check for gamemaster tools display 2026-01-30 05:46:47 +00:00
d7cf0d6fd9 Add Gamemaster features configuration to feature flags 2026-01-30 05:46:35 +00:00
91dcc19bd3 🔧 Simplify developer tools availability logic by removing authentication checks and using a feature flag 2026-01-30 05:46:29 +00:00
8daf9128bd Improve developer tools availability check logic 2026-01-30 05:40:42 +00:00
7c512f410f 🔒 Add Discord OAuth permission check for developer tools access 2026-01-30 05:40:37 +00:00
616524d5ef 🔧 Refactor Discord OAuth redirect URI assignment for improved readability 2026-01-30 05:36:41 +00:00
e102373026 ⚙️ Add validation for Discord OAuth configuration variables 2026-01-30 05:36:36 +00:00
ae7c1e449b 🔧 Refactor auth route setup for improved readability 2026-01-30 05:34:23 +00:00
8bcc9ca701 🔒 Add authentication route with secret and admin password configuration 2026-01-30 05:34:18 +00:00
8c45a748ef 🔒 Add authentication router integration to OAuth proxy 2026-01-30 05:34:13 +00:00
ee7f1d97f3 Add endpoint to fetch Discord user profile using access token 2026-01-30 05:34:01 +00:00
edd76e2db6 🗑️ Remove outdated backup files 2026-01-30 05:32:41 +00:00
2851f3df0a 🔧 Add environment preparation step for setting up Discord redirect URI before building 2026-01-30 05:31:51 +00:00
1ff0dcd103 🛠️ Add .env file for environment variable configuration 2026-01-30 05:31:32 +00:00
ff1f94b7df 🗑️ Remove outdated backup file and adjust Docker Compose configuration for OAuth redirect URIs 2026-01-30 05:28:54 +00:00
bab84ca531 🔧 Fix environment variable placement and port configuration in production Docker Compose file 2026-01-30 05:28:30 +00:00
eb9afeef26 🗑️ Remove outdated backup file and update Docker Compose configuration with Discord OAuth environment variables 2026-01-30 05:27:48 +00:00
f915d6d44c 🔧 Add Discord OAuth environment variables to production Docker Compose configuration 2026-01-30 05:26:55 +00:00
aa1379a11a 🔧 Add Discord redirect URI configuration for local deployment 2026-01-30 05:26:45 +00:00
8bb2cf05c0 🔒 Improve Discord OAuth configuration handling by formatting and ensuring environment variable checks 2026-01-30 05:26:39 +00:00
1f73b9744a 🔧 Update Discord OAuth to prioritize DISCORD_REDIRECT_URI over VITE_DISCORD_REDIRECT_URI and adjust error messages accordingly 2026-01-30 05:26:34 +00:00
6c952bbf48 🗑️ Remove outdated backup file 2026-01-30 05:23:19 +00:00
b9c73e0a4d 🗑️ Remove temporary Docker Compose configuration file for Pokedex.Online 2026-01-30 05:15:58 +00:00
4515f86e5c 🗑️ Remove outdated backup files 2026-01-30 05:15:56 +00:00
e8b2a2c16e 🎨 Reformat code for improved readability in environment variable processing 2026-01-30 05:14:17 +00:00
09fae4ef54 🔒 Add Discord admin user permissions and update developer access check logic 2026-01-30 05:14:12 +00:00
e886fd62d1 Improve logging format for Discord user access and error handling 2026-01-30 05:13:40 +00:00
2ff4160944 🔒 Add Discord user authentication and admin permissions check for developer tools access 2026-01-30 05:13:35 +00:00
2ef40ac5a3 🗑️ Remove outdated backup file and unused environment variables from Docker Compose configuration 2026-01-30 05:03:09 +00:00
7663d9ce4c 🔧 Remove unused OAuth-related environment variables from production Docker Compose configuration 2026-01-30 05:02:38 +00:00
f5629bce50 🗑️ Remove outdated backup files 2026-01-30 05:00:44 +00:00
c40f310fba 🛠️ Add backend health check endpoint to deployment script 2026-01-30 05:00:30 +00:00
8eb037474c 🗑️ Remove outdated backup files 2026-01-30 05:00:18 +00:00
aa4648027f 🔧 Fix backend health check by correcting duplicate function call and improving readability 2026-01-30 05:00:05 +00:00
cd67421c5f 🔧 Add path parameter to healthCheck function and update backend health check calls 2026-01-30 05:00:03 +00:00
ed7ee9e857 🔧 Fix variable scope issue by reassigning config outside of try block 2026-01-30 04:59:50 +00:00
34a84bd574 🗑️ Remove outdated backup file 2026-01-30 04:59:01 +00:00
d64d2a032e 🚀 Improve local deployment script with enhanced error handling, health checks, and logging 2026-01-30 04:58:44 +00:00
6dd1be08a4 🐳 Add local Docker deployment functionality with health checks and error handling 2026-01-30 04:58:39 +00:00
61613ac7d5 🎨 Adjust indentation and formatting for consistent code style in deployment script 2026-01-30 04:58:32 +00:00
e472db461d 🚀 Add local deployment support and improve error handling in deploy script 2026-01-30 04:58:27 +00:00
66a6db193b 🔧 Add fallback to localhost for undefined deployment targets 2026-01-30 04:57:51 +00:00
b82e2f2424 Add support for local deployment target in Pokedex deployment scripts 2026-01-30 04:57:41 +00:00
aa8b05d6bf 🗑️ Remove outdated backup files 2026-01-30 04:54:38 +00:00
b849690e5f 🗑️ Remove outdated backup files 2026-01-30 04:54:28 +00:00
52cf322a00 🛠️ Improve code readability by reformatting console logs and removing unnecessary whitespace 2026-01-30 04:53:23 +00:00
9fdfbb22d8 🔒 Improve SSH connection handling, enhance file transfer reliability, and update OAuth error handling and tests 2026-01-30 04:53:18 +00:00
ab595394be 🔧 Improve readability by reformatting error messages and logging statements in OAuth token exchange logic 2026-01-29 21:31:01 +00:00
2b34c0ccf5 Add support for Discord OAuth token exchange alongside existing Challonge integration 2026-01-29 21:30:47 +00:00
d62106abf5 🔑 Add OAuth token exchange endpoint with support for Discord and error handling 2026-01-29 21:30:09 +00:00
553f7b1aef Improve readability by reformatting computed properties and function calls 2026-01-29 21:24:42 +00:00
db8c4fff2c 🔒 Update Challonge client credentials handling with refined property names and methods 2026-01-29 21:24:37 +00:00
80d4ff9044 🔒 Rename deleteApiKey function to clearApiKey in Challonge API key deletion logic 2026-01-29 21:23:40 +00:00
56cbbfdc7a 🔑 Rename and refactor Challonge API key methods for clarity 2026-01-29 21:23:34 +00:00
94532a4e6b 🎨 Simplify tool card styling by removing specific "settings" class and associated styles 2026-01-29 21:22:38 +00:00
d9939217c7 🎨 Improve code readability by reformatting HTML and CSS for better structure and consistency 2026-01-29 21:21:55 +00:00
456374bb86 🎨 Enhance authentication UI with provider-specific status indicators and dynamic connection checks 2026-01-29 21:21:50 +00:00
e90b6486c8 🎨 Improve readability of authentication hub description in Home view 2026-01-29 21:20:36 +00:00
0537a5a4f6 🔒 Update API Key Manager to Authentication Hub with expanded platform support 2026-01-29 21:20:31 +00:00
f500619817 🚀 Add documentation for AUTH_HUB deployment completion 2026-01-29 21:15:47 +00:00
9d1168594e 📝 Add League ID and reference to Card Cycle document 2026-01-29 21:15:02 +00:00
6ce93a54b1 🔑 Update v-model binding for Challonge API key input field 2026-01-29 21:14:24 +00:00
ff1a4fa450 📝 Update workspace references from "League Approval" to "Things To Chat About" in Obsidian configuration 2026-01-29 21:14:01 +00:00
9a67fcfcc9 📝 Update workspace references from "Staff" to "League Approval" in Obsidian configuration 2026-01-29 21:13:56 +00:00
e2af29413f 🎨 Add styled info section with heading and paragraph elements 2026-01-29 20:56:08 +00:00
b8dbd73951 🧹 Remove unnecessary blank line in ChallongeTest.vue layout 2026-01-29 20:55:57 +00:00
90e9764658 🧹 Remove unused authentication configuration sections from ChallongeTest view 2026-01-29 20:55:52 +00:00
491ae26500 ⚙️ Add authentication settings link and description to ChallongeTest view 2026-01-29 20:55:47 +00:00
16dc093d96 ✏️ Reformat and improve readability of authentication-related UI and code structure 2026-01-29 20:55:37 +00:00
352485f626 🔒 Add authentication hub view for user login and registration 2026-01-29 20:55:32 +00:00
8022b0ea0a 🔒 Enhance developer tools availability check with permission validation 2026-01-29 20:47:48 +00:00
89cc8d378b 🔄 Reformat code for improved readability in OAuth callback logic 2026-01-29 20:47:22 +00:00
a2163d2f1b Enhance OAuth callback to support multiple providers and improve UI/UX styling 2026-01-29 20:29:36 +00:00
a00859030e 📂 Update workspace configuration and reorganize file references for "Staff" and related documents 2026-01-29 20:28:42 +00:00
70ecc08f22 🔄 Update ApiKeyManager to AuthenticationHub and add legacy redirects for backwards compatibility 2026-01-29 15:25:45 +00:00
6257c872e3 Add session summary documentation for Pokedex online 2026-01-29 15:24:22 +00:00
4297f2e807 Add documentation for applying code changes to the project 2026-01-29 15:24:03 +00:00
99a7d2de30 Add implementation summary for Pokedex Online project 2026-01-29 15:22:46 +00:00
536ce07b12 Add documentation for authentication hub progress 2026-01-29 15:22:26 +00:00
d7f88378e5 🧹 Clean up code formatting and fix minor syntax inconsistencies in useDiscordOAuth.js 2026-01-29 15:21:30 +00:00
9ce8422596 Add composable for handling Discord OAuth integration 2026-01-29 15:21:25 +00:00
8093cc8d2a 📝 Reformat code for improved readability and consistency in the OAuth composable 2026-01-29 15:21:20 +00:00
0a5e1b9251 🔒 Add OAuth support for authentication 2026-01-29 15:21:15 +00:00
b5ca44b96c 🛠️ Update platform configuration comments for clarity and formatting 2026-01-29 15:20:57 +00:00
2e33136d88 🎮 Add platform configuration for new gaming systems 2026-01-29 15:20:52 +00:00
875bbbec65 🔒 Add authentication hub implementation details 2026-01-29 15:20:38 +00:00
c6b166d265 🔧 Refactor computed property for better readability 2026-01-29 14:44:35 +00:00
e822dac6dc 🔒 Update DeveloperTools availability to show in production when authenticated 2026-01-29 14:44:29 +00:00
e6648f6ad0 🔧 Simplify destructuring assignments for state variables in useChallongeTests 2026-01-29 14:41:40 +00:00
a9c6454e8f 🔧 Fix binding of loading and error states to use reactive values 2026-01-29 14:40:35 +00:00
0f450461ac 🐛 Fix error binding in TournamentDetail component 2026-01-29 14:36:38 +00:00
9746e4b4f6 Improve error handling and formatting in tournament detail component 2026-01-29 14:34:45 +00:00
8801b62252 🔧 Improve error handling and logging for tournament detail loading errors 2026-01-29 14:34:40 +00:00
ecf32940ca Improve error message formatting and handling in TournamentDetail component 2026-01-29 14:33:12 +00:00
f11ca388f8 🔧 Improve error handling and messaging for tournament detail loading 2026-01-29 14:33:07 +00:00
d698705352 ⚠️ Update error handling to support both string and Error object types in TournamentDetail component 2026-01-29 14:31:53 +00:00
c5f1bfa15a 🔧 Simplify state property access in tournament details component 2026-01-29 14:29:50 +00:00
1c880e39d1 Expose tournamentDetailsState for direct access to loading and error states 2026-01-29 14:29:45 +00:00
8ba97c9d8b 🔧 Simplify base URL logic to always use nginx proxy for CORS handling 2026-01-29 14:27:27 +00:00
fa26c50ebc 🔧 Update Challonge API proxy configuration to simplify URL rewriting and enforce nginx proxy usage 2026-01-29 14:27:08 +00:00
819d7e0420 🔧 Update API route to remove /api/ prefix due to nginx configuration 2026-01-29 14:24:15 +00:00
702d923e92 Update progress to reflect completion of Phase 7 and successful production deployment 2026-01-29 14:22:28 +00:00
6812371662 Add test results for step 33 2026-01-29 14:22:09 +00:00
97c65e4132 🔧 Update Dockerfile to use npm install instead of npm ci for installing production dependencies 2026-01-29 14:19:03 +00:00
99457258db 🔧 Update Dockerfile to install only production dependencies using --omit=dev 2026-01-29 14:18:52 +00:00
47f0a0484b 🔧 Update default ports for deployment configuration 2026-01-29 14:15:05 +00:00
836fc70c49 ⚙️ Add backup file for Pokedex Online 2026-01-29 14:11:53 +00:00
f6e03a3998 ⚠️ Modify backend test handling to allow continuation on failure or absence 2026-01-29 14:11:43 +00:00
4de6bf4986 ⚙️ Add backup file for Pokedex Online 2026-01-29 14:10:44 +00:00
39a574c2c2 🔧 Update server dependency check to verify key modules in root node_modules 2026-01-29 14:10:31 +00:00
da9d658238 Add Terser and related dependencies for JavaScript minification 2026-01-29 14:07:37 +00:00
3533be1c8c Finalize production deployment setup and testing for Pokedex.Online with updated Docker configuration and documentation 2026-01-29 14:01:12 +00:00
ce21c1085f Update progress status for Phase 7 in project documentation 2026-01-29 13:58:36 +00:00
02ed0c971c 🚀 Automate deployment process with deploy.sh, update deployment documentation, and add deployment scripts to package.json 2026-01-29 13:58:21 +00:00
02513fec2a 🚀 Update deployment scripts to use a shell script with additional options and retain manual deployment option 2026-01-29 13:57:41 +00:00
9de0b19f94 Add pre-deployment and post-deployment checklists to deployment documentation 2026-01-29 13:57:11 +00:00
2db764104e 🛠️ Update deployment instructions with rollback procedures and script usage adjustments 2026-01-29 13:57:00 +00:00
c595623893 🚀 Add automated deployment script with detailed options and instructions in documentation 2026-01-29 13:56:49 +00:00
868e506ad8 🚀 Update deployment script for Pokedex Online 2026-01-29 13:53:39 +00:00
cb7c37ae04 Complete production build scripts with optimizations, new commands, Docker scripts, deployment shortcuts, and comprehensive documentation 2026-01-29 13:51:47 +00:00
6b73a73e14 📝 Update build documentation for clarity and accuracy 2026-01-29 13:51:20 +00:00
85334e502b 🛠️ Add new scripts for build, test, lint, and environment validation in package.json 2026-01-29 13:50:50 +00:00
098c9e30fd Improve code formatting and readability in build verification script 2026-01-29 13:50:37 +00:00
d2e03b1d62 Add script to verify build integrity for Pokedex Online 2026-01-29 13:50:32 +00:00
d3a16e5aa4 🎨 Fix inconsistent key formatting in manualChunks configuration 2026-01-29 13:50:12 +00:00
7fdc6e33f4 ️ Optimize production build configuration with chunk splitting, source maps, and increased asset limits 2026-01-29 13:50:07 +00:00
af7155f483 🛠️ Update build and deployment scripts with new commands for frontend, backend, and Docker operations 2026-01-29 13:49:55 +00:00
f476447357 🚀 Update production deployment progress and implement backend production readiness features 2026-01-29 13:20:25 +00:00
2f0f0e840f 🎨 Improve code formatting and spacing for readability in OAuth proxy server 2026-01-29 13:20:05 +00:00
c82e9ea5ec 🔒 Improve OAuth proxy with enhanced logging, configuration handling, health check middleware, and graceful shutdown support 2026-01-29 13:20:00 +00:00
05371a35f5 ⚙️ Improve OAuth token handling with enhanced logging and configuration usage 2026-01-29 13:19:46 +00:00
46808cf279 🎨 Reformat import statement for better readability 2026-01-29 13:19:41 +00:00
16c88d5fb7 🛠️ Refactor OAuth proxy server to improve configuration validation, logging, and middleware setup 2026-01-29 13:19:36 +00:00
0098155471 Simplify arrow function syntax and remove unnecessary whitespace in graceful shutdown utility 2026-01-29 13:19:23 +00:00
7bf1f9c459 🔧 Add graceful shutdown utility for server 2026-01-29 13:19:18 +00:00
ee24b9bffc 🧹 Clean up logger utility by removing extra whitespace and simplifying arrow function syntax 2026-01-29 13:19:05 +00:00
2327557764 🛠️ Update logger utility implementation 2026-01-29 13:19:00 +00:00
4615fa0ef1 Improve code readability by reformatting and reindenting environment validation logic 2026-01-29 13:18:46 +00:00
4d3818f961 🔧 Add environment variable validation utility 2026-01-29 13:18:41 +00:00
670fcf90df 🚀 Update deployment instructions in documentation 2026-01-29 06:38:06 +00:00
3511d4380b 🚀 Enhance deployment and Nginx configuration with security headers, optimized caching, API proxying, multi-container setup, and health checks 2026-01-29 06:37:42 +00:00
cfde33e38c 🛠️ Improve error message formatting for health check failures 2026-01-29 06:37:34 +00:00
9f8c7ed741 Enhance deployment script with separate health checks for frontend and backend services 2026-01-29 06:37:29 +00:00
ffe8f6c83d 🧹 Remove unnecessary whitespace in deploy script 2026-01-29 06:37:18 +00:00
fcbcd2ec96 🚀 Add functionality to transfer backend server files and directories during deployment 2026-01-29 06:37:13 +00:00
fd826807d8 🔧 Update deploy script to include backend port configuration 2026-01-29 06:36:59 +00:00
dff0cd964b 🔧 Update deploy script to log backend port separately 2026-01-29 06:36:39 +00:00
97e7ecd1b2 🔧 Update docker-compose modification logic to include backend port mapping and refine frontend port handling 2026-01-29 06:36:28 +00:00
ca36f33d69 🔧 Add support for specifying backend port in deployment configuration 2026-01-29 06:36:18 +00:00
a367485203 🚀 Enhance deployment script to include backend support, multi-container Docker management, and extended health checks 2026-01-29 06:36:08 +00:00
551a605a20 🚀 Update progress and complete Nginx configuration with enhanced security, caching, compression, and proxy settings 2026-01-29 06:34:48 +00:00
77ec6436a8 🔒 Enhance Nginx configuration with improved gzip settings, additional security headers, caching rules, API proxy settings, WebSocket support, health check endpoint, and updated error page handling 2026-01-29 06:34:24 +00:00
688f75babf Complete multi-container Docker setup for production deployment 2026-01-29 06:33:58 +00:00
dfc57b84fd 🔧 Add example environment variables for server configuration 2026-01-29 06:33:50 +00:00
4e3c1deaac 🗑️ Remove Dockerfile for Pokedex Online website 2026-01-29 06:33:24 +00:00
31c40ecd6b 🐳 Update Dockerfile to optimize image build process and dependencies 2026-01-29 06:33:14 +00:00
1799d4980a 🛠️ Format healthcheck commands for better readability in production Docker Compose file 2026-01-29 06:33:09 +00:00
be3072dc75 🔧 Update production Docker Compose configuration for Pokedex Online 2026-01-29 06:33:03 +00:00
85ad180bb5 🚧 Update progress status for Phase 7 production deployment in documentation 2026-01-29 06:32:34 +00:00
2eb6cd25be Update progress documentation to mark Phase 6 as complete and outline steps for Phase 7 2026-01-29 06:32:29 +00:00
bb6039cd7b 📝 Update progress notes to reflect accurate line count reduction and quality improvements in refactoring 2026-01-29 06:31:32 +00:00
a99a9db967 📝 Update progress details for ChallongeTest refactoring with revised line reduction and logic improvements 2026-01-29 06:31:22 +00:00
4293e0405a Update project progress to reflect completion of Phase 6 and updated step count 2026-01-29 06:30:49 +00:00
545903149e Complete ChallongeTest refactoring with 76% code reduction, achieving clean, maintainable, and fully-tested components 2026-01-29 06:30:39 +00:00
8c0e9c8d37 🎨 Simplify arrow function syntax in watch callback 2026-01-29 06:27:46 +00:00
96a9c07184 🔧 Refactor Challonge client and tournament logic to use reusable hooks, reducing code duplication and improving maintainability 2026-01-29 06:27:41 +00:00
1eb61c2a4b 🎨 Refactor tournament display logic by replacing inline components with reusable TournamentGrid and TournamentDetail components 2026-01-29 06:27:09 +00:00
a848110dec 🎛️ Replace inline API version and settings controls with reusable ApiVersionSelector component and update imports accordingly 2026-01-29 06:26:45 +00:00
0316fad26b Update test coverage with 77 new tests, including new tests for Challonge-related components and composables 2026-01-29 06:25:59 +00:00
36ac9b5eb1 🚀 Update progress on Pokedex.Online project with significant advancements in Phase 6, including completion of multiple steps, creation of new components and composables, and increased test coverage 2026-01-29 06:25:49 +00:00
1b942fdd26 🎮 Add backup file for ChallongeTest Vue component 2026-01-29 06:23:44 +00:00
0f12b0e865 🧪 Improve test readability and formatting in TournamentDetail component unit tests 2026-01-29 05:34:32 +00:00
adf8e1ab72 Add unit tests for TournamentDetail component 2026-01-29 05:34:27 +00:00
5dafcfa232 🎮 Add tournament detail component for displaying Challonge tournament information 2026-01-29 05:34:02 +00:00
66ad148ccc 🗑️ Remove unused Vitest configuration file 2026-01-29 05:33:35 +00:00
75a5e5ba47 🎨 Reformat test assertions and slot content for improved readability 2026-01-29 05:33:32 +00:00
ece566ea56 Add unit tests for TournamentGrid component in Challonge integration 2026-01-29 05:33:27 +00:00
e4f99e82f4 🎨 Improve code formatting for better readability in tournament status components 2026-01-29 05:32:59 +00:00
38603a46d8 🎨 Update TournamentGrid component styling and layout 2026-01-29 05:32:54 +00:00
66eae1ddcd Refactor tests to use radio button index for API version selection validation 2026-01-29 05:13:18 +00:00
5a56aa4cbc Add unit tests for Challonge API version selector component 2026-01-29 05:13:08 +00:00
8d9b5125cc 🎨 Improve code formatting and consistency in ApiVersionSelector component 2026-01-29 05:12:58 +00:00
ec1e1f794b Add API version selector component for Challonge integration 2026-01-29 05:12:53 +00:00
efc97eea31 🎯 Refactor test file for improved readability and consistency in formatting and mock function definitions 2026-01-29 05:12:20 +00:00
7b04d39768 Add unit tests for Challonge composable functions 2026-01-29 05:12:15 +00:00
7f06b56fde 📝 Update comments and formatting in useChallongeTests composable 2026-01-29 05:12:00 +00:00
df888ddfd7 🧪 Add unit tests for Challonge-related functionality 2026-01-29 05:11:56 +00:00
0b465d82c1 Update tests to check for defined properties instead of specific values 2026-01-29 05:10:41 +00:00
716f20039f 🎯 Simplify test code formatting and improve readability in useChallongeClient unit tests 2026-01-29 05:10:36 +00:00
cb75fa34a0 Add unit tests for Challonge client composable 2026-01-29 05:10:30 +00:00
243e4c2eda 🧹 Remove unit tests for useChallongeClient composable 2026-01-29 05:10:20 +00:00
2723b96c0b 🎨 Reformat import statements and destructuring for improved readability 2026-01-29 05:09:43 +00:00
0148d0a1f1 🧪 Add unit tests for useChallongeClient composable and refactor code formatting for readability 2026-01-29 05:09:38 +00:00
568224f2d9 Add composable for interacting with Challonge API 2026-01-29 05:09:33 +00:00
cb23c5e923 Update progress notes to reflect completion of Phase 5 and increased test coverage 2026-01-29 05:08:05 +00:00
eaf43a54c8 Update progress to mark Phase 5 as complete and outline tasks for Phase 6 2026-01-29 05:08:00 +00:00
c077989f51 Update test coverage details with new passing tests and files 2026-01-29 05:07:50 +00:00
ba0c84fa77 Refactor GamemasterExplorer component with new composables, extracted components, comprehensive tests, and significant line count reduction 2026-01-29 05:07:40 +00:00
ea98011fbc Update progress for GamemasterExplorer refactoring to reflect completion and detailed results 2026-01-29 05:07:35 +00:00
c292ca72d4 Update progress tracker with latest phase, step, and test suite completion stats 2026-01-29 05:07:25 +00:00
e87f8e6dd4 Extend mocked useJsonFilter composable with additional methods and properties in unit tests 2026-01-29 05:06:09 +00:00
3ef1cd8300 🧪 Add mock implementation for search history in GamemasterExplorer tests 2026-01-29 05:05:53 +00:00
e55740782e 🎨 Simplify import formatting and improve code readability in unit tests for GamemasterExplorer component 2026-01-29 05:05:47 +00:00
dbf60a4860 🔧 Add async handling to test cases in GamemasterExplorer unit tests 2026-01-29 05:05:42 +00:00
6f6ddb3660 ️ Add async handling to loading state test in GamemasterExplorer component 2026-01-29 05:05:32 +00:00
bea0f15566 🔍 Add unit tests for GamemasterExplorer view 2026-01-29 05:05:22 +00:00
5feab84540 🛠️ Update FileSelector test to reflect that loadStatus is handled by the parent component 2026-01-29 05:03:11 +00:00
ff7f19a790 🔧 Update mock implementation of useFeatureFlags to use a value function for getFlags 2026-01-29 05:02:26 +00:00
ef1a2d2210 🐛 Fix incorrect function call for retrieving flags 2026-01-29 05:02:11 +00:00
48e785770e 🔧 Fix computed property to correctly reference getFlags value 2026-01-29 04:59:52 +00:00
45a9eb5099 🎨 Adjust styling by adding a warning banner class definition 2026-01-29 04:58:27 +00:00
d75c52c93e 🎨 Enhance dark mode and syntax highlighting styles for JSON viewer 2026-01-29 04:57:59 +00:00
d755e77c44 🎨 Ensure transparent backgrounds for all syntax highlighting elements in both light and dark modes 2026-01-29 04:56:48 +00:00
4952bc7649 🎨 Enhance syntax highlighting styles with !important for consistent appearance 2026-01-29 04:54:35 +00:00
7a1fb11dfb 🎨 Improve light and dark mode styles for content viewer with enhanced contrast and color adjustments 2026-01-29 04:51:46 +00:00
0639d577a1 🛠️ Remove redundant loadStatus call in FileSelector component 2026-01-29 04:50:14 +00:00
aa8a3e2aab ️ Make loadStatus asynchronous in onMounted lifecycle method 2026-01-29 04:50:04 +00:00
81310abbce 🔧 Simplify API endpoint paths by removing redundant '/api' prefix in GamemasterManager.vue 2026-01-29 04:45:34 +00:00
13e6ce7467 🔧 Simplify API proxy rewrite rule in Vite configuration 2026-01-29 04:42:32 +00:00
0cc01aa476 📝 Format error messages for Challonge OAuth configuration consistently 2026-01-29 04:35:19 +00:00
8650920c7c 🔒 Add validation for Challonge OAuth configuration in token and refresh endpoints 2026-01-29 04:35:14 +00:00
4769193ae4 ⚠️ Update OAuth proxy to warn instead of exit when Challonge credentials are missing 2026-01-29 04:35:03 +00:00
fc2a528d89 Refactor server save logic to include status reload and improve error handling 2026-01-29 04:32:22 +00:00
bb05c7e3e1 Fix syntax issue in asynchronous function definition for Gamemaster processing 2026-01-29 04:30:18 +00:00
87a7f28cf8 🔄 Refactor async state management to simplify and streamline API calls in GamemasterManager component 2026-01-29 04:30:07 +00:00
cb81e29c31 🎨 Reformat computed properties and conditional assignments for improved readability 2026-01-29 04:27:56 +00:00
835168179e Enhance reactivity and null safety in file state management with computed properties and conditional checks 2026-01-29 04:27:51 +00:00
6c5ed223c6 🎨 Reformat code for improved readability in FileSelector component 2026-01-29 04:27:32 +00:00
a5592f3857 🎨 Refactor computed properties and method bindings for improved readability and maintainability 2026-01-29 04:27:27 +00:00
5ac738a689 Add onMounted lifecycle hook and loadStatus state to GamemasterExplorer component 2026-01-29 04:23:14 +00:00
c47e2199bb 🐛 Fix incorrect method call syntax for retry button action 2026-01-29 04:20:50 +00:00
0bd9576426 🗑️ Remove unnecessary closing script tag from GamemasterExplorer.vue 2026-01-29 04:20:00 +00:00
3d0b848699 🎨 Refactor and optimize GamemasterExplorer.vue by restructuring template, improving component usage, and enhancing code readability and maintainability 2026-01-29 04:13:27 +00:00
6fb5190582 📝 Update progress documentation to reflect adjustments in completed steps and phase status 2026-01-29 04:05:39 +00:00
61236dc0b2 Update progress to reflect completion of GamemasterExplorer refactoring and an additional completed step 2026-01-29 04:04:29 +00:00
f2e1725156 🔄 Refactor GamemasterExplorer.vue to modularize functionality using composables and simplify template structure 2026-01-29 04:04:16 +00:00
cac222a39b 🎨 Improve code readability by reformatting computed property and component props 2026-01-29 04:02:18 +00:00
4c50f296fc 🎨 Refactor components to use modular structure and computed properties for improved maintainability and reusability in Gamemaster Explorer and FilterPanel 2026-01-29 04:02:13 +00:00
6cce9ba646 Add support for external selection state in ActionToolbar component 2026-01-29 04:00:47 +00:00
1d6e1ca196 🎨 Improve readability by reformatting computed property definition 2026-01-29 04:00:42 +00:00
51ca3dc2c4 Add support for external selection state in JsonViewer component 2026-01-29 04:00:37 +00:00
9f41ad7817 🎛️ Update FileSelector to use computed activeFilesState with default props 2026-01-29 04:00:32 +00:00
8e35ab4f15 🔍 Refactor computed property for active search state to improve readability 2026-01-29 04:00:27 +00:00
6b31eab984 🔍 Add support for external search state in SearchBar component 2026-01-29 04:00:22 +00:00
02f198db93 Update progress to reflect completed ActionToolbar and additional FilterPanel enhancements 2026-01-29 03:59:03 +00:00
b97199e286 Update progress documentation to reflect updated test suite results 2026-01-29 03:58:53 +00:00
6f89686862 Add unit tests for ActionToolbar component in gamemaster module 2026-01-29 03:57:44 +00:00
f16d261476 Add action toolbar component for gamemaster functionality 2026-01-29 03:57:34 +00:00
7713e51c50 Update progress for GamemasterExplorer refactoring and complete FilterPanel component implementation 2026-01-29 03:57:10 +00:00
8eaff2b134 Update progress and test suite statistics in project documentation 2026-01-29 03:57:05 +00:00
78a1e6bc69 Add unit tests for FilterPanel component in gamemaster module 2026-01-29 03:56:55 +00:00
2945042d34 🎨 Improve code readability by reformatting template and computed properties 2026-01-29 03:56:40 +00:00
139c58cdae Add filtering functionality to the gamemaster panel 2026-01-29 03:56:35 +00:00
92d60f9cfc Update progress for GamemasterExplorer refactoring and mark JsonViewer component as complete 2026-01-29 03:55:43 +00:00
02959bb13a 📈 Update progress metrics and test suite results in project documentation 2026-01-29 03:55:38 +00:00
b07c74d3c1 🧪 Add unit tests for JsonViewer component in gamemaster 2026-01-29 03:55:21 +00:00
199c46b3e6 🎨 Improve code readability by adjusting formatting of the JSON viewer component 2026-01-29 03:55:05 +00:00
418c2cb20f Add JSON viewer component for displaying game master data 2026-01-29 03:55:00 +00:00
c6fc7894dc Update progress for GamemasterExplorer refactoring and complete FileSelector component with tests 2026-01-29 03:53:27 +00:00
5509bb3500 📈 Update progress and test suite metrics in project documentation 2026-01-29 03:53:22 +00:00
a951af24e3 🎨 Simplify mock data initialization in FileSelector component tests 2026-01-29 03:53:00 +00:00
3ebfc1a519 Add FileSelector component and corresponding unit tests 2026-01-29 03:52:54 +00:00
49270e6727 🎨 Simplify formatting and improve readability in SearchBar component 2026-01-29 03:51:11 +00:00
f30c7880f6 Update progress documentation to reflect completed SearchBar component and additional passing tests 2026-01-29 03:51:06 +00:00
e98cb05b14 Improve code formatting and readability, handle edge cases in tests, and enhance lazy path extraction logic 2026-01-29 03:47:37 +00:00
9507059ad9 Complete Step 16 by extracting and testing the useJsonFilter composable with comprehensive functionality and test coverage 2026-01-29 03:47:32 +00:00
78e5dd9217 Update progress documentation to reflect one additional completed step 2026-01-29 03:28:44 +00:00
1218d0d226 Update test coverage details and adjust test counts in progress documentation 2026-01-29 03:28:28 +00:00
a190f9b324 🎯 Update progress on GamemasterExplorer refactoring with new composable for line selection and additional tests 2026-01-29 03:28:23 +00:00
832b3e9cc3 🗑️ Remove unused Vitest configuration file 2026-01-29 03:28:08 +00:00
12ea08a7e1 Add unit tests for useLineSelection composable 2026-01-29 03:28:06 +00:00
b97d1c1f71 Update test coverage details with new passing tests and added file 2026-01-29 03:26:52 +00:00
ce90dac264 Update progress on GamemasterExplorer refactoring with new composable extraction and additional tests 2026-01-29 03:26:47 +00:00
29aadc41ea 🧪 Update unit test to directly set preferences and validate file content handling 2026-01-29 03:26:35 +00:00
b0ad499b7e 🧪 Simplify and improve test cases for expandDisplayLinesToInclude in useGamemasterFiles composable 2026-01-29 03:26:20 +00:00
c99fdec60d 🔧 Simplify and refactor test for large file handling by replacing mocked data with manual line array setup 2026-01-29 03:26:15 +00:00
69354f5b71 Refactor test to verify successful file loading and content handling instead of error handling 2026-01-29 03:26:10 +00:00
9254bf2f80 🔄 Update test data structure in useGamemasterFiles to use objects instead of plain text for large file simulation 2026-01-29 03:24:48 +00:00
9b4b418724 🔧 Update tests to handle large JSON data and improve file loading error handling 2026-01-29 03:24:38 +00:00
a34468275f 🧪 Improve test by adding setup for available files before simulating load failure 2026-01-29 03:24:33 +00:00
a69ebafb2c 🧪 Update test to handle large file content and improve line expansion logic 2026-01-29 03:24:03 +00:00
c7696eb4bb Improve test for setting correct line numbers by adding line generation and conditional checks 2026-01-29 03:23:53 +00:00
2f636ab08d 🧪 Update test to use large string content instead of object for large file handling 2026-01-29 03:23:48 +00:00
a92c49b178 🔧 Fix error handling and update test logic for loading moves in useGamemasterFiles test 2026-01-29 03:23:38 +00:00
6f72715726 🧪 Simplify unit test by removing unnecessary mock data and adding a prerequisite function call 2026-01-29 03:23:25 +00:00
619aab0309 🧹 Simplify test code by condensing multi-line statements into single lines where applicable 2026-01-29 03:21:14 +00:00
e1bf447c21 Add unit tests for useGamemasterFiles composable 2026-01-29 03:20:51 +00:00
5d8f753659 🎨 Improve code readability by reformatting imports, refining filter and sort logic, and simplifying function syntax 2026-01-29 03:20:34 +00:00
3fdc9a510d Add composable for handling Gamemaster file operations 2026-01-29 03:20:29 +00:00
7498aa5e73 📈 Update progress and test coverage details in project documentation 2026-01-29 03:19:18 +00:00
8b1a24ded8 Update progress on GamemasterExplorer refactoring with completion of useGamemasterSearch composable implementation and tests 2026-01-29 03:19:13 +00:00
98b0d9b298 🧹 Remove unnecessary whitespace in unit test file 2026-01-29 03:18:57 +00:00
b5a78e5283 🧪 Refactor unit tests to improve clarity and ensure proper handling of debounced search and clearing results 2026-01-29 03:18:52 +00:00
069ac1cea2 🧪 Refactor unit tests for executeSearch to separate query setting and result clearing logic 2026-01-29 03:18:47 +00:00
5bfe7c6078 Improve regex escaping logic in unit test for special characters 2026-01-29 03:18:36 +00:00
3c7e84b21a Add unit tests for Gamemaster search composable 2026-01-29 03:18:32 +00:00
17d24b72d1 Improve search term escaping and formatting in Gamemaster search functionality 2026-01-29 03:18:18 +00:00
777bcae010 🔍 Add functionality for gamemaster search in composable 2026-01-29 03:18:13 +00:00
7e3e2191fa 📝 Update PROGRESS.md with detailed plans for phases 5-8 refactoring and production deployment steps 2026-01-29 02:11:09 +00:00
1761b466d1 📝 Update progress documentation to reflect completion of Phases 1-4 and outline next steps for GamemasterExplorer refactoring 2026-01-29 02:09:13 +00:00
771dd91118 🚩 Add feature flags system with configuration, state management, conditional rendering, developer tools, router integration, and comprehensive testing 2026-01-29 02:08:52 +00:00
be79a96387 Add feature flag system with authentication, developer tools panel, integration, and comprehensive tests 2026-01-29 02:08:34 +00:00
8f3b051db1 Update project progress to reflect completion of additional phases and steps 2026-01-29 02:08:14 +00:00
03bf0f38d6 Add unit tests for router guards to verify route protection with authentication and feature flags 2026-01-29 02:07:47 +00:00
f31e5f8840 Add unit tests for router guards to improve coverage and reliability 2026-01-29 02:07:42 +00:00
fcc734a9bc Add unit tests for FeatureFlag component to ensure proper functionality 2026-01-29 02:07:25 +00:00
7b5b80d1d1 🛡️ Improve logging format for feature flag and protected route navigation 2026-01-29 02:07:03 +00:00
fcfc4215e0 🚦 Add feature flag checks to route guards and logging for flagged routes 2026-01-29 02:06:58 +00:00
fb92606f44 Refactor ChallongeTest.vue with useAsyncState for cleaner state management and update progress documentation 2026-01-29 02:04:46 +00:00
8afaf18985 Update progress documentation to reflect completion of phase 2 and updated step count 2026-01-29 02:04:29 +00:00
24a70d1cd7 Mark Phase 2 as complete in project progress documentation 2026-01-29 02:04:24 +00:00
df6dcd03d1 Update progress with completed tasks and refactor async operations to use useAsyncState 2026-01-29 02:04:19 +00:00
fb2629334c 🔄 Refactor error handling and state reset logic for improved clarity and consistency in tournament management 2026-01-29 02:03:45 +00:00
6835e3a7b8 🎮 Refactor tournament details handling to use tournamentDetailsState with error management and remove redundant assignments 2026-01-29 02:03:33 +00:00
1f85443db9 Refactor tournament loading logic to use loadMoreState for improved error handling and state management 2026-01-29 02:03:28 +00:00
b2de57e4ef Refactor tournament listing logic to use state management and simplify error handling 2026-01-29 02:03:18 +00:00
4ac4b9a3f6 Add computed property for tournament details 2026-01-29 02:03:06 +00:00
5e7e411a52 🔄 Refactor state management to use useAsyncState for tournaments and loading states 2026-01-29 02:02:56 +00:00
7dc792abc9 Add Vue 3 composable for group sizing and print-safe layout generation 2026-01-29 02:00:27 +00:00
308001594b Add composable for group print layout functionality 2026-01-29 01:59:50 +00:00
beff7cd832 Add utility function for calculating group sizes 2026-01-29 01:58:23 +00:00
43bc719292 🚀 Update roadmap to include Progressive Web App (PWA) capabilities 2026-01-28 23:04:09 +00:00
4f6b12feee 🔄 Update project structure and documentation to reflect migration from React to Vue 2026-01-28 23:04:03 +00:00
cc7d66c40b 🎨 Update framework details and technical implementation to reflect migration from React to Vue 3 2026-01-28 23:03:54 +00:00
84711b0c98 🔄 Update README to reflect use of Vue 3, modern JavaScript features, and enhanced test coverage 2026-01-28 23:03:44 +00:00
00b8d01cff 🔄 Update README to reflect migration from React to Vue and include relevant documentation links 2026-01-28 23:03:39 +00:00
1e892cc407 🎨 Update README to reflect migration from React to Vue and reorganize project structure documentation 2026-01-28 23:03:34 +00:00
855ee207fe 🔄 Update README to reflect changes in tech stack and framework details 2026-01-28 23:03:24 +00:00
923dfefb5a 🔄 Update project description to reflect migration from React to Vue 3 and include additional technical details 2026-01-28 23:03:18 +00:00
52ed9c04c0 🛠️ Update README with detailed tech stack, features, development metrics, security notes, and contribution guidelines 2026-01-28 23:01:33 +00:00
6a28e72c6c 📝 Simplify Docker deployment instructions and update project structure, testing, and coverage documentation in README 2026-01-28 23:01:12 +00:00
dae271f8a8 🚀 Simplify and streamline README with updated prerequisites, quick start instructions, and development commands 2026-01-28 23:01:02 +00:00
29d9629a5d Update README to reflect migration to React + Vite and highlight new features and production-ready status 2026-01-28 23:00:52 +00:00
fad07f5d90 Update progress documentation with latest project milestones 2026-01-28 23:00:37 +00:00
90159b2055 🧹 Remove unnecessary whitespace in unit tests for useFeatureFlags 2026-01-28 23:00:20 +00:00
ecf7e1f316 Add unit tests for feature flag composable 2026-01-28 23:00:15 +00:00
d77706be9f 🧹 Remove unit tests for useFeatureFlags composable 2026-01-28 23:00:05 +00:00
622cec5e2d 🧹 Remove unnecessary whitespace in unit tests for useFeatureFlags 2026-01-28 22:59:39 +00:00
6ec72ff36f Add unit tests for feature flag composable 2026-01-28 22:59:34 +00:00
d9d0de243d 🧹 Remove unnecessary whitespace in unit tests for DeveloperTools component 2026-01-28 22:59:24 +00:00
d94ad418c8 🧪 Simplify DeveloperTools tests by removing complex DOM interactions and redundant test cases, while adding mocks and focusing on core functionality 2026-01-28 22:59:19 +00:00
bb344d4096 🔧 Refactor environment mocking and component stubs in DeveloperTools unit tests 2026-01-28 22:58:05 +00:00
77da2ef580 🔧 Refactor test setup to use vi.spyOn for mocking useAuth module instead of direct mock replacement 2026-01-28 22:57:55 +00:00
50664739fe 🔧 Update import paths in DeveloperTools unit test for correct module resolution 2026-01-28 22:55:32 +00:00
2063c65efd 🔧 Fix import paths in useFeatureFlags unit test file 2026-01-28 22:55:22 +00:00
d3ac20f9fa 🧪 Add unit tests for DeveloperTools component 2026-01-28 22:54:40 +00:00
cf106cd5f3 Refactor unit tests for useFeatureFlags to improve readability, consistency, and formatting 2026-01-28 22:54:28 +00:00
338ee1f750 Add unit tests for feature flag composable 2026-01-28 22:54:23 +00:00
59823392e1 🛠️ Add Developer Tools component for development mode with improved formatting and functionality adjustments 2026-01-28 22:54:07 +00:00
740005e4b8 Add Developer Tools component for enhanced debugging features 2026-01-28 22:54:02 +00:00
a736519d76 🎨 Refactor code style for consistency and readability in feature flag composable 2026-01-28 22:53:48 +00:00
5151846a88 Add composable for managing feature flags 2026-01-28 22:53:43 +00:00
bc48762925 Add feature flags configuration for Pokedex Online 2026-01-28 22:53:33 +00:00
9c1d836e4f 🔒 Add documentation for authentication architecture in Pokedex.Online 2026-01-28 22:51:34 +00:00
4bbcdbb96e 🔒 Add JWT authentication system with backend, frontend, API client, route guards, and unit tests for secure user management and token-based access control 2026-01-28 22:51:15 +00:00
8f74fef02a Add JWT authentication system with backend utilities, middleware, routes, frontend state management, router guards, and authentication tests 2026-01-28 22:50:15 +00:00
88f426127e Update progress documentation with completed steps count 2026-01-28 22:50:01 +00:00
42f9acc4fa 🧹 Remove unnecessary whitespace in useAuth composable unit tests 2026-01-28 22:48:55 +00:00
8f06e50820 Add unit tests for authentication composable 2026-01-28 22:48:50 +00:00
d3c6f45757 Add unit tests for AdminLogin view to improve test coverage 2026-01-28 22:48:45 +00:00
0c0cc33e1e 🗑️ Remove unit tests for JWT utility functions 2026-01-28 22:48:35 +00:00
74f0e1e252 🛠️ Simplify code formatting and improve consistency in API client and JWT utilities tests 2026-01-28 22:48:11 +00:00
e51d484083 🛠️ Add default header management methods to API client 2026-01-28 22:48:06 +00:00
56578917e8 ✏️ Adjust line breaks in admin login descriptions for improved readability 2026-01-28 22:46:49 +00:00
07a6f902d9 🔒 Add admin login view for authentication 2026-01-28 22:46:44 +00:00
af34c7c719 Improve code formatting and update API request payload structure in authentication composable 2026-01-28 22:46:37 +00:00
1e97e190c5 🔒 Add authentication composable for managing auth state, token handling, and user info 2026-01-28 22:46:32 +00:00
f132339abf 🎨 Reformat import statement for improved readability 2026-01-28 22:46:27 +00:00
6d44590dff 🔒 Add authentication route logic for user login and registration 2026-01-28 22:46:22 +00:00
5d0c428dcd Improve readability by reformatting conditional expressions and code structure in authentication middleware 2026-01-28 22:46:17 +00:00
0044f1e965 🔒 Add authentication middleware for secure access 2026-01-28 22:46:10 +00:00
ac0a31c071 Refactor JWT utility functions for improved readability and consistency 2026-01-28 22:46:07 +00:00
3434a361d9 🔒 Update JWT utility functions for improved token handling 2026-01-28 22:46:02 +00:00
b6cbc12c5f 🎨 Refactor components to use shared utilities, simplify state management, and improve modal implementation 2026-01-28 22:45:08 +00:00
7048ee7a77 Update components to use shared utilities, BaseModal, and api-client while verifying build passes 2026-01-28 22:44:58 +00:00
bb558be6f8 Update progress documentation to reflect one additional completed step 2026-01-28 22:44:53 +00:00
3ae5a93e57 🗑️ Remove integration tests for ApiKeyManager and GamemasterManager components 2026-01-28 22:44:40 +00:00
177ae5c60d 🎨 Reformat test code for improved readability and consistency 2026-01-28 22:42:22 +00:00
d782eba3b4 🔑 Add integration tests for API key management 2026-01-28 22:42:16 +00:00
c87d494eee 📝 Simplify modal text formatting and button layout in API key manager 2026-01-28 22:42:11 +00:00
b07f1fd61a 🎨 Refactor delete confirmation modal to use BaseModal component for improved consistency and reusability 2026-01-28 22:42:06 +00:00
ae8832daff Add BaseModal import and simplify useAsyncState calls in ApiKeyManager and GamemasterManager views 2026-01-28 22:42:01 +00:00
0515fb7958 🚀 Refactor GamemasterManager to use reusable async state management for loading, saving, and error handling 2026-01-28 22:41:56 +00:00
d5ba1d5ab1 📝 Update progress documentation with new test coverage details and adjust coverage target 2026-01-28 22:26:33 +00:00
9dafca49b0 Update test coverage details to reflect increased passing tests count 2026-01-28 22:26:23 +00:00
67829cd09b Mark BaseButton and BaseModal components as complete with tests passing 2026-01-28 22:26:18 +00:00
c7b78e32c7 Update progress documentation with completed steps count 2026-01-28 22:26:08 +00:00
35e9a97708 🧹 Remove unnecessary whitespace in BaseModal unit tests 2026-01-28 22:25:42 +00:00
fa8286fa54 🧪 Improve BaseModal test to handle timing-dependent overflow checks and verify cleanup 2026-01-28 22:25:37 +00:00
5eb704af2d 🧪 Improve BaseModal unit test to handle timing-dependent overflow behavior 2026-01-28 22:25:31 +00:00
bf38a226fc 🧪 Add missing nextTick call in BaseModal unit test to ensure proper DOM updates 2026-01-28 22:25:15 +00:00
61fe7c6594 🧪 Add additional nextTick calls in BaseModal unit tests to ensure proper DOM updates 2026-01-28 22:25:10 +00:00
155a3d3985 🧪 Update BaseModal test to verify element existence instead of active focus due to JSDOM limitations 2026-01-28 22:25:00 +00:00
5453f80317 🧪 Improve test readability by reformatting code for better clarity and consistency in BaseModal unit tests 2026-01-28 22:24:43 +00:00
6d3d81c3c0 Add unit tests for BaseModal component 2026-01-28 22:24:39 +00:00
4cb66eafbd 🎨 Refactor code style for consistency and readability in BaseModal component 2026-01-28 22:24:11 +00:00
94b1828c88 Add BaseModal component for shared modal functionality 2026-01-28 22:24:06 +00:00
247d4330b2 Add support for slot detection in BaseButton component 2026-01-28 22:23:29 +00:00
2d89f09b92 Add slot support to BaseButton component 2026-01-28 22:23:14 +00:00
e061278123 🧹 Remove unnecessary whitespace in BaseButton component unit tests 2026-01-28 22:22:50 +00:00
be3fd84901 Add unit tests for BaseButton component 2026-01-28 22:22:45 +00:00
b06382c0bb 🎨 Improve code readability and consistency in BaseButton component by reformatting and simplifying syntax 2026-01-28 22:22:35 +00:00
9467d4d81d Update progress documentation and mark Step 3 as completed in project tracker 2026-01-28 22:22:30 +00:00
77a5d09db1 🛠️ Add new dependencies and configure workspace for server module integration 2026-01-28 22:21:28 +00:00
1279cfaa4e 🛠️ Add workspace configuration and update scripts for server management 2026-01-28 22:19:13 +00:00
6b2e5795bc Add new dependencies for server functionality 2026-01-28 22:19:03 +00:00
cd85a0f0a4 📝 Update progress documentation for Pokedex.Online project 2026-01-28 22:17:59 +00:00
a8b5ee6e3c 🧪 Improve test formatting and fix spacing issues in api-client unit tests 2026-01-28 22:17:22 +00:00
2a8cfd4011 Add unit tests for API client utility functions 2026-01-28 22:17:17 +00:00
4c950b5686 Improve code readability and formatting in API client utility 2026-01-28 22:16:56 +00:00
d5ec0cd9db Add utility functions for API client integration 2026-01-28 22:16:51 +00:00
6dfe06a412 Simplify arrow function syntax in unit test 2026-01-28 22:16:38 +00:00
26ff87450e 🛠️ Update useAsyncState test to handle operation cancellation with abort signal 2026-01-28 22:16:33 +00:00
c91843d828 🧪 Refactor unit tests for useAsyncState to improve readability and formatting 2026-01-28 22:15:39 +00:00
84ea99c219 Add unit tests for useAsyncState composable 2026-01-28 22:15:34 +00:00
5fcd3fc768 🎨 Improve code formatting and readability in useAsyncState composable 2026-01-28 22:14:59 +00:00
d7207e5014 Add composable for managing asynchronous state 2026-01-28 22:14:54 +00:00
9e8c1f12aa 🧪 Add testing scripts using Vitest to package.json 2026-01-28 22:14:40 +00:00
0e0b23a3a1 🛠️ Configure testing setup and update Vitest configuration 2026-01-28 22:14:25 +00:00
fcdab93e55 🧪 Add testing dependencies including Vitest, JSDOM, and Vue Test Utils 2026-01-28 22:10:20 +00:00
ed5b819c41 📚 Update README with additional project details and instructions 2026-01-28 22:09:15 +00:00
cc71cf1a91 📚 Reorganize documentation structure and update README with new links and details 2026-01-28 22:08:56 +00:00
507d9d600f 🗑️ Remove outdated documentation files and consolidate content into a single README for improved organization and maintainability 2026-01-28 22:08:37 +00:00
ed44fad9bd 🔄 Refactor documentation to update the restructuring plan for Pokedex.Online 2026-01-28 22:08:27 +00:00
649c7733c3 Improve performance monitoring by replacing console.time with performance.now for more precise timing 2026-01-28 21:20:33 +00:00
94a4d01966 🔑 Add API key status check for badge display 2026-01-28 21:20:11 +00:00
6bf6bb3a1c 🎨 Improve code formatting and readability in scroll position calculation logic 2026-01-28 21:18:02 +00:00
1a398b2528 🎯 Improve virtual scroller to center items during scroll for better visibility 2026-01-28 21:17:57 +00:00
32698e5261 Enhance scrolling logic with virtual scroller API support for large files and improve fallback behavior 2026-01-28 21:16:02 +00:00
a8b92e1d3b Add ref attribute to virtual scroller for enhanced component referencing 2026-01-28 21:15:46 +00:00
c6cc4a290e Add ref for virtual scroller in GamemasterExplorer component 2026-01-28 21:15:36 +00:00
3dc5565df5 🎨 Simplify scroll position calculation formatting in GamemasterExplorer component 2026-01-28 21:14:16 +00:00
9507c7b304 🎯 Improve scroll-to-result logic for centering elements within containers 2026-01-28 21:14:11 +00:00
5584dd8502 Improve readability of scroll position calculation logic in GamemasterExplorer component 2026-01-28 21:13:26 +00:00
d70c8e23f4 🖱️ Refine scrolling behavior to center elements within their container instead of the whole page 2026-01-28 21:12:34 +00:00
b91e1da47b Improve code formatting by removing unnecessary whitespace 2026-01-28 21:11:20 +00:00
8b6ba2787e 🔧 Fix Web Worker compatibility by converting reactive array to plain array before posting 2026-01-28 21:11:15 +00:00
89a90e2adf 🛠️ Improve error handling for search worker initialization 2026-01-28 21:08:20 +00:00
fad082195b 🔧 Refactor search worker initialization to use a promise for improved error handling and retry capability 2026-01-28 21:08:10 +00:00
a2ec573d39 🛠️ Improve error handling and logging for search worker initialization 2026-01-28 21:07:53 +00:00
b6707d75de 🔧 Update search worker initialization to use Vite's ?worker syntax for proper bundling and improve error handling 2026-01-28 21:07:48 +00:00
d91765941a 🎨 Improve console log formatting for better readability in search worker 2026-01-28 21:07:34 +00:00
ddf2289ea6 🛠️ Add detailed logging and error handling to search worker 2026-01-28 21:07:29 +00:00
109a3f1995 🔧 Improve search worker initialization and logging for better error handling and debugging 2026-01-28 21:07:19 +00:00
74893f7f74 🔧 Improve logging and error handling for search worker operations 2026-01-28 21:07:14 +00:00
ba798ff999 🔧 Enhance search worker initialization with error handling and logging 2026-01-28 21:07:09 +00:00
c3d758fda6 Improve code formatting by removing unnecessary whitespace 2026-01-28 20:51:15 +00:00
b8a4539fe3 Highlight search result matches in display lines 2026-01-28 20:51:10 +00:00
8fc32c517e Improve code formatting by removing unnecessary whitespace 2026-01-28 20:36:01 +00:00
f758069c82 🔍 Offload search functionality to a web worker for improved UI responsiveness and performance 2026-01-28 20:35:56 +00:00
d30612a883 🎨 Simplify arrow function syntax in handleSearchWorkerMessage definition 2026-01-28 20:35:45 +00:00
94bb78346f 🔍 Add web worker for search operations to improve performance and handle search progress and results 2026-01-28 20:35:40 +00:00
15aa7d2fea 🔍 Simplify arrow function syntax in search worker's message handler 2026-01-28 20:35:30 +00:00
8beb332548 🔍 Add search functionality in the worker script 2026-01-28 20:35:25 +00:00
384e2df3f1 🔧 Refactor scrollToResult function for improved readability and formatting consistency 2026-01-28 20:33:38 +00:00
4d951130a5 🔍 Improve scrolling logic to dynamically load more lines when searching results exceed displayed lines 2026-01-28 20:33:33 +00:00
1cc137b9bb Improve readability by reformatting code for better line wrapping 2026-01-28 20:33:22 +00:00
b953f5b9dc 🔍 Update search logic to process all file lines and optimize display updates 2026-01-28 20:33:16 +00:00
653702dfc6 💡 Clarify line display logic to show limited lines initially while retaining full content for searching 2026-01-28 20:32:59 +00:00
bdbec024fc 🔄 Reorder conditional checks for consistent handling of 'pokemon' in file type and name formatting functions 2026-01-28 20:30:06 +00:00
b903505ac2 🔍 Update file type and name detection logic to handle case-insensitive 'allForms' keyword 2026-01-28 20:29:09 +00:00
765742c6c7 Improve file selection logic to prioritize largest files of each type 2026-01-28 20:28:04 +00:00
1b9a1d9386 Implement logic to display unique files by type, prioritizing the largest file for each type 2026-01-28 20:27:59 +00:00
469bbb186d Configure highlight.js to suppress HTML warnings with updated formatting 2026-01-28 20:25:32 +00:00
42447dd952 Configure highlight.js to suppress HTML warnings and remove redundant configuration 2026-01-28 20:25:27 +00:00
4079fa0975 Configure highlight.js to handle HTML safely with updated settings 2026-01-28 20:23:18 +00:00
9f241da3b0 Refactor router-view to use scoped slots with dynamic component rendering 2026-01-28 20:23:08 +00:00
0a53b7758f 🎨 Add placeholder for mobile-specific styles in GamemasterExplorer component 2026-01-28 20:22:05 +00:00
feaa990ea0 🎨 Refine UI styles for improved responsiveness, consistency, and readability across components 2026-01-28 20:21:16 +00:00
b4b1c77f49 🎨 Adjust layout and styling for file selector and search bar components 2026-01-28 20:20:38 +00:00
5614a6775b 🎨 Adjust UI styling with reduced padding, border radius, and shadow intensity for a more compact design 2026-01-28 20:20:33 +00:00
f4d394ae47 🧹 Remove unnecessary blank line in CSS media query 2026-01-28 20:19:38 +00:00
800baeb8f4 🎨 Adjust responsive styles and layout for improved header and button design 2026-01-28 20:19:20 +00:00
fd4d1d1358 🎨 Improve header layout and responsiveness in Gamemaster Explorer view 2026-01-28 20:17:54 +00:00
e9a461a478 Improve search term escaping for highlighting functionality 2026-01-28 20:15:18 +00:00
a687a51229 Enhance search highlighting by replacing regex usage with replaceAll for improved readability and consistency 2026-01-28 20:15:13 +00:00
6ccca7ae5e Improve scroll-to-result functionality with smoother scrolling and fallback adjustments 2026-01-28 20:14:52 +00:00
36804c1c24 🔄 Refactor scroll logic to improve reliability with virtual scroller rendering retries 2026-01-28 20:14:47 +00:00
2e9b34b266 🎨 Improve code readability by reformatting object literals and regex creation 2026-01-28 20:14:15 +00:00
06a8bf956e Enhance search result highlighting with current result indicator and improved match styling 2026-01-28 20:14:10 +00:00
4c89d15525 🔍 Enhance search result navigation with line number display and button tooltips 2026-01-28 20:12:46 +00:00
8e431e83f7 Improve scroll-to-result functionality with smoother scrolling and better fallback handling 2026-01-28 20:12:39 +00:00
6980cb8657 Enhance scrollToResult function with fallback and retry logic for better handling of virtual scroller scenarios 2026-01-28 20:12:34 +00:00
308df336c2 Add data-line attribute to display line numbers in GamemasterExplorer component 2026-01-28 20:10:01 +00:00
a130770bc0 🎨 Improve contrast and readability for syntax highlighting in light mode 2026-01-28 20:08:06 +00:00
f41f83ee2a 🗑️ Remove unnecessary closing brace from onFileChange function 2026-01-28 20:07:18 +00:00
2e94360a38 📝 Clarify file change handling and remove redundant file loading logic 2026-01-28 20:06:44 +00:00
bee38e5eaa 🔧 Simplify arrow function syntax in watch callback 2026-01-28 20:06:39 +00:00
8ca0c4b68c 🔄 Watch selectedFile changes to trigger file loading 2026-01-28 20:06:34 +00:00
65a8d64666 🔧 Add watch import to support reactive data handling in GamemasterExplorer component 2026-01-28 20:06:29 +00:00
fedb0496b4 🎨 Improve readability by reformatting theme assignment logic in highlight directive 2026-01-28 20:04:03 +00:00
9f18a7b3be Enhance highlight directive to support extended configuration and improve theme handling 2026-01-28 20:03:58 +00:00
300246397d 🎨 Adjust button styling to include a border and improve hover effect 2026-01-28 20:02:45 +00:00
ead6103dfb 🎨 Improve button styling with updated colors, border radius, font weight, hover effects, and transitions 2026-01-28 20:01:31 +00:00
a82a16f8e2 🎨 Update spinner color and add styling for error messages 2026-01-28 20:01:17 +00:00
d65039470e 🎨 Update styles for help and settings panels with new colors, padding, and text color adjustments 2026-01-28 20:01:04 +00:00
980622c365 Improve readability of raw file size check and error message formatting 2026-01-28 20:00:46 +00:00
289ba1156a Enhance UI styling and add file size check with error handling for large raw gamemaster files 2026-01-28 20:00:41 +00:00
ca798a3334 🎨 Improve code readability by reformatting template and function structures 2026-01-28 19:59:00 +00:00
585b0fb7a3 Enhance file selection and loading logic with dynamic options and support for "All Forms" files 2026-01-28 19:58:55 +00:00
070a1f2f74 🔙 Add "Back Home" button and improve layout styling in Gamemaster Explorer view 2026-01-28 19:58:23 +00:00
dc6a625025 🔧 Add path alias configuration to Vite resolve settings 2026-01-28 19:56:28 +00:00
f17408b283 🔧 Remove unused utility functions from imports 2026-01-28 19:54:58 +00:00
0b23d8b998 🎨 Adjust toast notification styles with updated colors 2026-01-28 19:54:46 +00:00
6060db82d7 🔧 Replace window with globalThis for determining innerWidth 2026-01-28 19:54:41 +00:00
49ce60ce80 🎨 Update button styles with new background and hover colors 2026-01-28 19:54:26 +00:00
e2259e79f0 🎨 Improve readability by reformatting the property filter dropdown code 2026-01-28 19:54:17 +00:00
66470d127c 🎨 Improve UI accessibility and code consistency by adding label for attribute, updating button styles, and refining DOM manipulation 2026-01-28 19:54:12 +00:00
afda1c7824 Add documentation for future Game Master Explorer features 2026-01-28 19:53:48 +00:00
12eeb7d9f4 🎨 Add secondary button styling with hover effect 2026-01-28 19:53:10 +00:00
d0f23b8088 🔍 Add JSON viewer exploration link to Gamemaster Manager interface 2026-01-28 19:52:58 +00:00
b9babf9693 🔍 Add Gamemaster Explorer tool link to the home view 2026-01-28 19:52:47 +00:00
41206dcacc 🗺️ Add route and component for GamemasterExplorer 2026-01-28 19:52:37 +00:00
0060db14a4 🎨 Improve code readability and maintainability by reformatting and restructuring HTML, CSS, and JavaScript files 2026-01-28 19:52:32 +00:00
59f0b26e63 Add GamemasterExplorer view to enhance functionality 2026-01-28 19:52:15 +00:00
1ad9389318 Simplify arrow function syntax and remove unnecessary whitespace across composable utilities 2026-01-28 19:50:05 +00:00
07c4379d81 🎨 Improve code readability by reformatting functions and cleaning up whitespace 2026-01-28 19:50:00 +00:00
b3d6bb0772 Add utility functions for JSON handling and performance optimization 2026-01-28 19:49:45 +00:00
2a262b77d8 Integrate virtual scroller for large lists, add syntax highlighting themes, and register custom highlight directive 2026-01-28 19:48:59 +00:00
058cd4e1bf Simplify arrow function syntax and remove unnecessary whitespace in highlight directive 2026-01-28 19:48:49 +00:00
5c338bd622 Add v-highlight directive for lazy-loaded syntax highlighting using highlight.js 2026-01-28 19:48:46 +00:00
05b2894cc8 Add new dependencies for syntax highlighting and virtual scrolling features 2026-01-28 19:48:44 +00:00
71d328acdf 🗑️ Remove download section from GamemasterManager view 2026-01-28 19:12:24 +00:00
1028cca194 🎨 Adjust layout and styling of file list and file details for improved responsiveness and readability 2026-01-28 19:11:41 +00:00
4445514a57 🎨 Rearrange file info and download button layout in GamemasterManager view 2026-01-28 19:11:10 +00:00
14496ada29 🎨 Improve file info layout and button styling in GamemasterManager view 2026-01-28 19:10:30 +00:00
ac76ef108b 📥 Add functionality to download individual and multiple files from the server 2026-01-28 19:10:07 +00:00
a169b1e698 📥 Add download buttons for individual files and a "Download All from Server" option 2026-01-28 19:10:00 +00:00
201c3be2df Update Pokémon data files with the latest game master information 2026-01-28 19:08:16 +00:00
0a7b920bb5 Add function to categorize gamemaster data into Pokémon, forms, and moves 2026-01-28 19:07:35 +00:00
5785985419 🔗 Add node-fetch import for HTTP requests 2026-01-28 19:07:25 +00:00
9eb91d3075 Add endpoint to fetch, process, and save gamemaster data server-side 2026-01-28 19:07:20 +00:00
57ac382c99 🛠️ Add proxy configuration for Gamemaster API in Vite config 2026-01-28 19:05:50 +00:00
c38d14a432 🎨 Reformat conditional logic for improved readability in regional form check 2026-01-28 19:03:18 +00:00
6f060001d5 Enhance regional form check by verifying form type as a string 2026-01-28 19:03:13 +00:00
7140004554 Refactor regional form check logic for improved readability and formatting consistency 2026-01-28 19:03:02 +00:00
67d333fb31 🌍 Refactor regional form check logic for better readability and clarity in gamemaster utility function 2026-01-28 19:02:51 +00:00
5db2f0840b Fix HTML and formatting issues in GamemasterManager view 2026-01-28 19:02:28 +00:00
de839e00db 🎮 Add documentation for GAMEMASTER setup process 2026-01-28 19:00:20 +00:00
19a5d0f093 📚 Update README with detailed instructions for running the backend, using the Gamemaster API, and managing API keys 2026-01-28 19:00:08 +00:00
cbe874d3bd 📝 Add feature list to README detailing application capabilities 2026-01-28 18:59:58 +00:00
9d2590ccfd Add documentation for GAMEMASTER API 2026-01-28 18:59:53 +00:00
f563e27224 🔗 Add gamemaster API route to OAuth proxy server 2026-01-28 18:59:30 +00:00
9a2faef3bf 🎨 Add and style new components for status cards, file lists, and code blocks in GamemasterManager view 2026-01-28 18:59:23 +00:00
6f5284ec10 📚 Add API usage documentation to GamemasterManager view 2026-01-28 18:59:20 +00:00
aae38d0353 Add server status loading and saving functionality with improved error handling and UI updates 2026-01-28 18:59:10 +00:00
e4ca6b392b 💾 Add server save functionality and display server storage status in Gamemaster Manager view 2026-01-28 18:59:05 +00:00
21d7d0c9be Improve readability of getPokemonById method by adjusting return statement formatting 2026-01-28 18:59:00 +00:00
5bcff917ef Add utility functions for interacting with the Game Master API 2026-01-28 18:58:55 +00:00
8b4cadf3c4 🎨 Simplify header setting for file download response 2026-01-28 18:58:50 +00:00
76f42cacc7 Add API endpoint for retrieving Pokémon data 2026-01-28 18:58:45 +00:00
4596112762 🗂️ Add placeholder file to preserve directory structure 2026-01-28 18:58:33 +00:00
f0e8164645 🎨 Adjust grid layout to use fixed two-column structure for wider screens 2026-01-28 18:56:22 +00:00
cc78b80747 🔄 Rearrange API Key Configuration section within the controls grid layout 2026-01-28 18:52:08 +00:00
679da54c77 🎨 Remove unnecessary whitespace in CSS styling 2026-01-28 18:50:36 +00:00
f616cbc0b2 🎨 Adjust responsive grid layout for control groups and collapsible groups 2026-01-28 18:50:21 +00:00
1ad59722b9 Add collapsible group component with styles and animations 2026-01-28 18:48:18 +00:00
22117fa23d Improve collapsible group headers for better readability and formatting consistency 2026-01-28 18:48:04 +00:00
0de8c0ee71 Add collapsible sections for API key, OAuth, and client credentials configurations 2026-01-28 18:47:59 +00:00
a921b09ba6 🎨 Improve API key configuration layout and remove redundant section 2026-01-28 18:46:54 +00:00
33272b67a8 🔑 Add API key configuration section with management link and note 2026-01-28 18:46:42 +00:00
4ae4d50ff2 Clean up unnecessary whitespace in ChallongeTest.vue 2026-01-28 18:41:09 +00:00
68777f3596 🔄 Refine authentication logic to prioritize tokens based on scope and improve debug configuration usage 2026-01-28 18:41:04 +00:00
f25bbb3b2d ⚠️ Improve readability of warning messages and hints for client credentials and scope usage 2026-01-28 18:40:53 +00:00
593a707438 ⚠️ Clarify client credentials scope requirements and add warning for USER scope usage 2026-01-28 18:40:48 +00:00
816eb7cb67 🎨 Fix inconsistent indentation in tournament authentication type logic 2026-01-28 18:40:37 +00:00
3894a2415c 🔧 Update tournament listing logic with additional scope types and refined authentication handling 2026-01-28 18:40:32 +00:00
b70c8efc22 🧹 Remove unnecessary whitespace in ChallongeTest.vue 2026-01-28 18:39:38 +00:00
6dbeff1c4a 🔧 Enable debug mode and adjust responsive styles for better mobile layout 2026-01-28 18:39:33 +00:00
0f206a0158 🔧 Update client authentication logic and adjust grid layout for responsiveness 2026-01-28 18:38:58 +00:00
b7a6a139b0 🎨 Reformat router-link element for improved readability 2026-01-28 18:32:49 +00:00
1ddc7761f5 🔑 Add support for client credentials authentication in API v2.1 with priority over OAuth and API key 2026-01-28 18:32:43 +00:00
8c4829f8c5 🔑 Add route for ClientCredentialsManager component 2026-01-28 18:32:21 +00:00
30d8202c55 🔗 Add route for ClientCredentialsManager component 2026-01-28 18:32:16 +00:00
149c3370a2 ✏️ Improve code readability by reformatting and rewrapping text and attributes for better alignment and consistency 2026-01-28 18:32:05 +00:00
8f7f9915e1 🔒 Add client credentials management functionality 2026-01-28 18:31:58 +00:00
c723496e69 🎨 Reformat code for consistent indentation and improve readability 2026-01-28 18:30:53 +00:00
36caa456fb 🔒 Add functionality to manage Challonge client credentials securely 2026-01-28 18:30:48 +00:00
e55537ff8b 🎮 Add support for selecting tournament scope in Challonge API integration 2026-01-28 18:29:26 +00:00
e85aea3f0c 🔧 Disable JavaScript and TypeScript validation in VS Code settings 2026-01-28 18:25:31 +00:00
292 changed files with 752362 additions and 1464 deletions

12
.env Normal file
View 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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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

View 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 "$@"

View 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

View 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

View File

@@ -6,5 +6,4 @@ services:
container_name: pokedex-online
ports:
- '8080:80'
- '8443:443'
restart: unless-stopped

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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();

View 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

View 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

View 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

View 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

View 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"]

View File

@@ -0,0 +1,2 @@
# Gamemaster data files stored here
# These are generated by the GamemasterManager tool

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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;

View 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);
}

View 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();
};
}

View 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();
};
}

View File

@@ -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.)
}
});

View 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"
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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
};
}

View 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');
}

View 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();
}

View 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
});
};
}

View 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');
}

View 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;

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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';

View 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
};
}

View 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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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)
};
}

View File

@@ -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();
}

View File

@@ -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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View 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
};
}

View File

@@ -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
};
}

View 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
};
}

View File

@@ -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
};
}

View 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
};
}

View 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
};
}

View 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;
}

View 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;
}

View 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;

View File

@@ -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');
}
})();

View 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 || '/';
}

View File

@@ -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'
}
];

View File

@@ -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]) => {

View File

@@ -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;

View 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'
});

View 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();

View File

@@ -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);

View 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);
}

View 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;
}

View File

@@ -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);
}
};
}

View File

@@ -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 => {

View 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