Compare commits

...

146 Commits

Author SHA1 Message Date
Julien Froidefond
0c47bf916c Refactor management components to remove loading state: Eliminate unused loading state and related conditional rendering from ChallengeManagement, EventManagement, FeedbackManagement, HouseManagement, and UserManagement components, simplifying the code and improving readability.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m43s
2025-12-22 08:56:37 +01:00
Julien Froidefond
9bcafe54d3 Add profile and house background preferences to SitePreferences: Extend SitePreferences model and related services to include profileBackground and houseBackground fields. Update API and UI components to support new background settings, enhancing user customization options. 2025-12-22 08:54:51 +01:00
Julien Froidefond
14c767cfc0 Refactor AdminPage and remove AdminPanel component: Simplify admin navigation by redirecting to preferences page and eliminating the AdminPanel component, streamlining the admin interface.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m21s
2025-12-19 14:02:06 +01:00
Julien Froidefond
82069c74bc Add HouseManagement integration to AdminPanel and implement removeMemberAsAdmin feature in HouseService: Enhance admin capabilities with new section for house management and functionality to remove members from houses by admins, including points deduction logic. 2025-12-19 13:58:04 +01:00
Julien Froidefond
a062f5573b Refactor Dockerfile to improve DATABASE_URL handling and enhance entrypoint script: Introduce ARG for DATABASE_URL during build, streamline migration commands, and add error handling for migration failures in the entrypoint script.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m40s
2025-12-19 09:04:30 +01:00
Julien Froidefond
6e7c5d3eaf Update Dockerfile to include prisma.config.ts and streamline entrypoint script: Add copying of prisma.config.ts for Prisma 7 compatibility and remove unnecessary migration checks from the entrypoint script for improved clarity.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2025-12-19 09:00:37 +01:00
Julien Froidefond
5dc178543e Update entrypoint script in Dockerfile and remove DATABASE_URL reference from schema.prisma: Modify entrypoint to check for prisma.config.ts instead of schema.prisma, and streamline migration command. Remove DATABASE_URL environment variable usage from schema.prisma for improved clarity.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m9s
2025-12-19 08:58:48 +01:00
Julien Froidefond
881b8149e5 Refactor Dockerfile and schema.prisma for improved migration handling: Remove migration checks during build, enhance entrypoint script to validate DATABASE_URL and schema.prisma presence, and update schema.prisma to use environment variable for database URL.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 14s
2025-12-19 08:47:06 +01:00
Julien Froidefond
d6a1e21e9f Update Docker configuration for Prisma migrations: Comment out migrations volume in docker-compose.yml for production use, and add checks in Dockerfile to verify the presence of migrations during build and entrypoint execution.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
2025-12-19 08:40:18 +01:00
Julien Froidefond
0b56d625ec Enhance HouseManagement and HousesPage components: Introduce invitation management features, including fetching and displaying pending invitations. Refactor data handling and UI updates for improved user experience and maintainability. Optimize state management with useCallback and useEffect for better performance.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m43s
2025-12-18 09:16:13 +01:00
Julien Froidefond
f5dab3cb95 Refactor HousesPage and HouseManagement components: Introduce TypeScript types for house and invitation data structures to enhance type safety. Update data serialization logic for improved clarity and maintainability. Refactor UI components for better readability and consistency, including adjustments to conditional rendering and styling in HouseManagement. Optimize fetch logic in HousesSection with useCallback for performance improvements.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m25s
2025-12-18 08:50:14 +01:00
Julien Froidefond
1b82bd9ee6 Implement house points system: Add houseJoinPoints, houseLeavePoints, and houseCreatePoints to SitePreferences model and update related services. Enhance house management features to award and deduct points for house creation, membership removal, and leaving a house. Update environment configuration for PostgreSQL and adjust UI components to reflect new functionalities.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-18 08:48:31 +01:00
Julien Froidefond
12bc44e3ac Enhance UI consistency in House components: Replace SectionTitle with styled headings in HouseCard, HouseManagement, and HousesSection for improved visual hierarchy and readability. Update styles for better alignment and user experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m37s
2025-12-18 08:15:34 +01:00
Julien Froidefond
4a415f79e0 Enhance UI consistency across multiple components: Update SectionTitle sizes and margins in StyleGuidePage, AdminPanel, ChallengesSection, HousesSection, LeaderboardSection, and ProfileForm. Add descriptive subtitles and additional text for improved user guidance. 2025-12-18 08:13:55 +01:00
Julien Froidefond
a62e61a314 Refactor event management and UI components: Update date handling in EventManagement to format dates for input, enhance EventsSection to display a message when no events are available, and improve styling in multiple components for better layout consistency.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m36s
2025-12-18 08:08:39 +01:00
Julien Froidefond
91460930a4 Refactor Prisma logging and connection settings: Remove sensitive connection URL logging for security, and simplify logging configuration to only capture errors in the Prisma client setup.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 11m29s
2025-12-17 15:16:04 +01:00
Julien Froidefond
fdedc1cf65 Update Dockerfile to enhance Prisma setup: Create migrations directory and copy schema.prisma along with migrations from builder, ensuring proper ownership for Next.js user. 2025-12-17 14:01:30 +01:00
Julien Froidefond
4fcf34c9aa Update background image positioning in multiple components: Change background image class from 'absolute' to 'fixed' in AdminPage, ChallengesSection, and BackgroundSection for improved layout consistency.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19m39s
2025-12-17 13:39:50 +01:00
Julien Froidefond
85ee812ab1 Add house leaderboard feature: Integrate house leaderboard functionality in LeaderboardPage and LeaderboardSection components. Update userStatsService to fetch house leaderboard data, and enhance UI to display house rankings, scores, and member details. Update Prisma schema to include house-related models and relationships, and seed database with initial house data.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-17 13:35:18 +01:00
Julien Froidefond
cb02b494f4 Update environment configuration for PostgreSQL: Add new environment variables for NextAuth and PostgreSQL settings in .env file, update docker-compose.yml to utilize these variables, and enhance README documentation for environment setup. Ensure DATABASE_URL is constructed dynamically if not defined. 2025-12-17 13:15:40 +01:00
Julien Froidefond
2c7a346cde Remove scripts directory copy from Dockerfile to streamline build process and eliminate unnecessary files.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2025-12-17 12:38:24 +01:00
Julien Froidefond
5875813f2f Refactor Docker configuration for PostgreSQL migration: Remove SQLite volume from docker-compose.yml, update Dockerfile to eliminate SQLite dependencies, and adjust README files to reflect PostgreSQL setup. Delete migration script and related documentation as part of the transition to PostgreSQL.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m25s
2025-12-17 12:29:13 +01:00
Julien Froidefond
20c3043572 Update deploy workflow to include PostgreSQL data path: Add POSTGRES_DATA_PATH environment variable to the deployment configuration for improved database management.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 13m19s
2025-12-17 11:55:57 +01:00
Julien Froidefond
8ad09ab9c8 Implement PostgreSQL support and update database configuration: Migrate from SQLite to PostgreSQL by updating the Prisma schema, Docker configuration, and environment variables. Add PostgreSQL dependencies and adjust the database connection logic in the application. Enhance .gitignore to exclude PostgreSQL-related files and directories.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-17 11:41:32 +01:00
Julien Froidefond
1f59cc7f9d Enhance challenge management and badge components: Implement event dispatch for refreshing challenge badges after various actions (acceptance, rejection, deletion, cancellation) in ChallengeManagement and ChallengesSection. Update ChallengeBadge to listen for refresh events, ensuring accurate challenge count display and improved user experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10m9s
2025-12-17 10:44:56 +01:00
Julien Froidefond
67b3d9e2a9 Add API call optimization in EventsPageSection: Introduce a ref to prevent multiple API calls for event registrations, ensuring efficient data fetching. Update effect dependencies for improved performance and maintainability.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-17 10:37:30 +01:00
Julien Froidefond
ba3b2c17b9 Add admin challenge acceptance functionality: Implement adminAcceptChallenge method in ChallengeService, allowing admins to accept pending challenges. Update ChallengeManagement component to include a button for accepting challenges, enhancing admin capabilities and user feedback handling.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m48s
2025-12-17 08:14:58 +01:00
Julien Froidefond
7c0b3bc848 Add database migration command to entrypoint script: Include 'pnpm dlx prisma db push' to ensure database schema is updated during container startup, enhancing deployment reliability.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12s
2025-12-16 17:01:53 +01:00
Julien Froidefond
5eddf36121 Refactor UserManagement component layout: Update user display to a grid format for improved responsiveness, enhance user information presentation with clearer stats and action buttons, and streamline the overall UI for better user experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m3s
2025-12-16 16:55:40 +01:00
Julien Froidefond
ec965cd59d Enhance ChallengeManagement and EventManagement components: Refactor layout for better readability, implement event registration viewing with score editing functionality, and improve user feedback handling in modals. Update EventRegistrationService to fetch event registrations with user details, ensuring a more interactive admin experience. 2025-12-16 16:52:50 +01:00
Julien Froidefond
79c21955e0 Refactor modal implementation across admin components: Replace Card components with a reusable Modal component in ChallengeManagement, EventManagement, and UserManagement, enhancing UI consistency and maintainability. Update Modal to use React portals for improved rendering. 2025-12-16 16:50:06 +01:00
Julien Froidefond
16e4b63ffd Add feedback management features: Implement functions to add bonus points and mark feedback as read in the FeedbackManagement component. Update EventFeedback model to include isRead property, enhancing user interaction and feedback tracking. 2025-12-16 16:43:53 +01:00
Julien Froidefond
3dd82c2bd4 Add event registration and feedback points to site preferences: Update SitePreferences model and related components to include eventRegistrationPoints and eventFeedbackPoints, ensuring proper handling of user scores during event interactions. 2025-12-16 16:38:01 +01:00
Julien Froidefond
f45cc1839e Add score property to UserData interface across navigation and profile components: Update Navigation.tsx, NavigationWrapper.tsx, and PlayerStats.tsx to include score, ensuring consistent user data handling and display. 2025-12-16 16:29:21 +01:00
Julien Froidefond
ffbf3cd42f Optimize database calls across multiple components by implementing Promise.all for parallel fetching of data, enhancing performance and reducing loading times.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m40s
2025-12-16 11:19:54 +01:00
Julien Froidefond
a9a4120874 Refactor ChallengesSection component to utilize initial challenges and users data: Replace fetching logic with props for challenges and users, streamline challenge creation with a dedicated form component, and enhance UI for better user experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m49s
2025-12-16 08:20:40 +01:00
Julien Froidefond
c7595c4173 Add examples section to ChallengesSection component: Introduce a toggleable examples section with detailed challenge descriptions and suggested points, enhancing user engagement and clarity on challenge expectations.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m47s
2025-12-16 08:09:37 +01:00
Julien Froidefond
bfaf30ee26 Add admin challenge management features: Implement functions for canceling and reactivating challenges, enhance error handling, and update the ChallengeManagement component to support these actions. Update API to retrieve all challenge statuses for admin and improve UI to display active challenges count.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m16s
2025-12-15 22:19:58 +01:00
Julien Froidefond
633245c1f1 Update Dockerfile to improve database handling and entrypoint script: Change DATABASE_URL for build environment, streamline Prisma commands, and implement a custom entrypoint script for better migration management and application startup.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m25s
2025-12-15 22:01:58 +01:00
Julien Froidefond
bee8999362 Add challengesBackground to site preferences: Update AdminPanel interface to include challengesBackground property for enhanced customization options.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m55s
2025-12-15 21:30:29 +01:00
Julien Froidefond
d3a4fa7cf5 Add challenges background preference support: Extend site preferences and related components to include challengesBackground, update API and UI to handle new background image settings for challenges.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m23s
2025-12-15 21:26:30 +01:00
Julien Froidefond
83446759fe Update challenge management component to improve pending challenges display: Simplify text for clarity by modifying the message format for pending challenges in the ChallengeManagement component.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-15 21:21:25 +01:00
Julien Froidefond
b790ee21f2 Refactor admin actions and improve code formatting: Standardize import statements, enhance error handling messages, and apply consistent formatting across event, user, and preference management functions for better readability and maintainability.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-15 21:20:39 +01:00
Julien Froidefond
321da3176e Refactor challenge management functions and improve code formatting: Standardize import statements, enhance error handling messages, and apply consistent formatting across challenge validation, rejection, update, and deletion functions for better readability and maintainability. 2025-12-15 21:20:13 +01:00
Julien Froidefond
e4b0907801 Update Dockerfile to streamline database handling and remove entrypoint script: Change DATABASE_URL for development environment, enhance Prisma commands for migration and database push, and eliminate the custom entrypoint script for simplified application startup. 2025-12-15 21:18:20 +01:00
Julien Froidefond
9b9cc3885a Enhance entrypoint script for migration handling: Improve error resolution process by adding direct database updates for missing migration files, and refine output messages for clarity during deployment.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
2025-12-15 17:58:40 +01:00
Julien Froidefond
d45475fb5a Refine migration deployment process in entrypoint script: Capture exit code separately and display migration logs for better error handling and clarity during deployment.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m17s
2025-12-15 17:52:31 +01:00
Julien Froidefond
042b3128d4 Improve migration error handling in entrypoint script: Enhance extraction of migration names from logs, add fallback mechanisms, and refine output messages for better clarity during deployment processes.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m21s
2025-12-15 17:47:53 +01:00
Julien Froidefond
5e179fb97a Enhance Dockerfile by adding scripts directory and updating entrypoint script: Copy scripts to the container and set permissions for the new entrypoint script, improving application startup and migration handling.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m58s
2025-12-15 17:41:32 +01:00
Julien Froidefond
d9555a5d49 Refactor Dockerfile to improve Prisma Client generation and migration handling: Replace direct Prisma command with pnpm dlx for better execution, and modify entrypoint script to allow migration failures without stopping the application.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2025-12-15 17:38:27 +01:00
Julien Froidefond
83aa54ff44 Update Dockerfile to streamline Prisma Client generation and migration process: Change DATABASE_URL for build environment, enhance entrypoint script with migration handling, and ensure only production dependencies are installed.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1m34s
2025-12-15 17:35:10 +01:00
Julien Froidefond
d08f6f6a9c Update environment paths in .env and Dockerfile: Change PRISMA_DATA_PATH and UPLOADS_PATH for consistency with new directory structure. Adjust DATABASE_URL in Dockerfile to point to the correct database location and ensure Prisma Client generation and migration deployment are included in the build process.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m30s
2025-12-15 16:29:29 +01:00
Julien Froidefond
699de28868 Refactor challenge access checks and update text encoding: Remove unnecessary session variable in update and delete challenge functions. Update text in ChallengeManagement and ChallengesSection components for proper HTML encoding, enhancing display consistency.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
2025-12-15 16:07:05 +01:00
Julien Froidefond
bbb0fbb9a1 Add dotenv package for environment variable management and update pnpm-lock.yaml. Adjust layout in RegisterPage and LoginPage components for improved responsiveness. Enhance AdminPanel with ChallengeManagement section and update navigation links for challenges. Refactor Prisma schema to include Challenge model and related enums. 2025-12-15 16:07:00 +01:00
Julien Froidefond
f2bb02406e Revert "Add dotenv package for environment variable management and update pnpm-lock.yaml. Adjust layout in RegisterPage and LoginPage components for improved responsiveness. Enhance AdminPanel with ChallengeManagement section and update navigation links for challenges. Refactor Prisma schema to include Challenge model and related enums."
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m43s
This reverts commit f093977b34.
2025-12-15 16:02:31 +01:00
Julien Froidefond
177b34d70f Revert "Refactor challenge access checks and update text encoding: Remove unnecessary session variable in update and delete challenge functions. Update text in ChallengeManagement and ChallengesSection components for proper HTML encoding, enhancing display consistency."
This reverts commit 5e810202bb.
2025-12-15 16:01:26 +01:00
Julien Froidefond
5e810202bb Refactor challenge access checks and update text encoding: Remove unnecessary session variable in update and delete challenge functions. Update text in ChallengeManagement and ChallengesSection components for proper HTML encoding, enhancing display consistency.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m5s
2025-12-15 15:33:14 +01:00
Julien Froidefond
f093977b34 Add dotenv package for environment variable management and update pnpm-lock.yaml. Adjust layout in RegisterPage and LoginPage components for improved responsiveness. Enhance AdminPanel with ChallengeManagement section and update navigation links for challenges. Refactor Prisma schema to include Challenge model and related enums.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3m2s
2025-12-15 15:16:54 +01:00
Julien Froidefond
9518eef3d4 Refactor ThemeProvider component: Simplify theme initialization logic by loading the theme from localStorage directly in the state hook, improving code clarity and reducing unnecessary effects. Update AdminPanel component by removing unused Link import, and fix button text encoding in EventsPageSection for proper display.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m1s
2025-12-15 10:18:24 +01:00
Julien Froidefond
dcba162663 Update button text in EventsPageSection component: Change "Rejoindre en direct" to "Connectez-vous à Teams, c'est maintenant !" for improved clarity and user engagement during live events. 2025-12-15 10:12:56 +01:00
Julien Froidefond
1e865330a0 Refactor character class handling across components: Replace hardcoded character class definitions with a centralized CHARACTER_CLASSES import, enhancing maintainability and consistency. Update ProfileForm, Leaderboard, and LeaderboardSection components to utilize new utility functions for character class icons and names, improving code clarity and reducing duplication.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m48s
2025-12-15 10:08:12 +01:00
Julien Froidefond
7dfd426d1f Update LeaderboardSection component: Change text truncation to word breaking for better display of long entries in leaderboard.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m28s
2025-12-15 09:07:37 +01:00
Julien Froidefond
b1925cd904 Optimize Dockerfile for dependency installation: Implement caching for pnpm store during installation to improve build performance and efficiency in both development and production stages.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m8s
2025-12-13 12:10:23 +01:00
Julien Froidefond
bd8d698c4e Remove link to Style Guide in AdminPanel component to streamline UI preferences section.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s
2025-12-13 08:05:13 +01:00
Julien Froidefond
268b99ebd6 Update Next.js version to 15.5.9 in package.json and pnpm-lock.yaml for improved performance and security.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m27s
2025-12-13 07:23:51 +01:00
Julien Froidefond
6d1e3daebb Add Footer component to layout and remove StyleGuidePage
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m19s
2025-12-12 17:10:19 +01:00
Julien Froidefond
5b96cf907e Enhance theming and UI components: Introduce a new dark cyan theme in globals.css, update layout to utilize ThemeProvider for consistent theming, and refactor button and card components to use CSS variables for styling. Improve navigation and alert components with dynamic styles based on theme variables, ensuring a cohesive user experience across the application.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 49s
2025-12-12 17:05:22 +01:00
Julien Froidefond
97db800c73 Refactor component imports and structure: Update import paths for various components to improve organization, moving them into appropriate subdirectories. Remove unused components related to user and event management, enhancing code clarity and maintainability across the application.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
2025-12-12 16:48:41 +01:00
Julien Froidefond
880e96d6e4 Update Avatar component in StyleGuidePage: Modify Avatar usage to handle null image source, ensuring fallback functionality works correctly. Clean up ProfileForm by removing unused health and experience percentage calculations for improved code clarity. 2025-12-12 16:45:55 +01:00
Julien Froidefond
99a475736b Enhance UI components and animations: Introduce a shimmer animation effect in globals.css, refactor FeedbackPageClient, LoginPage, RegisterPage, and AdminPanel components to utilize new UI components for improved consistency and maintainability. Update event and feedback handling in EventsPageSection and FeedbackModal, ensuring a cohesive user experience across the application. 2025-12-12 16:44:57 +01:00
Julien Froidefond
db01c25de7 Refactor API routes and component logic: Remove unused event and user management routes, streamline feedback handling in components, and enhance state management with transitions for improved user experience. Update service layer methods for better organization and maintainability across the application.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m38s
2025-12-12 16:28:07 +01:00
Julien Froidefond
494ac3f503 Refactor event handling and user management: Replace direct database calls with service layer methods for events, user profiles, and preferences, enhancing code organization and maintainability. Update API routes to utilize new services for event registration, feedback, and user statistics, ensuring a consistent approach across the application. 2025-12-12 16:19:13 +01:00
Julien Froidefond
fd095246a3 Add project structure and rules documentation: Introduce guidelines for organizing backend, frontend, and shared components, including rules for services, API routes, clients, and components to ensure a consistent and maintainable codebase. 2025-12-12 16:18:55 +01:00
Julien Froidefond
ce0697a908 Add background image and overlay to AdminPage: Implement a full-screen background image with a dark overlay for improved readability, enhancing the visual presentation of the admin interface.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m19s
2025-12-12 16:07:35 +01:00
Julien Froidefond
f69fbbd0e1 Refactor image URL handling: Update API routes to return image URLs via the API instead of direct paths, ensuring consistency across avatar and background uploads. Introduce normalization functions for avatar and background URLs to maintain compatibility with existing URLs.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
2025-12-12 15:59:18 +01:00
Julien Froidefond
702476c349 Refactor avatar upload logic: Update API routes to create a dedicated 'uploads/avatars' directory for storing user avatars, ensuring organized file management and returning the correct image URL. 2025-12-12 15:51:47 +01:00
Julien Froidefond
aada4ad08b Remove unused image files from uploads directory to clean up storage and improve organization. 2025-12-12 15:45:36 +01:00
Julien Froidefond
dbe5e1e9cc Update environment and Docker configuration: Add PRISMA_DATA_PATH and UPLOADS_PATH to .env, modify docker-compose.yml to ensure correct volume mapping for uploads, and enhance Dockerfile to streamline file copying and directory creation for uploads.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m22s
2025-12-12 11:16:49 +01:00
Julien Froidefond
5c06ec20a6 Refactor FeedbackPage component: Update props handling to ensure params are resolved as a Promise, aligning with Next.js 15 requirements and improving code clarity.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m38s
2025-12-12 10:37:59 +01:00
Julien Froidefond
f2805505a1 Refactor FeedbackPage component: Simplify props handling and remove unused state and effects related to event and feedback management, streamlining the feedback submission process.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 54s
2025-12-12 10:07:15 +01:00
Julien Froidefond
ae08ed7793 Enhance image upload and background management: Update Docker configuration to create a dedicated backgrounds directory for uploaded images, modify API routes to handle background images specifically, and improve README documentation to reflect these changes. Additionally, refactor components to utilize the new Avatar component for consistent avatar rendering across the application.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 33s
2025-12-12 08:46:31 +01:00
Julien Froidefond
3ad680f416 Add UPLOADS_PATH to deployment workflow: Include UPLOADS_PATH environment variable in deploy.yml to support image uploads configuration, enhancing deployment flexibility.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 13m50s
2025-12-12 08:15:34 +01:00
Julien Froidefond
09a3f6a106 Update Docker configuration to persist uploaded images: Modify docker-compose.yml to include a volume for uploaded images, enhance Dockerfile to create the uploads directory, and update README to document the new uploads path configuration.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-12 08:13:40 +01:00
Julien Froidefond
6f98013382 Refactor bio update logic in profile route: Adjust bio assignment to handle null values correctly, ensuring proper trimming and assignment of user bio data.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-12 08:11:51 +01:00
Julien Froidefond
db2993c50b Refactor avatar display logic in LeaderboardSection: Simplify avatar rendering by consolidating conditional checks and enhancing fallback display for users without avatars, improving visual consistency and user experience.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-12 08:09:12 +01:00
369c83d162 Actualiser .gitea/workflows/deploy.yml
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m1s
2025-12-12 06:39:42 +01:00
Julien Froidefond
41234f0729 Update README and HeroSection components: Revise project description to reflect new branding as "Game of Tech - Peaksys" and enhance the HeroSection text to better convey the platform's gamification features and user engagement opportunities.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 25s
2025-12-11 15:01:30 +01:00
Julien Froidefond
2cfb9ad041 Enhance EventsPageSection and Navigation components: Add click functionality to select events and update logo section to be a clickable link, improving user interaction and navigation experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 11s
2025-12-11 14:57:20 +01:00
Julien Froidefond
bbdb7d6ad2 Update NEXTAUTH_URL in docker-compose.yml to support environment variable fallback, enhancing configuration flexibility.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 17s
2025-12-11 13:39:18 +01:00
Julien Froidefond
8c17ed44e3 Add label to Docker service: Disable Watchtower updates for the app service in docker-compose.yml to prevent automatic container updates during runtime.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2025-12-11 12:08:13 +01:00
Julien Froidefond
216f438b0a Update deployment workflow: Change NEXTAUTH_URL reference in deploy.yml from secrets to vars for improved consistency in environment variable management.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2025-12-11 11:13:48 +01:00
Julien Froidefond
9b40210b36 Update docker-compose.yml and deploy.yml: Modify volume path to use an environment variable for data persistence and update deployment workflow to reference secrets for environment variables, enhancing flexibility and security. 2025-12-11 11:11:44 +01:00
Julien Froidefond
8b1ed1b8b9 Remove --build flag from docker compose command in deploy.yml to streamline deployment process and avoid unnecessary image rebuilding.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 50s
2025-12-11 09:04:44 +01:00
Julien Froidefond
155d026284 Add environment variables to deployment workflow: Update deploy.yml to include necessary environment variables for Docker build, enhancing configuration for deployment.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-11 08:57:42 +01:00
Julien Froidefond
bd25738486 Update deployment workflow: Modify deploy.yml to include the --build flag in the docker compose up command, ensuring that images are rebuilt during deployment for the latest changes.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9m16s
2025-12-11 07:55:16 +01:00
Julien Froidefond
23ca6345de Update Docker configuration and deployment workflow: Change volume path in docker-compose.yml to use an external USB drive for data persistence. Adjust deploy.yml to uncomment deployment triggers for clarity and streamline the deployment process.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2025-12-11 07:53:57 +01:00
Julien Froidefond
93c6624aec Add login redirection for feedback actions: Ensure users are redirected to the login page if they attempt to provide feedback without being authenticated, enhancing user experience and security. 2025-12-11 07:45:46 +01:00
Julien Froidefond
6c08789555 Refactor HeroSection component: Remove mouse tracking functionality and simplify gradient background styling for improved performance and clarity. 2025-12-11 06:45:42 +01:00
Julien Froidefond
7dbd044859 Refactor UI components for improved responsiveness and consistency: Update styles in AdminPanel, EventManagement, FeedbackManagement, HeroSection, ImageSelector, LeaderboardSection, Navigation, PlayerStats, and UserManagement to enhance mobile and desktop layouts. Adjust text sizes, padding, and button styles for better user experience across devices. 2025-12-11 06:45:14 +01:00
Julien Froidefond
f0c9a9e4cc Update database connection path: Change DATABASE_URL in .env and related files to point to the new data directory for SQLite database. 2025-12-11 06:36:54 +01:00
Julien Froidefond
53e5e7de3c Update Docker configuration: Modify docker-compose.yml to change DATABASE_URL and volume paths for SQLite database. Refactor Dockerfile to streamline build process, set up entrypoint script, and ensure proper permissions. Update package.json to include built dependencies for Prisma. Adjust deploy.yml to comment out deployment triggers for clarity. 2025-12-10 15:45:48 +01:00
Julien Froidefond
1152389785 Rename 'app' service to 'got-app' in docker-compose.yml for clarity and consistency. Remove 'docker info' step from deploy.yml to streamline the deployment workflow.
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m55s
2025-12-10 14:15:32 +01:00
Julien Froidefond
65c73688e7 Refactor deployment workflow: Remove 'docker compose build --no-cache' command from deploy.yml to simplify the deployment process and enhance efficiency.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m22s
2025-12-10 14:09:22 +01:00
Julien Froidefond
ae91ae5894 Refactor deployment workflow: Remove 'docker compose down' command from deploy.yml to streamline the deployment process and focus on building and starting the application.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-10 14:06:37 +01:00
Julien Froidefond
18d11d623a Update deployment workflow: Modify deploy.yml to include 'docker compose down' and 'docker compose build --no-cache' commands for improved deployment process and to ensure fresh builds. 2025-12-10 14:06:21 +01:00
Julien Froidefond
30dc84ccc0 Update deployment configuration: Change runner name in deploy.yml from 'Mac-mini-de-Julien.local' to 'mac-orbstack-runner' for consistency in workflow execution.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-10 14:02:34 +01:00
Julien Froidefond
378cf3d66a Update deployment configuration: Change runner name in deploy.yml from 'mac-orbstack-runner' to 'Mac-mini-de-Julien.local' for improved clarity and consistency in workflow execution.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-10 14:00:28 +01:00
Julien Froidefond
3c318e1763 Enhance feedback submission process: Update FeedbackModal to improve user experience for event feedback. Refactor event handling logic to better manage feedback state and streamline user interactions.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2025-12-10 13:59:11 +01:00
Julien Froidefond
9fde18a35e Add feedback functionality to EventsPageSection: Integrate FeedbackModal for event feedback submission, allowing users to provide feedback on selected events. Update event handling to manage feedback state and improve user interaction. 2025-12-10 12:06:30 +01:00
Julien Froidefond
80e9d953ae Remove unused import for calculateEventStatus in Events page component to streamline code and improve clarity. 2025-12-10 11:37:40 +01:00
Julien Froidefond
d11059dac2 Refactor ESLint configuration and update code formatting: Standardize quotes in eslint.config.mjs, next.config.js, and various TypeScript files for consistency. Add Prettier as a dependency and include formatting scripts in package.json. Clean up unnecessary whitespace in multiple files to enhance code readability. 2025-12-10 11:30:00 +01:00
Julien Froidefond
66237458ec Update Next.js configuration and enhance dynamic rendering: Set output to 'standalone' in next.config.js for improved deployment. Implement 'force-dynamic' rendering in multiple pages (Home, Admin, Events, Leaderboard) to ensure fresh data retrieval on each request. 2025-12-10 11:26:11 +01:00
Julien Froidefond
3bd43e777e Implement event feedback functionality: Add EventFeedback model to Prisma schema, enabling users to submit ratings and comments for events. Update EventsPageSection and AdminPanel components to support feedback management, including UI for submitting feedback and viewing existing feedbacks. Refactor registration logic to retrieve all user registrations for improved feedback handling. 2025-12-10 06:11:32 +01:00
Julien Froidefond
44be5d2e98 Remove ESLint configuration file and update type imports across components: Deleted eslint.config.js to streamline project setup. Updated type imports in layout, login, register, and other components to use direct imports for improved clarity and consistency. Enhanced error handling in various components and replaced apostrophes with HTML entities for better rendering. 2025-12-10 06:01:34 +01:00
Julien Froidefond
13b8971cc7 Update ESLint configuration and dependencies: Change lint script in package.json to use eslint directly with TypeScript extensions. Add new ESLint dependencies including @eslint/js, @typescript-eslint/eslint-plugin, and @typescript-eslint/parser to enhance code quality and maintainability. Update pnpm-lock.yaml to reflect these changes and ensure consistent package management. 2025-12-10 06:00:04 +01:00
Julien Froidefond
88d377e7b2 Add user deletion functionality: Implement DELETE API endpoint for user management, allowing admins to remove users while preventing self-deletion. Enhance UserManagement component with delete confirmation and error handling for improved user experience. 2025-12-10 05:55:52 +01:00
Julien Froidefond
125e9b345d Enhance user registration and profile management: Update registration API to include bio, character class, and avatar fields. Implement validation for character class and improve error messages. Refactor registration page to support multi-step form with avatar upload and additional profile customization options, enhancing user experience during account creation. 2025-12-10 05:54:06 +01:00
Julien Froidefond
95e580aff6 Refactor event date handling in EventsSection and PlayerStats components: Serialize event dates for consistent formatting and update date display to use localized string representation. Remove profile link from Navigation and integrate it into PlayerStats for improved user experience. 2025-12-10 05:49:44 +01:00
Julien Froidefond
a6c329ff15 Update event types in EventManagement and EventsPageSection components: Replace existing event types with new categories (ATELIER, KATA, PRESENTATION, LEARNING_HOUR) to better reflect current offerings. Adjust related functions and seed data to ensure consistency across the application. 2025-12-10 05:48:23 +01:00
Julien Froidefond
a69613a232 Refactor event status handling: Remove EventStatus enum from the Prisma schema and update related API routes and UI components to calculate event status dynamically based on event date. This change simplifies event management and enhances data integrity by ensuring status is always derived from the date. 2025-12-10 05:45:25 +01:00
Julien Froidefond
fb830c6fcc Refactor date handling in EventsPageSection: Update getFirstDayOfMonth function to correctly convert UTC day of the week for improved calendar display. Simplify month navigation logic by directly using UTC date methods, enhancing clarity and functionality in event date management. 2025-12-10 05:37:42 +01:00
Julien Froidefond
56de7d6b01 Add upcoming events to seed data: Introduce new workshops on monoids, naming processes, rule design patterns, and wrap refactoring techniques, enhancing the event catalog for better user engagement and learning opportunities. 2025-12-10 05:35:47 +01:00
Julien Froidefond
1b07fe8ae5 Refactor event date handling: Update event model to use DateTime type for date fields in Prisma schema. Modify API routes and UI components to ensure consistent date formatting and handling, improving data integrity and user experience across event management and display. 2025-12-10 05:32:23 +01:00
Julien Froidefond
fdd860c456 Enhance event model and management: Add new fields for room, time, and maxPlaces to the Event model in Prisma schema. Update API routes and UI components to support these fields, improving event details and user interaction in event management and registration processes. 2025-12-10 05:27:35 +01:00
Julien Froidefond
fb5c8c1466 Refactor event registration logic in EventsPageSection: Introduce useRef to track initial data usage, preventing unnecessary API calls when user session changes. Update loading and error handling to enhance user experience during event registration. 2025-12-09 22:18:31 +01:00
Julien Froidefond
b643e283fa Enhance event registration handling: Integrate server-side session management to fetch user registrations for upcoming events, preventing flicker on load. Update EventsPageSection to accept initial registration data, improving user experience by pre-populating registration states. 2025-12-09 22:18:01 +01:00
Julien Froidefond
e6201120b9 Add user seeding functionality and enhance user profiles: Update prisma.config to include seed script and modify seed.ts to add new users with character classes, avatars, and bios, enriching user profiles and gameplay experience. 2025-12-09 22:15:58 +01:00
Julien Froidefond
0ac6dbd423 Add character selection modal to Leaderboard: Implement a modal for displaying detailed user profiles when a leaderboard entry is clicked. This includes user avatar, rank, character class, score, level, and bio, enhancing user interaction and profile visibility. 2025-12-09 22:11:47 +01:00
Julien Froidefond
b245be3bf4 Enhance user profiles with character class feature: Add character class field to user model and update related API routes, UI components, and validation logic. This update improves user profile customization and leaderboard entries by allowing users to select and display their character class. 2025-12-09 22:03:51 +01:00
Julien Froidefond
3a0dd57bb6 Add bio field to user model and update related components: Enhance leaderboard and profile features by including a bio field in user data. Update API routes, UI components, and validation logic to support bio input and display, improving user profiles and leaderboard entries. 2025-12-09 22:00:19 +01:00
Julien Froidefond
16b0437ecb Refactor event fetching and display: Change event retrieval order to descending by date, enhance event data structure to include registration counts, and update UI components to reflect these changes for better user experience. 2025-12-09 21:55:42 +01:00
Julien Froidefond
50a2eaf109 Implement event registration functionality: Add EventRegistration model to Prisma schema, enabling user event registrations. Enhance EventsPageSection component with registration checks, calendar view, and improved event display. Refactor event rendering logic to separate upcoming and past events, improving user experience. 2025-12-09 21:53:10 +01:00
Julien Froidefond
5ae6cde14e Enhance HeroSection component: Implement mouse-following gradient effect for the game title, improving visual interactivity. Update CSS for better font formatting and add a new title animation class for enhanced text styling. 2025-12-09 14:40:02 +01:00
Julien Froidefond
6dc08dc818 Refactor HeroSection component: Remove particle and orb animations to simplify the code and improve performance. Streamline the component structure while maintaining the existing background image functionality. 2025-12-09 14:32:45 +01:00
Julien Froidefond
b8f4672206 Refactor ProfileForm component: Improve code readability by formatting error messages and UI elements. Add default avatar selection feature for user profile customization, enhancing user experience. Update button labels for clarity during avatar upload and password modification. 2025-12-09 14:26:13 +01:00
Julien Froidefond
67131f6470 Refactor page components to use NavigationWrapper and integrate Prisma for data fetching. Update EventsSection and LeaderboardSection to accept props for events and leaderboard data, enhancing performance and user experience. Implement user authentication in ProfilePage and AdminPage, ensuring secure access to user data. 2025-12-09 14:11:47 +01:00
Julien Froidefond
b1f36f6210 Enhance Admin and Profile UI: Update admin page to display user preferences with improved layout and visuals. Add password change functionality to profile page, including form handling and validation. Refactor ImageSelector for better image preview and upload experience. 2025-12-09 14:02:27 +01:00
Julien Froidefond
4e38bd1e8e Refactor Navigation component to improve user experience by adding profile link for user access and enhancing navigation options. 2025-12-09 12:56:24 +01:00
Julien Froidefond
447ef9d076 Add profile link to Navigation component for user access, enhancing navigation options. 2025-12-09 12:46:29 +01:00
Julien Froidefond
f732eb7385 feat: user admin ui : condensed 2025-12-09 12:40:45 +01:00
Julien Froidefond
d1e94f1402 Update HeroSection buttons to use Next.js Link for navigation to events and leaderboard, enhancing user experience and accessibility. Rename game title in Navigation component to 'Peaksys'. 2025-12-09 08:51:24 +01:00
Julien Froidefond
82c557e10c Add Event Management section to admin page, allowing users to manage events alongside preferences and user management. Updated UI to include event button and corresponding display area. 2025-12-09 08:49:47 +01:00
Julien Froidefond
4de3fea776 Add User Management component to admin page, replacing placeholder text with functional UI for user management. 2025-12-09 08:48:08 +01:00
Julien Froidefond
8c326bdd20 Refactor admin preferences management to use global site preferences, update UI components for better user experience, and implement image selection for background settings. 2025-12-09 08:37:52 +01:00
Julien Froidefond
4486f305f2 Add database and Prisma configurations, enhance event and leaderboard components with API integration, and update navigation for session management 2025-12-09 08:24:14 +01:00
Julien Froidefond
f57a30eb4d Enhance HeroSection layout by adding flexbox for better alignment and adjusting max-width for larger screens. This improves the overall presentation of the game title. 2025-12-09 06:53:27 +01:00
239 changed files with 44656 additions and 857 deletions

View File

@@ -0,0 +1,32 @@
---
globs: app/api/**/*.ts
---
# API Routes Rules
1. Routes MUST only use services for data access
2. Routes MUST handle input validation
3. Routes MUST return typed responses
4. Routes MUST use proper error handling
Example of correct API route:
```typescript
import { MyService } from "@/services/my-service";
export async function GET(request: Request) {
try {
const service = new MyService(pool);
const data = await service.getData();
return Response.json(data);
} catch (error) {
return Response.error();
}
}
```
❌ FORBIDDEN:
- Direct database queries
- Business logic implementation
- Untyped responses

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: Enforce business logic separation between frontend and backend
---
# Business Logic Separation Rules
## Core Principle: NO Business Logic in Frontend
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
### Components
- UI rendering and presentation logic
- Form validation (UI-level only, not business rules)
- User interaction handling (clicks, inputs, navigation)
- Visual state management (loading, errors, UI states)
- Data formatting for display purposes only
### Hooks
- React state management
- API call orchestration (using clients)
- UI-specific logic (modals, forms, animations)
- Data fetching and caching coordination
### Clients
- HTTP requests to API routes
- Request/response transformation (serialization only)
- Error handling and retry logic
- Authentication token management
## ❌ FORBIDDEN in Frontend
### Business Rules
```typescript
// ❌ BAD: Business logic in component
const TaskCard = ({ task }) => {
const canEdit = task.status === 'open' && task.assignee === currentUser.id;
const priority = task.dueDate < new Date() ? 'high' : 'normal';
// This is business logic!
}
// ✅ GOOD: Get computed values from backend
const TaskCard = ({ task }) => {
const { canEdit, priority } = task; // Computed by backend service
}
```
### Data Processing
```typescript
// ❌ BAD: Data transformation in frontend
const processJiraTasks = (tasks) => {
return tasks.map(task => ({
...task,
normalizedStatus: mapJiraStatus(task.status),
estimatedHours: calculateEstimate(task.storyPoints)
}));
}
// ✅ GOOD: Data already processed by backend service
const { processedTasks } = await tasksClient.getTasks();
```
### Domain Logic
```typescript
// ❌ BAD: Domain calculations in frontend
const calculateTeamVelocity = (sprints) => {
// Complex business calculation
}
// ✅ GOOD: Domain logic in service
// This belongs in services/team-analytics.ts
```
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
### Services Layer
- All business rules and domain logic
- Data validation and processing
- Integration with external APIs (Jira, macOS Reminders)
- Complex calculations and algorithms
- Data aggregation and analytics
- Permission and authorization logic
### API Routes
- Input validation and sanitization
- Service orchestration
- Response formatting
- Error handling and logging
- Authentication and authorization
## Implementation Pattern
### ✅ Correct Flow
```
User Action → Component → Client → API Route → Service → Database
↑ ↓
Pure UI Logic Business Logic
```
### ❌ Incorrect Flow
```
User Action → Component with Business Logic → Database
```
## Examples
### Task Status Management
```typescript
// ❌ BAD: In component
const updateTaskStatus = (taskId, newStatus) => {
if (newStatus === 'done' && !task.hasAllSubtasks) {
throw new Error('Cannot complete task with pending subtasks');
}
// Business rule in frontend!
}
// ✅ GOOD: In services/task-processor.ts
export const updateTaskStatus = async (taskId: string, newStatus: TaskStatus) => {
const task = await getTask(taskId);
// Business rules in service
if (newStatus === 'done' && !await hasAllSubtasksCompleted(taskId)) {
throw new BusinessError('Cannot complete task with pending subtasks');
}
return await updateTask(taskId, { status: newStatus });
}
```
### Team Analytics
```typescript
// ❌ BAD: In component
const TeamDashboard = () => {
const calculateBurndown = (tasks) => {
// Complex business calculation in component
}
}
// ✅ GOOD: In services/team-analytics.ts
export const getTeamBurndown = async (teamId: string, sprintId: string) => {
// All calculation logic in service
const tasks = await getSprintTasks(sprintId);
return calculateBurndownMetrics(tasks);
}
```
## Enforcement
When reviewing code:
1. **Components**: Should only contain JSX, event handlers, and UI state
2. **Hooks**: Should only orchestrate API calls and manage React state
3. **Clients**: Should only make HTTP requests and handle responses
4. **Services**: Should contain ALL business logic and data processing
5. **API Routes**: Should validate input and call appropriate services
## Red Flags
Watch for these patterns that indicate business logic in frontend:
- Complex calculations in components/hooks
- Business rule validation in forms
- Data transformation beyond display formatting
- Domain-specific constants and rules
- Integration logic with external systems
Remember: **The frontend is a thin presentation layer. All intelligence lives in the backend.**

31
.cursor/rules/clients.mdc Normal file
View File

@@ -0,0 +1,31 @@
---
globs: clients/**/*.ts
---
# HTTP Clients Rules
1. All HTTP calls MUST be in clients/domains/
2. Each domain MUST have its own client
3. Clients MUST use the base HTTP client
4. Clients MUST type their responses
Example of correct client:
```typescript
import { HttpClient } from "@/clients/base/http-client";
import { MyData } from "@/lib/types";
export class MyClient {
constructor(private httpClient: HttpClient) {}
async getData(): Promise<MyData[]> {
return this.httpClient.get("/api/data");
}
}
```
❌ FORBIDDEN:
- Direct fetch calls
- Business logic in clients
- Untyped responses

View File

@@ -0,0 +1,28 @@
---
globs: src/components/**/*.tsx
---
# Components Rules
1. UI components MUST be in src/components/ui/
2. Feature components MUST be in their feature folder
3. Components MUST use clients for data fetching
4. Components MUST be properly typed
Example of correct component:
```typescript
import { useMyClient } from '@/hooks/use-my-client';
export const MyComponent = () => {
const { data } = useMyClient();
return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>;
};
```
❌ FORBIDDEN:
- Direct service usage
- Direct database queries
- Direct fetch calls
- Untyped props

View File

@@ -0,0 +1,167 @@
---
alwaysApply: true
description: CSS Variables theme system best practices
---
# CSS Variables Theme System
## Core Principle: Pure CSS Variables for Theming
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
## ✅ Architecture Pattern
### CSS Structure
```css
:root {
/* Light theme (default values) */
--background: #f1f5f9;
--foreground: #0f172a;
--primary: #0891b2;
--success: #059669;
--destructive: #dc2626;
--accent: #d97706;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #059669;
--blue: #2563eb;
--gray: #6b7280;
--gray-light: #e5e7eb;
}
.dark {
/* Dark theme (override values) */
--background: #1e293b;
--foreground: #f1f5f9;
--primary: #06b6d4;
--success: #10b981;
--destructive: #ef4444;
--accent: #f59e0b;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--gray: #9ca3af;
--gray-light: #374151;
}
```
### Theme Application
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
- **No duplication**: Theme is applied only once, not in multiple places
- **SSR safe**: Initial theme from server-side preferences
## ✅ Component Usage Patterns
### Correct: Using CSS Variables
```tsx
// ✅ GOOD: CSS Variables in className
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
// ✅ GOOD: CSS Variables in style prop
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
// ✅ GOOD: CSS Variables with color-mix for transparency
<div style={{
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
}}>
```
### ❌ Forbidden: Tailwind Dark Mode Classes
```tsx
// ❌ BAD: Tailwind dark: classes
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
// ❌ BAD: Conditional classes
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
// ❌ BAD: Hardcoded colors
<div className="bg-red-500 text-blue-600">
```
## ✅ Color System
### Semantic Color Tokens
- `--background`: Main background color
- `--foreground`: Main text color
- `--card`: Card/panel background
- `--card-hover`: Card hover state
- `--card-column`: Column background (darker than cards)
- `--border`: Border color
- `--input`: Input field background
- `--primary`: Primary brand color
- `--primary-foreground`: Text on primary background
- `--muted`: Muted text color
- `--muted-foreground`: Secondary text color
- `--accent`: Accent color (orange/amber)
- `--destructive`: Error/danger color (red)
- `--success`: Success color (green)
- `--purple`: Purple accent
- `--yellow`: Yellow accent
- `--green`: Green accent
- `--blue`: Blue accent
- `--gray`: Gray color
- `--gray-light`: Light gray background
### Color Mixing Patterns
```css
/* Background with transparency */
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
/* Border with transparency */
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
/* Text with opacity */
color: color-mix(in srgb, var(--destructive) 80%, transparent);
```
## ✅ Theme Context Usage
### ThemeProvider Setup
```tsx
// In layout.tsx
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
{children}
</ThemeProvider>
```
### Component Usage
```tsx
import { useTheme } from '@/contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme, setTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
</button>
);
}
```
## ✅ Future Extensibility
This system is designed to support:
- **Custom color themes**: Easy to add new color variables
- **User preferences**: Colors can be dynamically changed
- **Theme presets**: Multiple predefined themes
- **Accessibility**: High contrast modes
## 🚨 Anti-patterns to Avoid
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
3. **Don't hardcode colors**: Always use semantic color tokens
4. **Don't use conditional classes**: Use CSS variables instead
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
## 📁 Key Files
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**

View File

@@ -0,0 +1,30 @@
---
alwaysApply: true
---
# Project Structure Rules
1. Backend:
- [src/services/](mdc:src/services/) - ALL database access
- [src/app/api/](mdc:src/app/api/) - API routes using services
2. Frontend:
- [src/clients/](mdc:src/clients/) - HTTP clients
- [src/components/](mdc:src/components/) - React components (organized by domain)
- [src/hooks/](mdc:src/hooks/) - React hooks
3. Shared:
- [src/lib/](mdc:src/lib/) - Types and utilities
- [scripts/](mdc:scripts/) - Utility scripts
Key Files:
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
❌ FORBIDDEN:
- Database access outside src/services/
- HTTP calls outside src/clients/
- Business logic in src/components/

View File

@@ -0,0 +1,113 @@
---
alwaysApply: true
description: Guide for when to use Server Actions vs API Routes in Next.js App Router
---
# Server Actions vs API Routes - Decision Guide
## ✅ USE SERVER ACTIONS for:
### Quick Actions & Mutations
- **TaskCard actions**: `updateTaskStatus()`, `updateTaskTitle()`, `deleteTask()`
- **Daily checkboxes**: `toggleCheckbox()`, `addCheckbox()`, `updateCheckbox()`
- **User preferences**: `updateTheme()`, `updateViewPreferences()`, `updateFilters()`
- **Simple CRUD**: `createTag()`, `updateTag()`, `deleteTag()`
### Characteristics of Server Action candidates:
- Simple, frequent mutations
- No complex business logic
- Used in interactive components (forms, buttons, toggles)
- Need immediate UI feedback with `useTransition`
- Benefit from automatic cache revalidation
## ❌ KEEP API ROUTES for:
### Complex Endpoints
- **Initial data fetching**: `GET /api/tasks` with complex filters
- **External integrations**: `POST /api/jira/sync` with complex logic
- **Analytics & reports**: Complex data aggregation
- **Public API**: Endpoints that might be called from mobile/external
### Characteristics that require API Routes:
- Complex business logic or data processing
- Multiple service orchestration
- Need for HTTP monitoring/logging
- External consumption (mobile apps, webhooks)
- Real-time features (WebSockets, SSE)
- File uploads or special content types
## 🔄 Implementation Pattern
### Server Actions Structure
```typescript
// actions/tasks.ts
'use server'
import { tasksService } from '@/services/tasks';
import { revalidatePath } from 'next/cache';
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
try {
const task = await tasksService.updateTask(taskId, { status });
revalidatePath('/'); // Auto cache invalidation
return { success: true, data: task };
} catch (error) {
return { success: false, error: error.message };
}
}
```
### Component Usage with useTransition
```typescript
// components/TaskCard.tsx
'use client';
import { updateTaskStatus } from '@/actions/tasks';
import { useTransition } from 'react';
export function TaskCard({ task }) {
const [isPending, startTransition] = useTransition();
const handleStatusChange = (status) => {
startTransition(async () => {
const result = await updateTaskStatus(task.id, status);
if (!result.success) {
// Handle error
}
});
};
return (
<div className={isPending ? 'opacity-50' : ''}>
{/* UI with loading state */}
</div>
);
}
```
## 🏗️ Migration Strategy
When migrating from API Routes to Server Actions:
1. **Create server action** in `actions/` directory
2. **Update component** to use server action directly
3. **Remove API route** (PATCH, POST, DELETE for mutations)
4. **Simplify client** (remove mutation methods, keep GET only)
5. **Update hooks** to use server actions instead of HTTP calls
## 🎯 Benefits of Server Actions
- **🚀 Performance**: No HTTP serialization overhead
- **🔄 Cache intelligence**: Automatic revalidation with `revalidatePath()`
- **📦 Bundle reduction**: Less client-side HTTP code
- **⚡ UX**: Native loading states with `useTransition`
- **🎯 Simplicity**: Direct service calls, less boilerplate
## 🚨 Anti-patterns to Avoid
- Don't use server actions for complex data fetching
- Don't use server actions for endpoints that need HTTP monitoring
- Don't use server actions for public API endpoints
- Don't mix server actions with client-side state management for the same data
Remember: Server Actions are for **direct mutations**, API Routes are for **complex operations** and **public interfaces**.

View File

@@ -0,0 +1,42 @@
---
globs: src/services/*.ts
---
# Services Rules
1. Services MUST contain ALL PostgreSQL queries
2. Services are the ONLY layer allowed to communicate with the database
3. Each service MUST:
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
- Implement proper transaction management
- Handle errors and logging
- Validate data before insertion
- Have a clear interface
Example of correct service implementation:
```typescript
export class MyService {
constructor(private pool: Pool) {}
async myMethod(): Promise<Result> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// ... queries
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
```
❌ FORBIDDEN:
- Direct database queries outside src/services
- Raw SQL in API routes
- Database logic in components

View File

@@ -0,0 +1,54 @@
---
alwaysApply: true
description: Automatic TODO tracking and task completion management
---
# TODO Task Tracking Rules
## Automatic Task Completion
Whenever you complete a task or implement a feature mentioned in [TODO.md](mdc:TODO.md), you MUST:
1. **Immediately update the TODO.md** by changing `- [ ]` to `- [x]` for the completed task
2. **Use the exact task description** from the TODO - don't modify the text
3. **Update related sub-tasks** if completing a parent task affects them
4. **Add completion timestamp** in a comment if the task was significant
## Task Completion Examples
### ✅ Correct completion marking:
```markdown
- [x] Initialiser Next.js avec TypeScript
- [x] Configurer ESLint, Prettier
- [x] Setup structure de dossiers selon les règles du workspace
```
### ✅ With timestamp for major milestones:
```markdown
- [x] Créer `services/database.ts` - Pool de connexion DB <!-- Completed 2025-01-15 -->
```
## When to Update TODO.md
Update the TODO immediately after:
- Creating/modifying files mentioned in tasks
- Implementing features described in tasks
- Completing configuration steps
- Finishing any work item listed in the TODO
## Task Dependencies
When completing tasks, consider:
- **Parent tasks**: Mark parent complete only when ALL sub-tasks are done
- **Blocking tasks**: Some tasks may unblock others - mention this in updates
- **Phase completion**: Note when entire phases are completed
## Progress Tracking
Always maintain visibility of:
- Current phase progress
- Next logical task to tackle
- Any blockers or issues encountered
- Completed vs remaining work ratio
This ensures the TODO.md remains an accurate reflection of project progress and helps maintain momentum.

64
.dockerignore Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage
.nyc_output
# Next.js
.next
out
dist
# Production
build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
.env
# Vercel
.vercel
# Typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Database
*.db
*.db-journal
dev.db
prisma/dev.db
# Prisma generated (will be regenerated in container)
prisma/generated

32
.env Normal file
View File

@@ -0,0 +1,32 @@
# Environment variables declared in this file are NOT automatically loaded by Prisma.
# Please add `import "dotenv/config";` to your `prisma.config.ts` file, or use the Prisma CLI with Bun
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
# DATABASE_URL="file:./data/dev.db"
# AUTH_SECRET="your-secret-key-change-this-in-production"
# AUTH_URL="http://localhost:3000"
# PRISMA_DATA_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/data"
# UPLOADS_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/public/uploads"
# NextAuth Configuration
NEXTAUTH_SECRET=change-this-secret-in-production
NEXTAUTH_URL=http://localhost:3000
# PostgreSQL Configuration
POSTGRES_USER=gotgaming
POSTGRES_PASSWORD=change-this-in-production
POSTGRES_DB=gotgaming
POSTGRES_HOST=localhost
POSTGRES_PORT=5433
# Database URL (construite automatiquement si non définie)
# Si vous définissez cette variable, elle sera utilisée telle quelle
# Sinon, elle sera construite à partir des variables POSTGRES_* ci-dessus
DATABASE_URL=postgresql://gotgaming:change-this-in-production@localhost:5433/gotgaming?schema=public
# Docker Volumes (optionnel)
POSTGRES_DATA_PATH=./data/postgres
UPLOADS_PATH=./public/uploads

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# NextAuth Configuration
NEXTAUTH_SECRET=change-this-secret-in-production
NEXTAUTH_URL=http://localhost:3000
# PostgreSQL Configuration
POSTGRES_USER=gotgaming
POSTGRES_PASSWORD=change-this-in-production
POSTGRES_DB=gotgaming
POSTGRES_HOST=got-postgres
POSTGRES_PORT=5432
# Database URL (construite automatiquement si non définie)
# Si vous définissez cette variable, elle sera utilisée telle quelle
# Sinon, elle sera construite à partir des variables POSTGRES_* ci-dessus
# DATABASE_URL=postgresql://gotgaming:change-this-in-production@got-postgres:5432/gotgaming?schema=public
# Docker Volumes (optionnel)
POSTGRES_DATA_PATH=./data/postgres
UPLOADS_PATH=./public/uploads

View File

@@ -0,0 +1,26 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main # adapte la branche que tu veux déployer
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
UPLOADS_PATH: ${{ vars.UPLOADS_PATH }}
POSTGRES_DATA_PATH: ${{ vars.POSTGRES_DATA_PATH }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: |
docker compose up -d --build

14
.gitignore vendored
View File

@@ -25,6 +25,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env*.local
# vercel
@@ -34,3 +35,16 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# database
*.db
*.db-journal
dev.db*
# prisma
/app/generated/prisma
prisma/generated/
# database data
data/postgres/
data/*.db
data/*.db-journal

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.next
out
build
dist
*.db
*.db-journal
pnpm-lock.yaml
package-lock.json
yarn.lock

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

95
Dockerfile Normal file
View File

@@ -0,0 +1,95 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Stage 2: Builder
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ARG pour DATABASE_URL au build (valeur factice par défaut, car prisma generate n'a pas besoin de vraie DB)
ARG DATABASE_URL_BUILD="postgresql://user:pass@localhost:5432/db"
ENV DATABASE_URL=$DATABASE_URL_BUILD
RUN pnpm prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copier les fichiers nécessaires
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/next.config.js ./next.config.js
# Copier le répertoire prisma complet (schema + migrations)
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# Copier prisma.config.ts (nécessaire pour Prisma 7)
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
# Installer seulement les dépendances de production puis générer Prisma Client
# ARG pour DATABASE_URL au build (valeur factice par défaut, car prisma generate n'a pas besoin de vraie DB)
# Au runtime, DATABASE_URL sera définie par docker-compose.yml (voir ligne 41)
ARG DATABASE_URL_BUILD="postgresql://user:pass@localhost:5432/db"
ENV DATABASE_URL=$DATABASE_URL_BUILD
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --prod && \
pnpm dlx prisma generate
# Ne pas définir ENV DATABASE_URL ici - elle sera définie par docker-compose.yml au runtime
# Create uploads directories
RUN mkdir -p /app/public/uploads /app/public/uploads/backgrounds && \
chown -R nextjs:nodejs /app/public/uploads
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
echo 'set -e' >> /app/entrypoint.sh && \
echo 'mkdir -p /app/public/uploads' >> /app/entrypoint.sh && \
echo 'mkdir -p /app/public/uploads/backgrounds' >> /app/entrypoint.sh && \
echo 'if [ -z "$DATABASE_URL" ]; then' >> /app/entrypoint.sh && \
echo ' echo "ERROR: DATABASE_URL is not set"' >> /app/entrypoint.sh && \
echo ' exit 1' >> /app/entrypoint.sh && \
echo 'fi' >> /app/entrypoint.sh && \
echo 'export DATABASE_URL' >> /app/entrypoint.sh && \
echo 'cd /app' >> /app/entrypoint.sh && \
echo 'echo "Applying migrations..."' >> /app/entrypoint.sh && \
echo 'if ! pnpm dlx prisma migrate deploy; then' >> /app/entrypoint.sh && \
echo ' echo "Migration failed. Attempting to resolve failed migration..."' >> /app/entrypoint.sh && \
echo ' pnpm dlx prisma migrate resolve --applied 20251217101717_init_postgres 2>/dev/null || true' >> /app/entrypoint.sh && \
echo ' pnpm dlx prisma migrate deploy || echo "WARNING: Some migrations may need manual resolution"' >> /app/entrypoint.sh && \
echo 'fi' >> /app/entrypoint.sh && \
echo 'exec pnpm start' >> /app/entrypoint.sh && \
chmod +x /app/entrypoint.sh && \
chown nextjs:nodejs /app/entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["./entrypoint.sh"]

98
README.docker.md Normal file
View File

@@ -0,0 +1,98 @@
# Docker Setup
Ce projet inclut des fichiers Docker pour faciliter le déploiement.
## Fichiers Docker
- `Dockerfile` - Image de production optimisée (multi-stage build)
- `docker-compose.yml` - Configuration pour la production
## Production
Pour construire et démarrer l'application en production :
```bash
# Construire l'image
docker-compose build
# Démarrer les services
docker-compose up -d
# Voir les logs
docker-compose logs -f
```
## Variables d'environnement
Créez un fichier `.env` à la racine du projet à partir du template `.env.example` :
```bash
cp .env.example .env
```
Puis modifiez les valeurs dans `.env` selon votre configuration :
```env
# NextAuth Configuration
NEXTAUTH_SECRET=your-secret-key-here
NEXTAUTH_URL=http://localhost:3000
# PostgreSQL Configuration
POSTGRES_USER=gotgaming
POSTGRES_PASSWORD=change-this-in-production
POSTGRES_DB=gotgaming
# Database URL (optionnel - construite automatiquement si non définie)
# DATABASE_URL=postgresql://gotgaming:change-this-in-production@got-postgres:5432/gotgaming?schema=public
# Docker Volumes (optionnel)
POSTGRES_DATA_PATH=./data/postgres
UPLOADS_PATH=./public/uploads
```
**Important** :
- Le fichier `.env` est ignoré par Git (ne pas commiter vos secrets)
- Si vous changez `POSTGRES_PASSWORD` après la première initialisation, vous devrez soit réinitialiser la base, soit changer le mot de passe manuellement dans PostgreSQL
## Volumes persistants
### Base de données PostgreSQL
La base de données PostgreSQL est persistée via un volume Docker. Par défaut, elle est stockée dans `./data/postgres`, mais vous pouvez la personnaliser avec la variable d'environnement `POSTGRES_DATA_PATH`.
Les migrations Prisma sont appliquées automatiquement au démarrage du conteneur.
Pour appliquer manuellement les migrations :
```bash
docker-compose exec got-app pnpm dlx prisma migrate deploy
```
### Images uploadées
Les images uploadées (avatars et backgrounds) sont persistées dans un volume Docker. Par défaut, elles sont stockées dans `./uploads` à la racine du projet, mais vous pouvez personnaliser le chemin avec la variable d'environnement `UPLOADS_PATH`.
- Les avatars sont stockés dans `uploads/`
- Les backgrounds sont stockés dans `uploads/backgrounds/`
Exemple pour utiliser un chemin personnalisé :
```bash
UPLOADS_PATH=/path/to/your/uploads docker-compose up -d
```
## Commandes utiles
```bash
# Arrêter les conteneurs
docker-compose down
# Reconstruire sans cache
docker-compose build --no-cache
# Accéder au shell du conteneur
docker-compose exec got-app sh
# Voir les logs en temps réel
docker-compose logs -f got-app
```

View File

@@ -1,6 +1,6 @@
# Farmine Land - Site Gaming
# Game of Tech - Peaksys
Site web Next.js pour le jeu Farmine Land, un MMORPG 2D Open-World.
Plateforme de gamification tech pour Peaksys. Transformez votre apprentissage en aventure : participez aux événements tech (ateliers, katas, présentations), gagnez de l'expérience, montez en niveau et affrontez vos collègues sur le classement.
## Installation
@@ -27,9 +27,13 @@ pnpm start
- `/app` - Pages et layout Next.js
- `/components` - Composants React réutilisables
- `Navigation.tsx` - Barre de navigation
- `HeroSection.tsx` - Section principale avec logo et description
- `Leaderboard.tsx` - Tableau des classements
- `Navigation.tsx` - Barre de navigation avec logo cliquable
- `HeroSection.tsx` - Section principale avec description
- `EventsSection.tsx` - Section des événements à venir
- `EventsPageSection.tsx` - Page complète des événements
- `LeaderboardSection.tsx` - Tableau des classements
- `PlayerStats.tsx` - Statistiques du joueur (HP, XP, niveau)
- `AdminPanel.tsx` - Panneau d'administration
## Technologies
@@ -37,4 +41,5 @@ pnpm start
- React 18
- TypeScript
- Tailwind CSS
- Prisma (PostgreSQL)
- NextAuth.js

270
actions/admin/challenges.ts Normal file
View File

@@ -0,0 +1,270 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { challengeService } from "@/services/challenges/challenge.service";
import { Role } from "@/prisma/generated/prisma/client";
import { ValidationError, NotFoundError } from "@/services/errors";
async function checkAdminAccess() {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé - Admin uniquement");
}
return session;
}
export async function validateChallenge(
challengeId: string,
winnerId: string,
adminComment?: string
) {
try {
const session = await checkAdminAccess();
const challenge = await challengeService.validateChallenge(
challengeId,
session.user.id,
winnerId,
adminComment
);
revalidatePath("/admin");
revalidatePath("/challenges");
revalidatePath("/leaderboard");
return {
success: true,
message: "Défi validé avec succès",
data: challenge,
};
} catch (error) {
console.error("Validate challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la validation du défi",
};
}
}
export async function rejectChallenge(
challengeId: string,
adminComment?: string
) {
try {
const session = await checkAdminAccess();
const challenge = await challengeService.rejectChallenge(
challengeId,
session.user.id,
adminComment
);
revalidatePath("/admin");
revalidatePath("/challenges");
return { success: true, message: "Défi rejeté", data: challenge };
} catch (error) {
console.error("Reject challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors du rejet du défi",
};
}
}
export async function updateChallenge(
challengeId: string,
data: {
title?: string;
description?: string;
pointsReward?: number;
}
) {
try {
await checkAdminAccess();
const challenge = await challengeService.updateChallenge(challengeId, {
title: data.title,
description: data.description,
pointsReward: data.pointsReward,
});
revalidatePath("/admin");
revalidatePath("/challenges");
return {
success: true,
message: "Défi mis à jour avec succès",
data: challenge,
};
} catch (error) {
console.error("Update challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la mise à jour du défi",
};
}
}
export async function deleteChallenge(challengeId: string) {
try {
await checkAdminAccess();
await challengeService.deleteChallenge(challengeId);
revalidatePath("/admin");
revalidatePath("/challenges");
return { success: true, message: "Défi supprimé avec succès" };
} catch (error) {
console.error("Delete challenge error:", error);
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la suppression du défi",
};
}
}
export async function adminCancelChallenge(challengeId: string) {
try {
await checkAdminAccess();
const challenge = await challengeService.adminCancelChallenge(challengeId);
revalidatePath("/admin");
revalidatePath("/challenges");
return {
success: true,
message: "Défi annulé avec succès",
data: challenge,
};
} catch (error) {
console.error("Admin cancel challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'annulation du défi",
};
}
}
export async function reactivateChallenge(challengeId: string) {
try {
await checkAdminAccess();
const challenge = await challengeService.reactivateChallenge(challengeId);
revalidatePath("/admin");
revalidatePath("/challenges");
return {
success: true,
message: "Défi réactivé avec succès",
data: challenge,
};
} catch (error) {
console.error("Reactivate challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la réactivation du défi",
};
}
}
export async function adminAcceptChallenge(challengeId: string) {
try {
await checkAdminAccess();
const challenge = await challengeService.adminAcceptChallenge(challengeId);
revalidatePath("/admin");
revalidatePath("/challenges");
return {
success: true,
message: "Défi accepté avec succès",
data: challenge,
};
} catch (error) {
console.error("Admin accept challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message.includes("Accès refusé")) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'acceptation du défi",
};
}
}

142
actions/admin/events.ts Normal file
View File

@@ -0,0 +1,142 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { eventService } from "@/services/events/event.service";
import { Role, EventType } from "@/prisma/generated/prisma/client";
import { ValidationError, NotFoundError } from "@/services/errors";
function checkAdminAccess() {
return async () => {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé");
}
return session;
};
}
export async function createEvent(data: {
date: string;
name: string;
description?: string | null;
type: string;
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
}) {
try {
await checkAdminAccess()();
const event = await eventService.validateAndCreateEvent({
date: data.date,
name: data.name,
description: data.description ?? "",
type: data.type as EventType,
room: data.room ?? undefined,
time: data.time ?? undefined,
maxPlaces: data.maxPlaces ?? undefined,
});
revalidatePath("/admin");
revalidatePath("/events");
revalidatePath("/");
return { success: true, data: event };
} catch (error) {
console.error("Error creating event:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la création de l'événement",
};
}
}
export async function updateEvent(
eventId: string,
data: {
date?: string;
name?: string;
description?: string | null;
type?: string;
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
}
) {
try {
await checkAdminAccess()();
const event = await eventService.validateAndUpdateEvent(eventId, {
date: data.date,
name: data.name,
description: data.description ?? undefined,
type: data.type as EventType,
room: data.room ?? undefined,
time: data.time ?? undefined,
maxPlaces: data.maxPlaces ?? undefined,
});
revalidatePath("/admin");
revalidatePath("/events");
revalidatePath("/");
return { success: true, data: event };
} catch (error) {
console.error("Error updating event:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la mise à jour de l'événement",
};
}
}
export async function deleteEvent(eventId: string) {
try {
await checkAdminAccess()();
const existingEvent = await eventService.getEventById(eventId);
if (!existingEvent) {
return { success: false, error: "Événement non trouvé" };
}
await eventService.deleteEvent(eventId);
revalidatePath("/admin");
revalidatePath("/events");
revalidatePath("/");
return { success: true };
} catch (error) {
console.error("Error deleting event:", error);
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la suppression de l'événement",
};
}
}

127
actions/admin/feedback.ts Normal file
View File

@@ -0,0 +1,127 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/services/database";
import { Role } from "@/prisma/generated/prisma/client";
import { NotFoundError } from "@/services/errors";
function checkAdminAccess() {
return async () => {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé");
}
return session;
};
}
export async function addFeedbackBonusPoints(
userId: string,
points: number
) {
try {
await checkAdminAccess()();
// Vérifier que l'utilisateur existe
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, score: true },
});
if (!user) {
throw new NotFoundError("Utilisateur");
}
// Ajouter les points
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
score: {
increment: points,
},
},
select: {
id: true,
username: true,
score: true,
},
});
revalidatePath("/admin");
revalidatePath("/leaderboard");
return {
success: true,
message: `${points} points ajoutés avec succès`,
data: updatedUser,
};
} catch (error) {
console.error("Error adding bonus points:", error);
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de l'ajout des points",
};
}
}
export async function markFeedbackAsRead(feedbackId: string, isRead: boolean) {
try {
await checkAdminAccess()();
// Vérifier que le feedback existe
const feedback = await prisma.eventFeedback.findUnique({
where: { id: feedbackId },
select: { id: true },
});
if (!feedback) {
throw new NotFoundError("Feedback");
}
// Mettre à jour le statut
const updatedFeedback = await prisma.eventFeedback.update({
where: { id: feedbackId },
data: {
isRead,
},
select: {
id: true,
isRead: true,
},
});
revalidatePath("/admin");
return {
success: true,
message: isRead
? "Feedback marqué comme lu"
: "Feedback marqué comme non lu",
data: updatedFeedback,
};
} catch (error) {
console.error("Error marking feedback as read:", error);
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la mise à jour du feedback",
};
}
}

139
actions/admin/houses.ts Normal file
View File

@@ -0,0 +1,139 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import { Role } from "@/prisma/generated/prisma/client";
import {
ValidationError,
NotFoundError,
ConflictError,
ForbiddenError,
} from "@/services/errors";
function checkAdminAccess() {
return async () => {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé");
}
return session;
};
}
export async function updateHouse(
houseId: string,
data: {
name?: string;
description?: string | null;
}
) {
try {
await checkAdminAccess()();
// L'admin peut modifier n'importe quelle maison sans vérifier les permissions normales
// On utilise directement le service mais on bypass les vérifications de propriétaire/admin
const house = await houseService.getHouseById(houseId);
if (!house) {
return { success: false, error: "Maison non trouvée" };
}
// Utiliser le service avec le creatorId pour bypass les vérifications
const updatedHouse = await houseService.updateHouse(
houseId,
house.creatorId, // Utiliser le creatorId pour bypass
data
);
revalidatePath("/admin");
revalidatePath("/houses");
return { success: true, data: updatedHouse };
} catch (error) {
console.error("Error updating house:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof ConflictError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la mise à jour de la maison",
};
}
}
export async function deleteHouse(houseId: string) {
try {
await checkAdminAccess()();
const house = await houseService.getHouseById(houseId);
if (!house) {
return { success: false, error: "Maison non trouvée" };
}
// L'admin peut supprimer n'importe quelle maison
// On utilise le creatorId pour bypass les vérifications
await houseService.deleteHouse(houseId, house.creatorId);
revalidatePath("/admin");
revalidatePath("/houses");
return { success: true };
} catch (error) {
console.error("Error deleting house:", error);
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof ForbiddenError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la suppression de la maison",
};
}
}
export async function removeMember(houseId: string, memberId: string) {
try {
await checkAdminAccess()();
// L'admin peut retirer n'importe quel membre (sauf le propriétaire)
await houseService.removeMemberAsAdmin(houseId, memberId);
revalidatePath("/admin");
revalidatePath("/houses");
return { success: true, message: "Membre retiré de la maison" };
} catch (error) {
console.error("Error removing member:", error);
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof ForbiddenError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors du retrait du membre",
};
}
}

View File

@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client";
function checkAdminAccess() {
return async () => {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé");
}
return session;
};
}
export async function updateSitePreferences(data: {
homeBackground?: string | null;
eventsBackground?: string | null;
leaderboardBackground?: string | null;
challengesBackground?: string | null;
profileBackground?: string | null;
houseBackground?: string | null;
eventRegistrationPoints?: number;
eventFeedbackPoints?: number;
houseJoinPoints?: number;
houseLeavePoints?: number;
houseCreatePoints?: number;
}) {
try {
await checkAdminAccess()();
const preferences = await sitePreferencesService.updateSitePreferences({
homeBackground: data.homeBackground,
eventsBackground: data.eventsBackground,
leaderboardBackground: data.leaderboardBackground,
challengesBackground: data.challengesBackground,
profileBackground: data.profileBackground,
houseBackground: data.houseBackground,
eventRegistrationPoints: data.eventRegistrationPoints,
eventFeedbackPoints: data.eventFeedbackPoints,
houseJoinPoints: data.houseJoinPoints,
houseLeavePoints: data.houseLeavePoints,
houseCreatePoints: data.houseCreatePoints,
});
revalidatePath("/admin");
revalidatePath("/");
revalidatePath("/events");
revalidatePath("/leaderboard");
revalidatePath("/challenges");
revalidatePath("/profile");
revalidatePath("/houses");
return { success: true, data: preferences };
} catch (error) {
console.error("Error updating admin preferences:", error);
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la mise à jour des préférences",
};
}
}

129
actions/admin/users.ts Normal file
View File

@@ -0,0 +1,129 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { userStatsService } from "@/services/users/user-stats.service";
import { Role } from "@/prisma/generated/prisma/client";
import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
function checkAdminAccess() {
return async () => {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
throw new Error("Accès refusé");
}
return session;
};
}
export async function updateUser(
userId: string,
data: {
username?: string;
avatar?: string | null;
hpDelta?: number;
xpDelta?: number;
score?: number;
level?: number;
role?: string;
}
) {
try {
await checkAdminAccess()();
// Valider username si fourni
if (data.username !== undefined) {
try {
await userService.validateAndUpdateUserProfile(userId, {
username: data.username,
});
} catch (error) {
if (
error instanceof ValidationError ||
error instanceof ConflictError
) {
return { success: false, error: error.message };
}
throw error;
}
}
// Mettre à jour stats et profil
const updatedUser = await userStatsService.updateUserStatsAndProfile(
userId,
{
username: data.username,
avatar: data.avatar,
hpDelta: data.hpDelta,
xpDelta: data.xpDelta,
score: data.score,
level: data.level,
role: data.role ? (data.role as Role) : undefined,
},
{
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
}
);
revalidatePath("/admin");
revalidatePath("/leaderboard");
return { success: true, data: updatedUser };
} catch (error) {
console.error("Error updating user:", error);
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la mise à jour de l'utilisateur",
};
}
}
export async function deleteUser(userId: string) {
try {
const session = await checkAdminAccess()();
await userService.validateAndDeleteUser(userId, session.user.id);
revalidatePath("/admin");
revalidatePath("/leaderboard");
return { success: true };
} catch (error) {
console.error("Error deleting user:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
if (error instanceof Error && error.message === "Accès refusé") {
return { success: false, error: "Accès refusé" };
}
return {
success: false,
error: "Erreur lors de la suppression de l'utilisateur",
};
}
}

View File

@@ -0,0 +1,131 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { challengeService } from "@/services/challenges/challenge.service";
import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function createChallenge(data: {
challengedId: string;
title: string;
description: string;
pointsReward?: number;
}) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté pour créer un défi",
};
}
const challenge = await challengeService.createChallenge({
challengerId: session.user.id,
challengedId: data.challengedId,
title: data.title,
description: data.description,
pointsReward: data.pointsReward || 100,
});
revalidatePath("/challenges");
revalidatePath("/profile");
return { success: true, message: "Défi créé avec succès", data: challenge };
} catch (error) {
console.error("Create challenge error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la création du défi",
};
}
}
export async function acceptChallenge(challengeId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté pour accepter un défi",
};
}
const challenge = await challengeService.acceptChallenge(
challengeId,
session.user.id
);
revalidatePath("/challenges");
revalidatePath("/profile");
return { success: true, message: "Défi accepté", data: challenge };
} catch (error) {
console.error("Accept challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'acceptation du défi",
};
}
}
export async function cancelChallenge(challengeId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté pour annuler un défi",
};
}
const challenge = await challengeService.cancelChallenge(
challengeId,
session.user.id
);
revalidatePath("/challenges");
revalidatePath("/profile");
return { success: true, message: "Défi annulé", data: challenge };
} catch (error) {
console.error("Cancel challenge error:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'annulation du défi",
};
}
}

View File

@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { eventFeedbackService } from "@/services/events/event-feedback.service";
import { ValidationError, NotFoundError } from "@/services/errors";
export async function createFeedback(
eventId: string,
data: {
rating: number;
comment?: string | null;
}
) {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Non authentifié" };
}
const feedback = await eventFeedbackService.validateAndCreateFeedback(
session.user.id,
eventId,
{ rating: data.rating, comment: data.comment }
);
revalidatePath(`/feedback/${eventId}`);
revalidatePath("/events");
return { success: true, data: feedback };
} catch (error) {
console.error("Error saving feedback:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Erreur lors de l'enregistrement du feedback",
};
}
}

View File

@@ -0,0 +1,77 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function registerForEvent(eventId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté pour vous inscrire",
};
}
const registration = await eventRegistrationService.validateAndRegisterUser(
session.user.id,
eventId
);
revalidatePath("/events");
revalidatePath("/");
return {
success: true,
message: "Inscription réussie",
data: registration,
};
} catch (error) {
console.error("Registration error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'inscription",
};
}
}
export async function unregisterFromEvent(eventId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Vous devez être connecté" };
}
await eventRegistrationService.unregisterUserFromEvent(
session.user.id,
eventId
);
revalidatePath("/events");
revalidatePath("/");
return { success: true, message: "Inscription annulée" };
} catch (error) {
console.error("Unregistration error:", error);
return {
success: false,
error: "Une erreur est survenue lors de l'annulation",
};
}
}

48
actions/houses/create.ts Normal file
View File

@@ -0,0 +1,48 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import {
ValidationError,
ConflictError,
} from "@/services/errors";
export async function createHouse(data: {
name: string;
description?: string | null;
}) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté pour créer une maison",
};
}
const house = await houseService.createHouse({
name: data.name,
description: data.description,
creatorId: session.user.id,
});
revalidatePath("/houses");
revalidatePath("/profile");
return { success: true, message: "Maison créée avec succès", data: house };
} catch (error) {
console.error("Create house error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la création de la maison",
};
}
}

View File

@@ -0,0 +1,173 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import {
ValidationError,
ConflictError,
ForbiddenError,
NotFoundError,
} from "@/services/errors";
export async function inviteUser(houseId: string, inviteeId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
const invitation = await houseService.inviteUser({
houseId,
inviterId: session.user.id,
inviteeId,
});
revalidatePath("/houses");
revalidatePath(`/houses/${houseId}`);
return {
success: true,
message: "Invitation envoyée",
data: invitation,
};
} catch (error) {
console.error("Invite user error:", error);
if (
error instanceof ValidationError ||
error instanceof ConflictError ||
error instanceof ForbiddenError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'envoi de l'invitation",
};
}
}
export async function acceptInvitation(invitationId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
const membership = await houseService.acceptInvitation(
invitationId,
session.user.id
);
revalidatePath("/houses");
revalidatePath("/profile");
revalidatePath("/invitations");
return {
success: true,
message: "Invitation acceptée",
data: membership,
};
} catch (error) {
console.error("Accept invitation error:", error);
if (
error instanceof ValidationError ||
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'acceptation de l'invitation",
};
}
}
export async function rejectInvitation(invitationId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.rejectInvitation(invitationId, session.user.id);
revalidatePath("/houses");
revalidatePath("/invitations");
return { success: true, message: "Invitation refusée" };
} catch (error) {
console.error("Reject invitation error:", error);
if (
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors du refus de l'invitation",
};
}
}
export async function cancelInvitation(invitationId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
// Récupérer l'invitation pour obtenir le houseId avant de l'annuler
const invitation = await houseService.getInvitationById(invitationId);
await houseService.cancelInvitation(invitationId, session.user.id);
revalidatePath("/houses");
if (invitation?.houseId) {
revalidatePath(`/houses/${invitation.houseId}`);
}
return { success: true, message: "Invitation annulée" };
} catch (error) {
console.error("Cancel invitation error:", error);
if (
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'annulation de l'invitation",
};
}
}

163
actions/houses/requests.ts Normal file
View File

@@ -0,0 +1,163 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import {
ValidationError,
ConflictError,
ForbiddenError,
NotFoundError,
} from "@/services/errors";
export async function requestToJoin(houseId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
const request = await houseService.requestToJoin({
houseId,
requesterId: session.user.id,
});
revalidatePath("/houses");
revalidatePath(`/houses/${houseId}`);
return {
success: true,
message: "Demande envoyée",
data: request,
};
} catch (error) {
console.error("Request to join error:", error);
if (
error instanceof ValidationError ||
error instanceof ConflictError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'envoi de la demande",
};
}
}
export async function acceptRequest(requestId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
const membership = await houseService.acceptRequest(
requestId,
session.user.id
);
revalidatePath("/houses");
revalidatePath("/profile");
return {
success: true,
message: "Demande acceptée",
data: membership,
};
} catch (error) {
console.error("Accept request error:", error);
if (
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'acceptation de la demande",
};
}
}
export async function rejectRequest(requestId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.rejectRequest(requestId, session.user.id);
revalidatePath("/houses");
return { success: true, message: "Demande refusée" };
} catch (error) {
console.error("Reject request error:", error);
if (
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors du refus de la demande",
};
}
}
export async function cancelRequest(requestId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.cancelRequest(requestId, session.user.id);
revalidatePath("/houses");
return { success: true, message: "Demande annulée" };
} catch (error) {
console.error("Cancel request error:", error);
if (
error instanceof ConflictError ||
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de l'annulation de la demande",
};
}
}

149
actions/houses/update.ts Normal file
View File

@@ -0,0 +1,149 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import {
ValidationError,
ConflictError,
ForbiddenError,
NotFoundError,
} from "@/services/errors";
export async function updateHouse(
houseId: string,
data: {
name?: string;
description?: string | null;
}
) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
const house = await houseService.updateHouse(houseId, session.user.id, data);
revalidatePath("/houses");
revalidatePath(`/houses/${houseId}`);
return { success: true, message: "Maison mise à jour", data: house };
} catch (error) {
console.error("Update house error:", error);
if (
error instanceof ValidationError ||
error instanceof ConflictError ||
error instanceof ForbiddenError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la mise à jour de la maison",
};
}
}
export async function deleteHouse(houseId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.deleteHouse(houseId, session.user.id);
revalidatePath("/houses");
revalidatePath("/profile");
return { success: true, message: "Maison supprimée" };
} catch (error) {
console.error("Delete house error:", error);
if (error instanceof ForbiddenError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la suppression de la maison",
};
}
}
export async function leaveHouse(houseId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.leaveHouse(houseId, session.user.id);
revalidatePath("/houses");
revalidatePath("/profile");
return { success: true, message: "Vous avez quitté la maison" };
} catch (error) {
console.error("Leave house error:", error);
if (error instanceof ForbiddenError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors de la sortie de la maison",
};
}
}
export async function removeMember(houseId: string, memberId: string) {
try {
const session = await auth();
if (!session?.user?.id) {
return {
success: false,
error: "Vous devez être connecté",
};
}
await houseService.removeMember(houseId, memberId, session.user.id);
revalidatePath("/houses");
revalidatePath("/profile");
return { success: true, message: "Membre retiré de la maison" };
} catch (error) {
console.error("Remove member error:", error);
if (
error instanceof ForbiddenError ||
error instanceof NotFoundError
) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Une erreur est survenue lors du retrait du membre",
};
}
}

View File

@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { ValidationError, NotFoundError } from "@/services/errors";
export async function updatePassword(data: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) {
try {
const session = await auth();
if (!session?.user) {
return { success: false, error: "Non authentifié" };
}
await userService.validateAndUpdatePassword(
session.user.id,
data.currentPassword,
data.newPassword,
data.confirmPassword
);
revalidatePath("/profile");
return { success: true, message: "Mot de passe modifié avec succès" };
} catch (error) {
console.error("Error updating password:", error);
if (error instanceof ValidationError) {
return { success: false, error: error.message };
}
if (error instanceof NotFoundError) {
return { success: false, error: error.message };
}
return {
success: false,
error: "Erreur lors de la modification du mot de passe",
};
}
}

View File

@@ -0,0 +1,61 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { CharacterClass } from "@/prisma/generated/prisma/client";
import { ValidationError, ConflictError } from "@/services/errors";
export async function updateProfile(data: {
username?: string;
avatar?: string | null;
bio?: string | null;
characterClass?: string | null;
}) {
try {
const session = await auth();
if (!session?.user) {
return { success: false, error: "Non authentifié" };
}
const updatedUser = await userService.validateAndUpdateUserProfile(
session.user.id,
{
username: data.username,
avatar: data.avatar,
bio: data.bio,
characterClass: data.characterClass
? (data.characterClass as CharacterClass)
: null,
},
{
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
}
);
revalidatePath("/profile");
revalidatePath("/");
return { success: true, data: updatedUser };
} catch (error) {
console.error("Error updating profile:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return { success: false, error: error.message };
}
return { success: false, error: "Erreur lors de la mise à jour du profil" };
}
}

View File

@@ -0,0 +1,26 @@
import ChallengeManagement from "@/components/admin/ChallengeManagement";
import { Card } from "@/components/ui";
import { challengeService } from "@/services/challenges/challenge.service";
export const dynamic = "force-dynamic";
export default async function AdminChallengesPage() {
const challenges = await challengeService.getAllChallenges();
// Sérialiser les dates pour le client
const serializedChallenges = challenges.map((challenge) => ({
...challenge,
createdAt: challenge.createdAt.toISOString(),
acceptedAt: challenge.acceptedAt?.toISOString() ?? null,
completedAt: challenge.completedAt?.toISOString() ?? null,
}));
return (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Défis
</h2>
<ChallengeManagement initialChallenges={serializedChallenges} />
</Card>
);
}

34
app/admin/events/page.tsx Normal file
View File

@@ -0,0 +1,34 @@
import EventManagement from "@/components/admin/EventManagement";
import { Card } from "@/components/ui";
import { eventService } from "@/services/events/event.service";
export const dynamic = "force-dynamic";
export default async function AdminEventsPage() {
const events = await eventService.getEventsWithStatus();
// Transformer les données pour la sérialisation
const serializedEvents = events.map((event) => ({
id: event.id,
date: event.date.toISOString(),
name: event.name,
description: event.description,
type: event.type,
status: event.status,
room: event.room,
time: event.time,
maxPlaces: event.maxPlaces,
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
registrationsCount: event.registrationsCount,
}));
return (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Événements
</h2>
<EventManagement initialEvents={serializedEvents} />
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import FeedbackManagement from "@/components/admin/FeedbackManagement";
import { Card } from "@/components/ui";
import { eventFeedbackService } from "@/services/events/event-feedback.service";
export const dynamic = "force-dynamic";
export default async function AdminFeedbacksPage() {
const [feedbacksRaw, statistics] = await Promise.all([
eventFeedbackService.getAllFeedbacks(),
eventFeedbackService.getFeedbackStatistics(),
]);
// Type assertion car getAllFeedbacks inclut event et user par défaut
const feedbacks = feedbacksRaw as unknown as Array<{
id: string;
rating: number;
comment: string | null;
isRead: boolean;
createdAt: Date;
event: {
id: string;
name: string;
date: Date;
type: string;
};
user: {
id: string;
username: string;
email: string;
avatar: string | null;
score: number;
};
}>;
// Sérialiser les dates pour le client
const serializedFeedbacks = feedbacks.map((feedback) => ({
id: feedback.id,
rating: feedback.rating,
comment: feedback.comment,
isRead: feedback.isRead,
createdAt: feedback.createdAt.toISOString(),
event: {
id: feedback.event.id,
name: feedback.event.name,
date: feedback.event.date.toISOString(),
type: feedback.event.type,
},
user: {
id: feedback.user.id,
username: feedback.user.username,
email: feedback.user.email,
avatar: feedback.user.avatar,
score: feedback.user.score,
},
}));
const serializedStatistics = statistics.map((stat) => ({
eventId: stat.eventId,
eventName: stat.eventName,
eventDate: stat.eventDate?.toISOString() ?? null,
eventType: stat.eventType,
averageRating: stat.averageRating,
feedbackCount: stat.feedbackCount,
}));
return (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Feedbacks
</h2>
<FeedbackManagement
initialFeedbacks={serializedFeedbacks}
initialStatistics={serializedStatistics}
/>
</Card>
);
}

90
app/admin/houses/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import HouseManagement from "@/components/admin/HouseManagement";
import { Card } from "@/components/ui";
import { houseService } from "@/services/houses/house.service";
import { Prisma } from "@/prisma/generated/prisma/client";
export const dynamic = "force-dynamic";
export default async function AdminHousesPage() {
type HouseWithIncludes = Prisma.HouseGetPayload<{
include: {
creator: {
select: {
id: true;
username: true;
avatar: true;
};
};
memberships: {
include: {
user: {
select: {
id: true;
username: true;
avatar: true;
score: true;
level: true;
};
};
};
};
};
}>;
const houses = (await houseService.getAllHouses({
include: {
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
orderBy: [{ role: "asc" }, { joinedAt: "asc" }],
},
},
orderBy: {
createdAt: "desc",
},
})) as unknown as HouseWithIncludes[];
// Transformer les données pour la sérialisation
const serializedHouses = houses.map((house) => ({
id: house.id,
name: house.name,
description: house.description,
creatorId: house.creatorId,
creator: house.creator,
createdAt: house.createdAt.toISOString(),
updatedAt: house.updatedAt.toISOString(),
membersCount: house.memberships?.length || 0,
memberships:
house.memberships?.map((membership) => ({
id: membership.id,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
user: membership.user,
})) || [],
}));
return (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Maisons
</h2>
<HouseManagement initialHouses={serializedHouses} />
</Card>
);
}

52
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import AdminNavigation from "@/components/admin/AdminNavigation";
import { SectionTitle } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== Role.ADMIN) {
redirect("/");
}
return (
<main className="min-h-screen bg-black relative">
{/* Background Image */}
<div
className="fixed inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/got-light.jpg')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
<NavigationWrapper />
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
<SectionTitle variant="gradient" size="md" className="mb-16 text-center">
ADMIN
</SectionTitle>
<AdminNavigation />
{children}
</div>
</section>
</main>
);
}

7
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
redirect("/admin/preferences");
}

View File

@@ -0,0 +1,30 @@
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
import EventPointsPreferences from "@/components/admin/EventPointsPreferences";
import EventFeedbackPointsPreferences from "@/components/admin/EventFeedbackPointsPreferences";
import HousePointsPreferences from "@/components/admin/HousePointsPreferences";
import { Card } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function AdminPreferencesPage() {
const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
return (
<Card variant="dark" className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h2 className="text-xl sm:text-2xl font-gaming font-bold text-pixel-gold break-words">
Préférences UI Globales
</h2>
</div>
<div className="space-y-4">
<BackgroundPreferences initialPreferences={sitePreferences} />
<EventPointsPreferences initialPreferences={sitePreferences} />
<EventFeedbackPointsPreferences initialPreferences={sitePreferences} />
<HousePointsPreferences initialPreferences={sitePreferences} />
</div>
</Card>
);
}

42
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import UserManagement from "@/components/admin/UserManagement";
import { Card } from "@/components/ui";
import { userService } from "@/services/users/user.service";
export const dynamic = "force-dynamic";
export default async function AdminUsersPage() {
const users = await userService.getAllUsers({
orderBy: {
score: "desc",
},
select: {
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
createdAt: true,
},
});
// Sérialiser les dates pour le client
const serializedUsers = users.map((user) => ({
...user,
createdAt: user.createdAt.toISOString(),
}));
return (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Utilisateurs
</h2>
<UserManagement initialUsers={serializedUsers} />
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Le fichier doit être une image" },
{ status: 400 }
);
}
// Limiter la taille (par exemple 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: "L'image est trop grande (max 5MB)" },
{ status: 400 }
);
}
// Créer le dossier uploads/avatars s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads");
const avatarsDir = join(uploadsDir, "avatars");
if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
}
// Générer un nom de fichier unique
const timestamp = Date.now();
const filename = `avatar-admin-${timestamp}-${file.name}`;
const filepath = join(avatarsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image via l'API
const imageUrl = `/api/avatars/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading avatar:", error);
return NextResponse.json(
{ error: "Erreur lors de l'upload de l'avatar" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { challengeService } from "@/services/challenges/challenge.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer tous les défis pour l'admin (PENDING, ACCEPTED, CANCELLED, COMPLETED, REJECTED)
const challenges = await challengeService.getAllChallenges();
return NextResponse.json(challenges);
} catch (error) {
console.error("Error fetching challenges:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des défis" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const { id: eventId } = await params;
const registrations = await eventRegistrationService.getEventRegistrations(
eventId
);
return NextResponse.json(registrations);
} catch (error) {
console.error("Error fetching event registrations:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des inscrits" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventService } from "@/services/events/event.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const events = await eventService.getEventsWithStatus();
// Transformer les données pour la sérialisation
const eventsWithCount = events.map((event) => ({
id: event.id,
date: event.date.toISOString(),
name: event.name,
description: event.description,
type: event.type,
status: event.status,
room: event.room,
time: event.time,
maxPlaces: event.maxPlaces,
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
registrationsCount: event.registrationsCount,
}));
return NextResponse.json(eventsWithCount);
} catch (error) {
console.error("Error fetching events:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des événements" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventFeedbackService } from "@/services/events/event-feedback.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
if (session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur
const feedbacks = await eventFeedbackService.getAllFeedbacks();
// Calculer les statistiques par événement
const statistics = await eventFeedbackService.getFeedbackStatistics();
return NextResponse.json({
feedbacks,
statistics,
});
} catch (error) {
console.error("Error fetching feedbacks:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des feedbacks" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
import { Role, Prisma } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer toutes les maisons avec leurs membres
type HouseWithIncludes = Prisma.HouseGetPayload<{
include: {
creator: {
select: {
id: true;
username: true;
avatar: true;
};
};
memberships: {
include: {
user: {
select: {
id: true;
username: true;
avatar: true;
score: true;
level: true;
};
};
};
};
};
}>;
const houses = (await houseService.getAllHouses({
include: {
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
orderBy: [
{ role: "asc" }, // OWNER, ADMIN, MEMBER
{ joinedAt: "asc" },
],
},
},
orderBy: {
createdAt: "desc",
},
})) as unknown as HouseWithIncludes[];
// Transformer les données pour la sérialisation
const housesWithData = houses.map((house) => ({
id: house.id,
name: house.name,
description: house.description,
creatorId: house.creatorId,
creator: house.creator,
createdAt: house.createdAt.toISOString(),
updatedAt: house.updatedAt.toISOString(),
membersCount: house.memberships?.length || 0,
memberships:
house.memberships?.map((membership) => ({
id: membership.id,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
user: membership.user,
})) || [],
}));
return NextResponse.json(housesWithData);
} catch (error) {
console.error("Error fetching houses:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des maisons" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import { readdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const images: string[] = [];
// Lister uniquement les images dans public/uploads/backgrounds/
const publicDir = join(process.cwd(), "public");
const uploadsDir = join(publicDir, "uploads");
const backgroundsDir = join(uploadsDir, "backgrounds");
if (existsSync(backgroundsDir)) {
const files = await readdir(backgroundsDir);
const imageFiles = files.filter(
(file) =>
file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".")
);
images.push(...imageFiles.map((file) => `/api/backgrounds/${file}`));
}
return NextResponse.json({ images });
} catch (error) {
console.error("Error listing images:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des images" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { Role } from "@/prisma/generated/prisma/client";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Le fichier doit être une image" },
{ status: 400 }
);
}
// Créer le dossier uploads/backgrounds s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads");
const backgroundsDir = join(uploadsDir, "backgrounds");
if (!existsSync(backgroundsDir)) {
await mkdir(backgroundsDir, { recursive: true });
}
// Générer un nom de fichier unique
const timestamp = Date.now();
const filename = `${timestamp}-${file.name}`;
const filepath = join(backgroundsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image via l'API
const imageUrl = `/api/backgrounds/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading image:", error);
return NextResponse.json(
{ error: "Erreur lors de l'upload de l'image" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
return NextResponse.json(sitePreferences);
} catch (error) {
console.error("Error fetching admin preferences:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des préférences" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
const session = await auth();
if (!session?.user || session.user.role !== Role.ADMIN) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// Récupérer tous les utilisateurs avec leurs stats
const users = await userService.getAllUsers({
orderBy: {
score: "desc",
},
select: {
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
createdAt: true,
},
});
return NextResponse.json(users);
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des utilisateurs" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Sécuriser le nom de fichier pour éviter les path traversal
if (
!filename ||
filename.includes("..") ||
filename.includes("/") ||
filename.includes("\\")
) {
return NextResponse.json(
{ error: "Nom de fichier invalide" },
{ status: 400 }
);
}
// Décoder le nom de fichier (au cas où il contient des caractères encodés)
const decodedFilename = decodeURIComponent(filename);
// Chemin vers le fichier avatar
const avatarsDir = join(process.cwd(), "public", "uploads", "avatars");
const filepath = join(avatarsDir, decodedFilename);
// Vérifier que le fichier existe
if (!existsSync(filepath)) {
return NextResponse.json({ error: "Avatar non trouvé" }, { status: 404 });
}
// Lire le fichier
const fileBuffer = await readFile(filepath);
// Déterminer le type MIME basé sur l'extension
const extension = decodedFilename.split(".").pop()?.toLowerCase();
let contentType = "image/jpeg"; // par défaut
switch (extension) {
case "png":
contentType = "image/png";
break;
case "gif":
contentType = "image/gif";
break;
case "webp":
contentType = "image/webp";
break;
case "svg":
contentType = "image/svg+xml";
break;
case "jpg":
case "jpeg":
default:
contentType = "image/jpeg";
}
// Retourner l'image avec les bons headers
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error serving avatar:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'avatar" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Sécuriser le nom de fichier pour éviter les path traversal
if (
!filename ||
filename.includes("..") ||
filename.includes("/") ||
filename.includes("\\")
) {
return NextResponse.json(
{ error: "Nom de fichier invalide" },
{ status: 400 }
);
}
// Décoder le nom de fichier (au cas où il contient des caractères encodés)
const decodedFilename = decodeURIComponent(filename);
// Chemin vers le fichier background
const backgroundsDir = join(
process.cwd(),
"public",
"uploads",
"backgrounds"
);
const filepath = join(backgroundsDir, decodedFilename);
// Vérifier que le fichier existe
if (!existsSync(filepath)) {
return NextResponse.json(
{ error: "Image de fond non trouvée" },
{ status: 404 }
);
}
// Lire le fichier
const fileBuffer = await readFile(filepath);
// Déterminer le type MIME basé sur l'extension
const extension = decodedFilename.split(".").pop()?.toLowerCase();
let contentType = "image/jpeg"; // par défaut
switch (extension) {
case "png":
contentType = "image/png";
break;
case "gif":
contentType = "image/gif";
break;
case "webp":
contentType = "image/webp";
break;
case "svg":
contentType = "image/svg+xml";
break;
case "jpg":
case "jpeg":
default:
contentType = "image/jpeg";
}
// Retourner l'image avec les bons headers
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error serving background:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'image de fond" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { challengeService } from "@/services/challenges/challenge.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ count: 0 });
}
const count = await challengeService.getActiveChallengesCount(
session.user.id
);
return NextResponse.json({ count });
} catch (error) {
console.error("Error fetching active challenges count:", error);
return NextResponse.json({ count: 0 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { challengeService } from "@/services/challenges/challenge.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
// Récupérer tous les défis de l'utilisateur
const challenges = await challengeService.getUserChallenges(
session.user.id
);
return NextResponse.json(challenges);
} catch (error) {
console.error("Error fetching challenges:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des défis" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventRegistrationService } from "@/services/events/event-registration.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ registered: false });
}
const { id: eventId } = await params;
const isRegistered = await eventRegistrationService.checkUserRegistration(
session.user.id,
eventId
);
return NextResponse.json({ registered: isRegistered });
} catch (error) {
console.error("Check registration error:", error);
return NextResponse.json({ registered: false });
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { eventService } from "@/services/events/event.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const event = await eventService.getEventById(id);
if (!event) {
return NextResponse.json(
{ error: "Événement introuvable" },
{ status: 404 }
);
}
return NextResponse.json(event);
} catch (error) {
console.error("Error fetching event:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'événement" },
{ status: 500 }
);
}
}

18
app/api/events/route.ts Normal file
View File

@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { eventService } from "@/services/events/event.service";
export async function GET() {
try {
const events = await eventService.getAllEvents({
orderBy: { date: "asc" },
});
return NextResponse.json(events);
} catch (error) {
console.error("Error fetching events:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des événements" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { eventFeedbackService } from "@/services/events/event-feedback.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ eventId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const { eventId } = await params;
// Récupérer le feedback de l'utilisateur pour cet événement
const feedback = await eventFeedbackService.getUserFeedback(
session.user.id,
eventId
);
return NextResponse.json({ feedback });
} catch (error) {
console.error("Error fetching feedback:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération du feedback" },
{ status: 500 }
);
}
}

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok" });
}

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ houseId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { houseId } = await params;
// Vérifier que l'utilisateur est membre de la maison
const isMember = await houseService.isUserMemberOfHouse(
session.user.id,
houseId
);
if (!isMember) {
return NextResponse.json(
{ error: "Vous devez être membre de cette maison" },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | null;
const invitations = await houseService.getHouseInvitations(
houseId,
status || undefined
);
return NextResponse.json(invitations);
} catch (error) {
console.error("Error fetching house invitations:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des invitations" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ houseId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { houseId } = await params;
// Vérifier que l'utilisateur est propriétaire ou admin
const isAuthorized = await houseService.isUserOwnerOrAdmin(
session.user.id,
houseId
);
if (!isAuthorized) {
return NextResponse.json(
{ error: "Vous n'avez pas les permissions pour voir les demandes" },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | null;
const requests = await houseService.getHouseRequests(houseId, status || undefined);
return NextResponse.json(requests);
} catch (error) {
console.error("Error fetching house requests:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des demandes" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ houseId: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { houseId } = await params;
const house = await houseService.getHouseById(houseId, {
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
},
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
});
if (!house) {
return NextResponse.json(
{ error: "Maison non trouvée" },
{ status: 404 }
);
}
return NextResponse.json(house);
} catch (error) {
console.error("Error fetching house:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de la maison" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const house = await houseService.getUserHouse(session.user.id, {
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
},
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
});
return NextResponse.json(house);
} catch (error) {
console.error("Error fetching user house:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de votre maison" },
{ status: 500 }
);
}
}

87
app/api/houses/route.ts Normal file
View File

@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const search = searchParams.get("search");
const include = searchParams.get("include")?.split(",") || [];
const includeOptions: {
memberships?: {
include: {
user: {
select: {
id: boolean;
username: boolean;
avatar: boolean;
score?: boolean;
level?: boolean;
};
};
};
};
creator?: {
select: {
id: boolean;
username: boolean;
avatar: boolean;
};
};
} = {};
if (include.includes("members")) {
includeOptions.memberships = {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
};
}
if (include.includes("creator")) {
includeOptions.creator = {
select: {
id: true,
username: true,
avatar: true,
},
};
}
let houses;
if (search) {
houses = await houseService.searchHouses(search, {
include: includeOptions,
});
} else {
houses = await houseService.getAllHouses({
include: includeOptions,
});
}
return NextResponse.json(houses);
} catch (error) {
console.error("Error fetching houses:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des maisons" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ count: 0 });
}
// Compter les invitations ET les demandes d'adhésion en attente
const count = await houseService.getPendingHouseActionsCount(
session.user.id
);
return NextResponse.json({ count });
} catch (error) {
console.error("Error fetching pending house actions count:", error);
return NextResponse.json({ count: 0 });
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { houseService } from "@/services/houses/house.service";
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const statusParam = searchParams.get("status");
const status = statusParam && ["PENDING", "ACCEPTED", "REJECTED", "CANCELLED"].includes(statusParam)
? (statusParam as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED")
: undefined;
const invitations = await houseService.getUserInvitations(
session.user.id,
status
);
return NextResponse.json(invitations);
} catch (error) {
console.error("Error fetching invitations:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des invitations" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { userStatsService } from "@/services/users/user-stats.service";
export async function GET() {
try {
const leaderboard = await userStatsService.getHouseLeaderboard(10);
return NextResponse.json(leaderboard);
} catch (error) {
console.error("Error fetching house leaderboard:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération du leaderboard des maisons" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { userStatsService } from "@/services/users/user-stats.service";
export async function GET() {
try {
const leaderboard = await userStatsService.getLeaderboard(10);
return NextResponse.json(leaderboard);
} catch (error) {
console.error("Error fetching leaderboard:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération du leaderboard" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
export async function GET() {
try {
// Récupérer les préférences globales du site (pas besoin d'authentification)
const sitePreferences = await sitePreferencesService.getSitePreferences();
// Si elles n'existent pas, retourner des valeurs par défaut
if (!sitePreferences) {
return NextResponse.json({
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
});
}
return NextResponse.json({
homeBackground: sitePreferences.homeBackground,
eventsBackground: sitePreferences.eventsBackground,
leaderboardBackground: sitePreferences.leaderboardBackground,
challengesBackground: sitePreferences.challengesBackground,
profileBackground: sitePreferences.profileBackground,
houseBackground: sitePreferences.houseBackground,
});
} catch (error) {
console.error("Error fetching preferences:", error);
return NextResponse.json(
{
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
},
{ status: 200 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Le fichier doit être une image" },
{ status: 400 }
);
}
// Limiter la taille (par exemple 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: "L'image est trop grande (max 5MB)" },
{ status: 400 }
);
}
// Créer le dossier uploads/avatars s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads");
const avatarsDir = join(uploadsDir, "avatars");
if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
}
// Générer un nom de fichier unique avec l'ID utilisateur
const timestamp = Date.now();
const filename = `avatar-${session.user.id}-${timestamp}-${file.name}`;
const filepath = join(avatarsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image via l'API
const imageUrl = `/api/avatars/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading avatar:", error);
return NextResponse.json(
{ error: "Erreur lors de l'upload de l'avatar" },
{ status: 500 }
);
}
}

44
app/api/profile/route.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const user = await userService.getUserById(session.user.id, {
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
createdAt: true,
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
return NextResponse.json(user);
} catch (error) {
console.error("Error fetching profile:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération du profil" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
if (!file.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Le fichier doit être une image" },
{ status: 400 }
);
}
// Limiter la taille (par exemple 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: "L'image est trop grande (max 5MB)" },
{ status: 400 }
);
}
// Créer le dossier uploads/avatars s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads");
const avatarsDir = join(uploadsDir, "avatars");
if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
}
// Générer un nom de fichier unique avec timestamp
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 9);
const filename = `avatar-register-${timestamp}-${randomId}-${file.name}`;
const filepath = join(avatarsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image via l'API
const imageUrl = `/api/avatars/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading avatar:", error);
return NextResponse.json(
{ error: "Erreur lors de l'upload de l'avatar" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { userService } from "@/services/users/user.service";
import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function POST(request: Request) {
try {
const body = await request.json();
const { userId, username, avatar, bio, characterClass } = body;
if (!userId) {
return NextResponse.json(
{ error: "ID utilisateur requis" },
{ status: 400 }
);
}
const updatedUser = await userService.validateAndCompleteRegistration(
userId,
{ username, avatar, bio, characterClass }
);
return NextResponse.json({
message: "Profil finalisé avec succès",
userId: updatedUser.id,
});
} catch (error) {
console.error("Error completing registration:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
return NextResponse.json(
{
error: `Erreur lors de la finalisation de l'inscription: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
},
{ status: 500 }
);
}
}

35
app/api/register/route.ts Normal file
View File

@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { userService } from "@/services/users/user.service";
import { ValidationError, ConflictError } from "@/services/errors";
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, username, password, bio, characterClass, avatar } = body;
const user = await userService.validateAndCreateUser({
email,
username,
password,
bio,
characterClass,
avatar,
});
return NextResponse.json(
{ message: "Compte créé avec succès", userId: user.id },
{ status: 201 }
);
} catch (error) {
console.error("Registration error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
const { id } = await params;
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Vérifier que l'utilisateur demande ses propres données ou est admin
if (session.user.id !== id && session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const user = await userService.getUserById(id, {
id: true,
username: true,
avatar: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
return NextResponse.json(user);
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération de l'utilisateur" },
{ status: 500 }
);
}
}

43
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
// Récupérer tous les utilisateurs (pour sélectionner qui défier)
const users = await userService.getAllUsers({
orderBy: {
username: "asc",
},
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
});
// Filtrer l'utilisateur actuel
const otherUsers = users.filter((user) => user.id !== session.user.id);
return NextResponse.json(otherUsers);
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des utilisateurs" },
{ status: 500 }
);
}
}

55
app/challenges/page.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import ChallengesSection from "@/components/challenges/ChallengesSection";
import { challengeService } from "@/services/challenges/challenge.service";
import { userService } from "@/services/users/user.service";
export const dynamic = "force-dynamic";
export default async function ChallengesPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const [challengesRaw, users, backgroundImage] = await Promise.all([
challengeService.getUserChallenges(session.user.id),
userService
.getAllUsers({
orderBy: {
username: "asc",
},
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
})
.then((users) => users.filter((user) => user.id !== session.user.id)),
getBackgroundImage("challenges", "/got-2.jpg"),
]);
// Convertir les dates Date en string pour correspondre au type attendu par le composant
const challenges = challengesRaw.map((challenge) => ({
...challenge,
createdAt: challenge.createdAt.toISOString(),
acceptedAt: challenge.acceptedAt?.toISOString() ?? null,
completedAt: challenge.completedAt?.toISOString() ?? null,
}));
return (
<main className="min-h-screen bg-black relative">
<NavigationWrapper />
<ChallengesSection
initialChallenges={challenges}
initialUsers={users}
backgroundImage={backgroundImage}
/>
</main>
);
}

View File

@@ -1,12 +1,48 @@
import Navigation from "@/components/Navigation";
import EventsPageSection from "@/components/EventsPageSection";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import EventsPageSection from "@/components/events/EventsPageSection";
import { eventService } from "@/services/events/event.service";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { getBackgroundImage } from "@/lib/preferences";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function EventsPage() {
// Paralléliser les appels indépendants
const session = await auth();
const [events, backgroundImage, allRegistrations] = await Promise.all([
eventService.getAllEvents({
orderBy: { date: "desc" },
}),
getBackgroundImage("events", "/got-2.jpg"),
session?.user?.id
? eventRegistrationService.getUserRegistrations(session.user.id)
: Promise.resolve([]),
]);
// Sérialiser les dates pour le client
const serializedEvents = events.map((event) => ({
...event,
date: event.date.toISOString(),
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
}));
// Construire le map des inscriptions
const initialRegistrations: Record<string, boolean> = {};
allRegistrations.forEach((reg) => {
initialRegistrations[reg.eventId] = true;
});
export default function EventsPage() {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<EventsPageSection />
<NavigationWrapper />
<EventsPageSection
events={serializedEvents}
backgroundImage={backgroundImage}
initialRegistrations={initialRegistrations}
/>
</main>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState, useEffect, useTransition, type FormEvent } from "react";
import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation";
import Navigation from "@/components/navigation/Navigation";
import { createFeedback } from "@/actions/events/feedback";
import {
StarRating,
Textarea,
Button,
Alert,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
interface Event {
id: string;
name: string;
date: string;
description: string;
}
interface Feedback {
id: string;
rating: number;
comment: string | null;
}
interface FeedbackPageClientProps {
backgroundImage: string;
}
export default function FeedbackPageClient({
backgroundImage,
}: FeedbackPageClientProps) {
const { status } = useSession();
const router = useRouter();
const params = useParams();
const eventId = params?.eventId as string;
const [event, setEvent] = useState<Event | null>(null);
const [existingFeedback, setExistingFeedback] = useState<Feedback | null>(
null
);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [, startTransition] = useTransition();
const [rating, setRating] = useState(0);
const [comment, setComment] = useState("");
const fetchEventAndFeedback = async () => {
try {
// Récupérer l'événement
const eventResponse = await fetch(`/api/events/${eventId}`);
if (!eventResponse.ok) {
setError("Événement introuvable");
setLoading(false);
return;
}
const eventData = await eventResponse.json();
setEvent(eventData);
// Récupérer le feedback existant si disponible
const feedbackResponse = await fetch(`/api/feedback/${eventId}`);
if (feedbackResponse.ok) {
const feedbackData = await feedbackResponse.json();
if (feedbackData.feedback) {
setExistingFeedback(feedbackData.feedback);
setRating(feedbackData.feedback.rating);
setComment(feedbackData.feedback.comment || "");
}
}
} catch {
setError("Erreur lors du chargement des données");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (status === "unauthenticated") {
router.push(`/login?redirect=/feedback/${eventId}`);
return;
}
if (status === "authenticated" && eventId) {
fetchEventAndFeedback();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, eventId, router]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setSuccess(false);
if (rating === 0) {
setError("Veuillez sélectionner une note");
return;
}
setSubmitting(true);
startTransition(async () => {
try {
const result = await createFeedback(eventId, {
rating,
comment: comment.trim() || null,
});
if (!result.success) {
setError(result.error || "Erreur lors de l'enregistrement");
setSubmitting(false);
return;
}
setSuccess(true);
if (result.data) {
setExistingFeedback({
id: result.data.id,
rating: result.data.rating,
comment: result.data.comment,
});
}
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
// Rediriger après 2 secondes
setTimeout(() => {
router.push("/events");
}, 2000);
} catch {
setError("Erreur lors de l'enregistrement");
} finally {
setSubmitting(false);
}
});
};
if (status === "loading" || loading) {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24">
<div className="text-white">Chargement...</div>
</section>
</main>
);
}
if (!event) {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24">
<div className="text-red-400">Événement introuvable</div>
</section>
</main>
);
}
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<BackgroundSection backgroundImage={backgroundImage} className="pt-24">
{/* Feedback Form */}
<div className="w-full max-w-2xl mx-auto px-8">
<Card variant="dark" className="p-8">
<SectionTitle
variant="gradient"
size="lg"
className="mb-2 text-center"
>
FEEDBACK
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-2">
{existingFeedback
? "Modifier votre feedback pour"
: "Donnez votre avis sur"}
</p>
<p className="text-pixel-gold text-lg font-semibold text-center mb-8">
{event.name}
</p>
{success && (
<Alert variant="success" className="mb-6">
Feedback enregistré avec succès ! Redirection...
</Alert>
)}
{error && (
<Alert variant="error" className="mb-6">
{error}
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Rating */}
<div>
<label className="block text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wider">
Note
</label>
<StarRating
value={rating}
onChange={setRating}
size="lg"
showValue
/>
</div>
{/* Comment */}
<Textarea
id="comment"
label="Commentaire (optionnel)"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={6}
maxLength={1000}
showCharCount
placeholder="Partagez votre expérience, vos suggestions..."
/>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
size="lg"
disabled={submitting || rating === 0}
className="w-full"
>
{submitting
? "Enregistrement..."
: existingFeedback
? "Modifier le feedback"
: "Envoyer le feedback"}
</Button>
</form>
</Card>
</div>
</BackgroundSection>
</main>
);
}

View File

@@ -0,0 +1,17 @@
import FeedbackPageClient from "./FeedbackPageClient";
import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic";
interface FeedbackPageProps {
params: Promise<{
eventId: string;
}>;
}
export default async function FeedbackPage({ params }: FeedbackPageProps) {
await params; // Ensure params are resolved (Next.js 15 requirement)
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
return <FeedbackPageClient backgroundImage={backgroundImage} />;
}

5
app/feedback/layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function FeedbackLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -2,10 +2,111 @@
@tailwind components;
@tailwind utilities;
:root {
/* Font variables - will be overridden by Next.js Font */
--font-orbitron: "Orbitron", sans-serif;
--font-rajdhani: "Rajdhani", sans-serif;
/* Dark cyan theme (default) */
--background: #001d2e;
--foreground: #ffffff;
--card: rgba(0, 29, 46, 0.6);
--card-hover: rgba(0, 29, 46, 0.8);
--card-column: rgba(0, 29, 46, 0.8);
--border: rgba(29, 254, 228, 0.3);
--input: rgba(0, 29, 46, 0.6);
--primary: #1dfee4;
--primary-foreground: #001d2e;
--muted: #9ca3af;
--muted-foreground: #9ca3af;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--accent: #1dfee4;
--destructive: #ef4444;
--success: #10b981;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--pixel-gold: #1dfee4;
--accent-color: #1dfee4;
}
.dark {
/* Dark gold theme (original) */
--background: #000000;
--foreground: #ffffff;
--card: rgba(0, 0, 0, 0.6);
--card-hover: rgba(0, 0, 0, 0.8);
--card-column: rgba(0, 0, 0, 0.8);
--border: rgba(218, 165, 32, 0.3);
--input: rgba(0, 0, 0, 0.6);
--primary: #06b6d4;
--primary-foreground: #ffffff;
--muted: #9ca3af;
--muted-foreground: #9ca3af;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--accent: #ff8c00;
--destructive: #ef4444;
--success: #10b981;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--pixel-gold: #daa520;
--accent-color: #daa520;
}
.dark-cyan {
/* Dark cyan theme (new) */
--background: #001d2e;
--foreground: #ffffff;
--card: rgba(0, 29, 46, 0.6);
--card-hover: rgba(0, 29, 46, 0.8);
--card-column: rgba(0, 29, 46, 0.8);
--border: rgba(29, 254, 228, 0.3);
--input: rgba(0, 29, 46, 0.6);
--primary: #1dfee4;
--primary-foreground: #001d2e;
--muted: #9ca3af;
--muted-foreground: #9ca3af;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--accent: #1dfee4;
--destructive: #ef4444;
--success: #10b981;
--purple: #8b5cf6;
--yellow: #eab308;
--green: #10b981;
--blue: #3b82f6;
--pixel-gold: #1dfee4;
--accent-color: #1dfee4;
}
@layer base {
body {
@apply bg-black text-white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background-color: var(--background);
color: var(--foreground);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
}
@@ -18,9 +119,75 @@
}
.text-pixel {
font-family: 'Courier New', monospace;
font-family: "Courier New", monospace;
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.8);
letter-spacing: 1px;
}
}
.title-animated {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* Button hover states using CSS variables */
.btn-primary {
border-color: color-mix(in srgb, var(--accent-color) 50%, transparent);
background-color: color-mix(in srgb, var(--background) 60%, transparent);
color: var(--foreground);
}
.btn-primary:hover:not(:disabled) {
border-color: var(--accent-color);
background-color: color-mix(in srgb, var(--accent-color) 10%, transparent);
}
.btn-secondary {
border-color: rgba(107, 114, 128, 0.5);
background-color: rgba(31, 41, 55, 0.2);
color: var(--gray-400);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--gray-500);
background-color: rgba(31, 41, 55, 0.3);
}
.btn-success {
border-color: rgba(16, 185, 129, 0.5);
background-color: rgba(16, 185, 129, 0.2);
color: var(--success);
}
.btn-success:hover:not(:disabled) {
background-color: rgba(16, 185, 129, 0.3);
}
.btn-danger {
border-color: rgba(239, 68, 68, 0.5);
background-color: rgba(239, 68, 68, 0.2);
color: var(--destructive);
}
.btn-danger:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.3);
}
.btn-ghost {
border-color: transparent;
background-color: transparent;
color: var(--foreground);
}
.btn-ghost:hover:not(:disabled) {
color: var(--accent-color);
}
}

207
app/houses/page.tsx Normal file
View File

@@ -0,0 +1,207 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import HousesSection from "@/components/houses/HousesSection";
import { houseService } from "@/services/houses/house.service";
import { prisma } from "@/services/database";
import type {
House,
HouseMembership,
HouseInvitation,
} from "@/prisma/generated/prisma/client";
export const dynamic = "force-dynamic";
// Types pour les données sérialisées
type HouseWithRelations = House & {
creator?: {
id: string;
username: string;
avatar: string | null;
} | null;
creatorId?: string;
memberships?: Array<
HouseMembership & {
user: {
id: string;
username: string;
avatar: string | null;
score: number | null;
level: number | null;
};
}
>;
};
type InvitationWithRelations = HouseInvitation & {
house: {
id: string;
name: string;
};
inviter: {
id: string;
username: string;
avatar: string | null;
};
};
export default async function HousesPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
const [housesData, myHouseData, invitationsData, users, backgroundImage] =
await Promise.all([
// Récupérer les maisons
houseService.getAllHouses({
include: {
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
orderBy: [
{ role: "asc" }, // OWNER, ADMIN, MEMBER
{ user: { score: "desc" } }, // Puis par score décroissant
],
},
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
},
}),
// Récupérer la maison de l'utilisateur
houseService.getUserHouse(session.user.id, {
memberships: {
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
score: true,
level: true,
},
},
},
},
creator: {
select: {
id: true,
username: true,
avatar: true,
},
},
}),
// Récupérer les invitations de l'utilisateur
houseService.getUserInvitations(session.user.id, "PENDING"),
// Récupérer tous les utilisateurs sans maison pour les invitations
prisma.user.findMany({
where: {
houseMemberships: {
none: {},
},
},
select: {
id: true,
username: true,
avatar: true,
},
orderBy: {
username: "asc",
},
}),
getBackgroundImage("houses", "/got-2.jpg"),
]);
// Sérialiser les données pour le client
const houses = (housesData as HouseWithRelations[]).map(
(house: HouseWithRelations) => ({
id: house.id,
name: house.name,
description: house.description,
creator: house.creator || {
id: house.creatorId || "",
username: "Unknown",
avatar: null,
},
memberships: (house.memberships || []).map((m) => ({
id: m.id,
role: m.role,
user: {
id: m.user.id,
username: m.user.username,
avatar: m.user.avatar,
score: m.user.score ?? 0,
level: m.user.level ?? 1,
},
})),
})
);
const myHouse = myHouseData
? {
id: myHouseData.id,
name: myHouseData.name,
description: myHouseData.description,
creator: (myHouseData as HouseWithRelations).creator || {
id: (myHouseData as HouseWithRelations).creatorId || "",
username: "Unknown",
avatar: null,
},
memberships: (
(myHouseData as HouseWithRelations).memberships || []
).map((m) => ({
id: m.id,
role: m.role,
user: {
id: m.user.id,
username: m.user.username,
avatar: m.user.avatar,
score: m.user.score ?? 0,
level: m.user.level ?? 1,
},
})),
}
: null;
const invitations = (invitationsData as InvitationWithRelations[]).map(
(inv: InvitationWithRelations) => ({
id: inv.id,
house: {
id: inv.house.id,
name: inv.house.name,
},
inviter: inv.inviter,
status: inv.status,
createdAt: inv.createdAt.toISOString(),
})
);
return (
<main className="min-h-screen bg-black relative">
<NavigationWrapper />
<HousesSection
initialHouses={houses}
initialMyHouse={myHouse}
initialUsers={users}
initialInvitations={invitations}
backgroundImage={backgroundImage}
/>
</main>
);
}

View File

@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Orbitron, Rajdhani } from "next/font/google";
import "./globals.css";
import SessionProvider from "@/components/layout/SessionProvider";
import { ThemeProvider } from "@/contexts/ThemeContext";
import Footer from "@/components/layout/Footer";
const orbitron = Orbitron({
subsets: ["latin"],
@@ -22,12 +26,21 @@ export const metadata: Metadata = {
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
<html lang="fr" className={`${orbitron.variable} ${rajdhani.variable}`}>
<body className="antialiased">{children}</body>
<html
lang="fr"
className={`${orbitron.variable} ${rajdhani.variable} dark-cyan`}
>
<body className="antialiased">
<ThemeProvider>
<SessionProvider>
{children}
<Footer />
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,12 +1,26 @@
import Navigation from "@/components/Navigation";
import LeaderboardSection from "@/components/LeaderboardSection";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import LeaderboardSection from "@/components/leaderboard/LeaderboardSection";
import { userStatsService } from "@/services/users/user-stats.service";
import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic";
export default async function LeaderboardPage() {
// Paralléliser les appels DB
const [leaderboard, houseLeaderboard, backgroundImage] = await Promise.all([
userStatsService.getLeaderboard(10),
userStatsService.getHouseLeaderboard(10),
getBackgroundImage("leaderboard", "/leaderboard-bg.jpg"),
]);
export default function LeaderboardPage() {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<LeaderboardSection />
<NavigationWrapper />
<LeaderboardSection
leaderboard={leaderboard}
houseLeaderboard={houseLeaderboard}
backgroundImage={backgroundImage}
/>
</main>
);
}

122
app/login/page.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client";
import { useState, type FormEvent } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Navigation from "@/components/navigation/Navigation";
import {
Input,
Button,
Alert,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
callbackUrl: "/",
});
if (result?.error) {
setError("Email ou mot de passe incorrect");
setLoading(false);
} else if (result?.ok) {
router.push("/");
router.refresh();
} else {
setError("Une erreur est survenue lors de la connexion");
setLoading(false);
}
} catch (err) {
console.error("Login error:", err);
setError("Une erreur est survenue");
setLoading(false);
}
};
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Login Form */}
<div className="w-full max-w-md mx-auto px-8">
<Card variant="dark" className="p-8">
<SectionTitle
variant="gradient"
size="md"
className="mb-2 text-center"
>
CONNEXION
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-8">
Connectez-vous à votre compte
</p>
<form onSubmit={handleSubmit} className="space-y-6">
{error && <Alert variant="error">{error}</Alert>}
<Input
id="email"
type="email"
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="votre@email.com"
/>
<Input
id="password"
type="password"
label="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
<Button
type="submit"
variant="primary"
size="lg"
disabled={loading}
className="w-full"
>
{loading ? "Connexion..." : "Se connecter"}
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-gray-400 text-sm">
Pas encore de compte ?{" "}
<Link
href="/register"
className="text-pixel-gold hover:text-orange-400 transition"
>
S&apos;inscrire
</Link>
</p>
</div>
</Card>
</div>
</BackgroundSection>
</main>
);
}

View File

@@ -1,13 +1,29 @@
import Navigation from "@/components/Navigation";
import HeroSection from "@/components/HeroSection";
import EventsSection from "@/components/EventsSection";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import HeroSection from "@/components/layout/HeroSection";
import EventsSection from "@/components/events/EventsSection";
import { eventService } from "@/services/events/event.service";
import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic";
export default async function Home() {
// Paralléliser les appels DB
const [events, backgroundImage] = await Promise.all([
eventService.getUpcomingEvents(3),
getBackgroundImage("home", "/got-2.jpg"),
]);
// Convert Date objects to strings for serialization
const serializedEvents = events.map((event) => ({
...event,
date: event.date.toISOString(),
}));
export default function Home() {
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<HeroSection />
<EventsSection />
<main className="min-h-screen relative" style={{ backgroundColor: "var(--background)" }}>
<NavigationWrapper />
<HeroSection backgroundImage={backgroundImage} />
<EventsSection events={serializedEvents} />
</main>
);
}

54
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service";
import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import ProfileForm from "@/components/profile/ProfileForm";
export default async function ProfilePage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// Paralléliser les appels DB
const [user, backgroundImage] = await Promise.all([
userService.getUserById(session.user.id, {
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
createdAt: true,
}),
getBackgroundImage("profile", "/got-background.jpg"),
]);
if (!user) {
redirect("/login");
}
// Convert Date to string for the component
const userProfile = {
...user,
createdAt: user.createdAt.toISOString(),
};
return (
<main className="min-h-screen bg-black relative">
<NavigationWrapper />
<ProfileForm
initialProfile={userProfile}
backgroundImage={backgroundImage}
/>
</main>
);
}

479
app/register/page.tsx Normal file
View File

@@ -0,0 +1,479 @@
"use client";
import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Navigation from "@/components/navigation/Navigation";
import {
Avatar,
Input,
Textarea,
Button,
Alert,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
import { CHARACTER_CLASSES } from "@/lib/character-classes";
export default function RegisterPage() {
const router = useRouter();
const [formData, setFormData] = useState({
email: "",
username: "",
password: "",
confirmPassword: "",
bio: "",
characterClass: null as string | null,
avatar: null as string | null,
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [step, setStep] = useState(1);
const [userId, setUserId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleAvatarUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
setError("");
try {
const formDataUpload = new FormData();
formDataUpload.append("file", file);
const response = await fetch("/api/register/avatar", {
method: "POST",
body: formDataUpload,
});
if (response.ok) {
const data = await response.json();
setFormData({
...formData,
avatar: data.url,
});
} else {
const errorData = await response.json();
setError(errorData.error || "Erreur lors de l'upload de l'avatar");
}
} catch (err) {
console.error("Error uploading avatar:", err);
setError("Erreur lors de l'upload de l'avatar");
} finally {
setUploadingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleStep1Submit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (formData.password !== formData.confirmPassword) {
setError("Les mots de passe ne correspondent pas");
return;
}
if (formData.password.length < 6) {
setError("Le mot de passe doit contenir au moins 6 caractères");
return;
}
setLoading(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: formData.email,
username: formData.username,
password: formData.password,
bio: null,
characterClass: null,
avatar: null,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || "Une erreur est survenue");
return;
}
setUserId(data.userId);
setStep(2);
} catch {
setError("Une erreur est survenue");
} finally {
setLoading(false);
}
};
const handleStep2Submit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!userId) {
setError("Erreur: ID utilisateur manquant");
return;
}
setLoading(true);
try {
const response = await fetch("/api/register/complete", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId,
username: formData.username,
bio: formData.bio && formData.bio.trim() ? formData.bio.trim() : null,
characterClass: formData.characterClass || null,
avatar: formData.avatar || null,
}),
});
if (!response.ok) {
const errorData = await response.json();
setError(errorData.error || "Une erreur est survenue");
return;
}
router.push("/login?registered=true");
} catch (err) {
console.error("Registration completion error:", err);
setError(err instanceof Error ? err.message : "Une erreur est survenue");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Register Form */}
<div className="w-full max-w-4xl mx-auto px-8">
<Card variant="dark" className="p-8">
<SectionTitle
variant="gradient"
size="lg"
className="mb-2 text-center"
>
INSCRIPTION
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-4">
{step === 1
? "Créez votre compte pour commencer"
: "Personnalisez votre profil"}
</p>
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition ${
step >= 1
? "bg-pixel-gold text-black"
: "bg-gray-700 text-gray-400"
}`}
>
1
</div>
<div
className={`h-px w-12 transition ${
step >= 2 ? "bg-pixel-gold" : "bg-gray-700"
}`}
/>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition ${
step >= 2
? "bg-pixel-gold text-black"
: "bg-gray-700 text-gray-400"
}`}
>
2
</div>
</div>
{step === 1 ? (
<form onSubmit={handleStep1Submit} className="space-y-6">
{error && <Alert variant="error">{error}</Alert>}
<Input
id="email"
name="email"
type="email"
label="Email"
value={formData.email}
onChange={handleChange}
required
placeholder="votre@email.com"
/>
<Input
id="username"
name="username"
type="text"
label="Nom d'utilisateur"
value={formData.username}
onChange={handleChange}
required
placeholder="VotrePseudo"
/>
<Input
id="password"
name="password"
type="password"
label="Mot de passe"
value={formData.password}
onChange={handleChange}
required
placeholder="••••••••"
/>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={handleChange}
required
placeholder="••••••••"
/>
<Button
type="submit"
variant="primary"
size="lg"
disabled={loading}
className="w-full"
>
{loading ? "Création..." : "Suivant"}
</Button>
</form>
) : (
<form onSubmit={handleStep2Submit} className="space-y-6">
{error && <Alert variant="error">{error}</Alert>}
{/* Avatar Selection */}
<div>
<label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">
Avatar (optionnel)
</label>
<div className="flex flex-col items-center gap-4">
{/* Preview */}
<div className="relative">
<Avatar
src={formData.avatar}
username={formData.username || "User"}
size="xl"
borderClassName="border-4 border-pixel-gold/50"
fallbackText={formData.username ? undefined : "?"}
/>
{uploadingAvatar && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-xs">
Upload...
</div>
</div>
)}
</div>
{/* Default Avatars */}
<div className="flex flex-col items-center gap-2 w-full">
<label className="text-pixel-gold text-xs uppercase tracking-widest">
Avatars par défaut
</label>
<div className="flex flex-wrap gap-2 justify-center">
{[
"/avatar-1.jpg",
"/avatar-2.jpg",
"/avatar-3.jpg",
"/avatar-4.jpg",
"/avatar-5.jpg",
"/avatar-6.jpg",
].map((defaultAvatar) => (
<button
key={defaultAvatar}
type="button"
onClick={() =>
setFormData({
...formData,
avatar: defaultAvatar,
})
}
className={`w-16 h-16 rounded-full border-2 overflow-hidden transition ${
formData.avatar === defaultAvatar
? "border-pixel-gold scale-110"
: "border-pixel-gold/30 hover:border-pixel-gold/50"
}`}
>
<img
src={defaultAvatar}
alt="Avatar par défaut"
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
{/* Custom Upload */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
id="avatar-upload"
/>
<label htmlFor="avatar-upload">
<Button
variant="primary"
size="md"
as="span"
className="cursor-pointer"
>
{uploadingAvatar
? "Upload en cours..."
: "Upload un avatar custom"}
</Button>
</label>
</div>
</div>
</div>
<Input
id="username-step2"
name="username"
type="text"
label="Nom d'utilisateur"
value={formData.username}
onChange={handleChange}
required
placeholder="VotrePseudo"
minLength={3}
maxLength={20}
/>
<p className="text-gray-500 text-xs mt-1">3-20 caractères</p>
<Textarea
id="bio"
name="bio"
label="Bio (optionnel)"
value={formData.bio}
onChange={handleChange}
rows={4}
maxLength={500}
showCharCount
placeholder="Parlez-nous de vous..."
/>
<div>
<label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">
Classe de Personnage (optionnel)
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CHARACTER_CLASSES.map((cls) => (
<button
key={cls.value}
type="button"
onClick={() =>
setFormData({
...formData,
characterClass:
formData.characterClass === cls.value
? null
: cls.value,
})
}
className={`p-4 border-2 rounded-lg text-left transition-all ${
formData.characterClass === cls.value
? "border-pixel-gold bg-pixel-gold/20 shadow-lg shadow-pixel-gold/30"
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50 hover:bg-black/60"
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{cls.icon}</span>
<span
className={`font-bold text-sm uppercase tracking-wider ${
formData.characterClass === cls.value
? "text-pixel-gold"
: "text-white"
}`}
>
{cls.name}
</span>
</div>
<p className="text-xs text-gray-400 leading-tight">
{cls.desc}
</p>
</button>
))}
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="secondary"
size="lg"
onClick={() => setStep(1)}
className="flex-1"
>
Retour
</Button>
<Button
type="submit"
variant="primary"
size="lg"
disabled={loading}
className="flex-1"
>
{loading ? "Finalisation..." : "Terminer"}
</Button>
</div>
</form>
)}
<div className="mt-6 text-center">
<p className="text-gray-400 text-sm">
Déjà un compte ?{" "}
<Link
href="/login"
className="text-pixel-gold hover:text-orange-400 transition"
>
Se connecter
</Link>
</p>
</div>
</Card>
</div>
</BackgroundSection>
</main>
);
}

542
app/style-guide/page.tsx Normal file
View File

@@ -0,0 +1,542 @@
"use client";
import { useState } from "react";
import Navigation from "@/components/navigation/Navigation";
import {
Button,
Input,
Textarea,
Select,
Card,
Badge,
Alert,
Modal,
ProgressBar,
StarRating,
Avatar,
SectionTitle,
BackgroundSection,
CloseButton,
} from "@/components/ui";
export default function StyleGuidePage() {
const [modalOpen, setModalOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [textareaValue, setTextareaValue] = useState("");
const [selectValue, setSelectValue] = useState("");
const [rating, setRating] = useState(0);
return (
<main className="min-h-screen bg-black relative">
<Navigation />
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24 pb-16">
<div className="w-full max-w-6xl mx-auto px-8">
<SectionTitle variant="gradient" size="xl" className="mb-16">
STYLE GUIDE
</SectionTitle>
<p className="text-gray-400 text-center mb-12 max-w-3xl mx-auto">
Guide de style complet avec tous les composants UI disponibles et
leurs variantes
</p>
{/* Buttons */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Buttons</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Variantes</h3>
<div className="flex flex-wrap gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="success">Success</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex flex-wrap items-center gap-4">
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" size="md">
Medium
</Button>
<Button variant="primary" size="lg">
Large
</Button>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">États</h3>
<div className="flex flex-wrap gap-4">
<Button variant="primary">Normal</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
</div>
</div>
</Card>
{/* Inputs */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Inputs</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Types</h3>
<div className="space-y-4 max-w-md">
<Input
label="Text Input"
type="text"
placeholder="Entrez du texte"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<Input
label="Email Input"
type="email"
placeholder="email@example.com"
/>
<Input
label="Password Input"
type="password"
placeholder="••••••••"
/>
<Input label="Number Input" type="number" placeholder="123" />
<Input label="Date Input" type="date" />
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Avec erreur</h3>
<div className="max-w-md">
<Input
label="Input avec erreur"
type="text"
error="Ce champ est requis"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
</div>
</div>
</div>
</Card>
{/* Textarea */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
Textarea
</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Basique</h3>
<div className="max-w-md">
<Textarea
label="Commentaire"
placeholder="Écrivez votre commentaire..."
value={textareaValue}
onChange={(e) => setTextareaValue(e.target.value)}
rows={4}
/>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">
Avec compteur de caractères
</h3>
<div className="max-w-md">
<Textarea
label="Bio"
placeholder="Parlez-nous de vous..."
value={textareaValue}
onChange={(e) => setTextareaValue(e.target.value)}
rows={4}
maxLength={500}
showCharCount
/>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Avec erreur</h3>
<div className="max-w-md">
<Textarea
label="Textarea avec erreur"
placeholder="Écrivez quelque chose..."
error="Ce champ est requis"
value={textareaValue}
onChange={(e) => setTextareaValue(e.target.value)}
rows={4}
/>
</div>
</div>
</div>
</Card>
{/* Select */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Select</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Basique</h3>
<div className="max-w-md">
<Select
label="Sélectionner une option"
value={selectValue}
onChange={(e) => setSelectValue(e.target.value)}
>
<option value="">Choisir...</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</Select>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Sans label</h3>
<div className="max-w-md">
<Select
value={selectValue}
onChange={(e) => setSelectValue(e.target.value)}
>
<option value="">Choisir...</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</Select>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Avec erreur</h3>
<div className="max-w-md">
<Select
label="Select avec erreur"
value={selectValue}
onChange={(e) => setSelectValue(e.target.value)}
error="Veuillez sélectionner une option"
>
<option value="">Choisir...</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</Select>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Disabled</h3>
<div className="max-w-md">
<Select
label="Select désactivé"
value={selectValue}
onChange={(e) => setSelectValue(e.target.value)}
disabled
>
<option value="">Choisir...</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</Select>
</div>
</div>
</div>
</Card>
{/* Badges */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Badges</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Variantes</h3>
<div className="flex flex-wrap gap-4">
<Badge variant="default">Default</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex flex-wrap items-center gap-4">
<Badge variant="default" size="xs">
Extra Small
</Badge>
<Badge variant="default" size="sm">
Small
</Badge>
<Badge variant="default" size="md">
Medium
</Badge>
</div>
</div>
</div>
</Card>
{/* Alerts */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Alerts</h2>
<div className="space-y-4 max-w-md">
<Alert variant="success">
Opération réussie ! Votre action a é effectuée avec succès.
</Alert>
<Alert variant="error">
Une erreur est survenue. Veuillez réessayer.
</Alert>
<Alert variant="warning">
Attention ! Cette action est irréversible.
</Alert>
<Alert variant="info">
Information : Voici quelques informations utiles.
</Alert>
</div>
</Card>
{/* Cards */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Cards</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card variant="default" className="p-4">
<h3 className="text-lg font-bold text-pixel-gold mb-2">
Card Default
</h3>
<p className="text-gray-300 text-sm">
Contenu de la carte avec variant default
</p>
</Card>
<Card variant="dark" className="p-4">
<h3 className="text-lg font-bold text-pixel-gold mb-2">
Card Dark
</h3>
<p className="text-gray-300 text-sm">
Contenu de la carte avec variant dark
</p>
</Card>
</div>
</Card>
{/* Progress Bars */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
Progress Bars
</h2>
<div className="space-y-6 max-w-md">
<div>
<h3 className="text-lg text-gray-300 mb-3">HP Bar (High)</h3>
<ProgressBar
value={75}
max={100}
variant="hp"
showLabel
label="HP"
/>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">HP Bar (Medium)</h3>
<ProgressBar
value={45}
max={100}
variant="hp"
showLabel
label="HP"
/>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">HP Bar (Low)</h3>
<ProgressBar
value={20}
max={100}
variant="hp"
showLabel
label="HP"
/>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">XP Bar</h3>
<ProgressBar
value={60}
max={100}
variant="xp"
showLabel
label="XP"
/>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Default</h3>
<ProgressBar
value={50}
max={100}
variant="default"
showLabel
label="Progress"
/>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Sans label</h3>
<ProgressBar value={60} max={100} variant="default" />
</div>
</div>
</Card>
{/* Star Rating */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
Star Rating
</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Interactif</h3>
<StarRating value={rating} onChange={setRating} showValue />
<p className="text-gray-400 text-sm mt-2">
Note sélectionnée : {rating}/5
</p>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="space-y-4">
<div>
<p className="text-gray-400 text-sm mb-2">Small</p>
<StarRating value={4} size="sm" />
</div>
<div>
<p className="text-gray-400 text-sm mb-2">Medium</p>
<StarRating value={4} size="md" />
</div>
<div>
<p className="text-gray-400 text-sm mb-2">Large</p>
<StarRating value={4} size="lg" />
</div>
</div>
</div>
</div>
</Card>
{/* Avatar */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Avatar</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex items-center gap-6">
<Avatar src="/avatar-1.jpg" username="User" size="sm" />
<Avatar src="/avatar-2.jpg" username="User" size="md" />
<Avatar src="/avatar-3.jpg" username="User" size="lg" />
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">
Sans image (fallback)
</h3>
<Avatar src={null} username="John Doe" size="lg" />
</div>
</div>
</Card>
{/* Section Title */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
Section Title
</h2>
<div className="space-y-8">
<div>
<h3 className="text-lg text-gray-300 mb-3">Variantes</h3>
<div className="space-y-4">
<SectionTitle variant="default" size="md">
Default Title
</SectionTitle>
<SectionTitle variant="gradient" size="md">
Gradient Title
</SectionTitle>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="space-y-4">
<SectionTitle variant="gradient" size="sm">
Small Title
</SectionTitle>
<SectionTitle variant="gradient" size="md">
Medium Title
</SectionTitle>
<SectionTitle variant="gradient" size="lg">
Large Title
</SectionTitle>
<SectionTitle variant="gradient" size="xl">
Extra Large Title
</SectionTitle>
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Avec sous-titre</h3>
<SectionTitle
variant="gradient"
size="lg"
subtitle="Un sous-titre descriptif"
>
Title with Subtitle
</SectionTitle>
</div>
</div>
</Card>
{/* Modal */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Modal</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex flex-wrap gap-4">
<Button onClick={() => setModalOpen(true)}>
Ouvrir Modal
</Button>
</div>
</div>
</div>
</Card>
{/* Close Button */}
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
Close Button
</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex items-center gap-6">
<CloseButton onClick={() => {}} size="sm" />
<CloseButton onClick={() => {}} size="md" />
<CloseButton onClick={() => {}} size="lg" />
</div>
</div>
<div>
<h3 className="text-lg text-gray-300 mb-3">Disabled</h3>
<CloseButton onClick={() => {}} disabled />
</div>
</div>
</Card>
{/* Modal Demo */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
size="md"
>
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-pixel-gold">
Exemple de Modal
</h2>
<CloseButton onClick={() => setModalOpen(false)} />
</div>
<p className="text-gray-300 mb-6">
Ceci est un exemple de modal avec différentes tailles
disponibles.
</p>
<div className="flex gap-4">
<Button onClick={() => setModalOpen(false)}>Fermer</Button>
</div>
</div>
</Modal>
</div>
</BackgroundSection>
</main>
);
}

75
components/Avatar.tsx Normal file
View File

@@ -0,0 +1,75 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { normalizeAvatarUrl } from "@/lib/avatars";
interface AvatarProps {
src: string | null | undefined;
username: string;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
className?: string;
borderClassName?: string;
fallbackText?: string;
}
const sizeClasses = {
xs: "w-6 h-6 text-[8px]",
sm: "w-8 h-8 text-[10px]",
md: "w-10 h-10 text-xs",
lg: "w-16 h-16 sm:w-20 sm:h-20 text-xl sm:text-2xl",
xl: "w-24 h-24 text-4xl",
"2xl": "w-32 h-32 text-4xl",
};
export default function Avatar({
src,
username,
size = "md",
className = "",
borderClassName = "",
fallbackText,
}: AvatarProps) {
const [avatarError, setAvatarError] = useState(false);
const prevSrcRef = useRef<string | null | undefined>(undefined);
// Reset error state when src changes
useEffect(() => {
if (src !== prevSrcRef.current) {
prevSrcRef.current = src;
// Reset error when src changes to allow retry
// eslint-disable-next-line react-hooks/set-state-in-effect
setAvatarError(false);
}
}, [src]);
const sizeClass = sizeClasses[size];
const normalizedSrc = normalizeAvatarUrl(src);
const displaySrc = normalizedSrc && !avatarError ? normalizedSrc : null;
const initial = fallbackText || username.charAt(0).toUpperCase();
return (
<div
className={`${sizeClass} rounded-full border overflow-hidden flex items-center justify-center relative ${className} ${borderClassName}`}
style={{
backgroundColor: "var(--card)",
borderColor: "var(--border)",
}}
>
{displaySrc ? (
<img
key={displaySrc}
src={displaySrc}
alt={username}
className="w-full h-full object-cover absolute inset-0"
onError={() => setAvatarError(true)}
/>
) : null}
<span
className={`font-bold ${displaySrc ? "hidden" : ""}`}
style={{ color: "var(--accent-color)" }}
>
{initial}
</span>
</div>
);
}

View File

@@ -1,230 +0,0 @@
"use client";
interface Event {
id: number;
date: string;
name: string;
description: string;
type: "summit" | "launch" | "festival" | "competition";
status: "upcoming" | "live" | "past";
}
const events: Event[] = [
{
id: 1,
date: "18 NOVEMBRE 2023",
name: "Sommet de l'Innovation Tech",
description:
"Rejoignez les leaders de l'industrie et les innovateurs pour une journée de discussions sur les technologies de pointe, les percées de l'IA et des opportunités de networking.",
type: "summit",
status: "past",
},
{
id: 2,
date: "3 DÉCEMBRE 2023",
name: "Lancement de la Révolution IA",
description:
"Assistez au lancement de systèmes d'IA révolutionnaires qui vont remodeler le paysage du gaming. Aperçus exclusifs et opportunités d'accès anticipé.",
type: "launch",
status: "past",
},
{
id: 3,
date: "22 DÉCEMBRE 2023",
name: "Festival du Code d'Hiver",
description:
"Une célébration de l'excellence en programmation avec des hackathons, des défis de codage et des prix. Montrez vos compétences et rivalisez avec les meilleurs développeurs.",
type: "festival",
status: "past",
},
{
id: 4,
date: "15 JANVIER 2024",
name: "Expo Informatique Quantique",
description:
"Explorez l'avenir de l'informatique quantique dans le gaming. Démonstrations interactives, conférences d'experts et ateliers pratiques pour tous les niveaux.",
type: "summit",
status: "upcoming",
},
{
id: 5,
date: "8 FÉVRIER 2024",
name: "Championnat Cyber Arena",
description:
"L'événement de gaming compétitif ultime. Compétissez pour la gloire, des récompenses exclusives et le titre de Champion Cyber Arena. Inscriptions ouvertes.",
type: "competition",
status: "upcoming",
},
{
id: 6,
date: "12 MARS 2024",
name: "Gala Tech du Printemps",
description:
"Une soirée élégante célébrant les réalisations technologiques. Cérémonie de remise de prix, networking et annonces exclusives des plus grandes entreprises tech.",
type: "festival",
status: "upcoming",
},
];
const getEventTypeColor = (type: Event["type"]) => {
switch (type) {
case "summit":
return "from-blue-600 to-cyan-500";
case "launch":
return "from-purple-600 to-pink-500";
case "festival":
return "from-pixel-gold to-orange-500";
case "competition":
return "from-red-600 to-orange-500";
default:
return "from-gray-600 to-gray-500";
}
};
const getEventTypeLabel = (type: Event["type"]) => {
switch (type) {
case "summit":
return "Sommet";
case "launch":
return "Lancement";
case "festival":
return "Festival";
case "competition":
return "Compétition";
default:
return type;
}
};
const getStatusBadge = (status: Event["status"]) => {
switch (status) {
case "upcoming":
return (
<span className="px-3 py-1 bg-green-900/50 border border-green-500/50 text-green-400 text-xs uppercase tracking-widest rounded">
À venir
</span>
);
case "live":
return (
<span className="px-3 py-1 bg-red-900/50 border border-red-500/50 text-red-400 text-xs uppercase tracking-widest rounded animate-pulse">
En direct
</span>
);
case "past":
return (
<span className="px-3 py-1 bg-gray-800/50 border border-gray-600/50 text-gray-400 text-xs uppercase tracking-widest rounded">
Passé
</span>
);
}
};
export default function EventsPageSection() {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/got-2.jpg')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Content */}
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
{/* Title Section */}
<div className="text-center mb-16">
<h1 className="text-5xl md:text-7xl font-gaming font-black mb-4 tracking-tight">
<span
className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
EVENTS
</span>
</h1>
<div className="text-pixel-gold text-lg md:text-xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 mb-6 tracking-wide">
<span></span>
<span>Événements à venir et passés</span>
<span></span>
</div>
<p className="text-gray-400 text-sm max-w-2xl mx-auto">
Rejoignez-nous pour des événements tech passionnants, des
compétitions et des célébrations tout au long de l'année
</p>
</div>
{/* Events Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<div
key={event.id}
className="bg-black/60 border border-pixel-gold/30 rounded-lg overflow-hidden backdrop-blur-sm hover:border-pixel-gold/50 transition group"
>
{/* Event Header */}
<div
className={`h-2 bg-gradient-to-r ${getEventTypeColor(
event.type
)}`}
></div>
{/* Event Content */}
<div className="p-6">
{/* Status Badge */}
<div className="flex justify-between items-start mb-4">
{getStatusBadge(event.status)}
<span className="text-pixel-gold text-xs uppercase tracking-widest">
{getEventTypeLabel(event.type)}
</span>
</div>
{/* Date */}
<div className="text-white text-sm font-bold uppercase tracking-widest mb-3">
{event.date}
</div>
{/* Event Name */}
<h3 className="text-xl font-bold text-white mb-3 group-hover:text-pixel-gold transition">
{event.name}
</h3>
{/* Description */}
<p className="text-gray-400 text-sm leading-relaxed mb-4">
{event.description}
</p>
{/* Action Button */}
{event.status === "upcoming" && (
<button className="w-full px-4 py-2 border border-pixel-gold/50 bg-black/40 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition">
S'inscrire maintenant
</button>
)}
{event.status === "live" && (
<button className="w-full px-4 py-2 border border-red-500/50 bg-red-900/20 text-red-400 uppercase text-xs tracking-widest rounded hover:bg-red-900/30 transition animate-pulse">
Rejoindre en direct
</button>
)}
{event.status === "past" && (
<button className="w-full px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-500 uppercase text-xs tracking-widest rounded cursor-not-allowed">
Événement terminé
</button>
)}
</div>
</div>
))}
</div>
{/* Footer Info */}
<div className="mt-12 text-center">
<p className="text-gray-500 text-sm">
Restez informé de nos derniers événements et annonces
</p>
</div>
</div>
</section>
);
}

View File

@@ -1,48 +0,0 @@
"use client";
interface Event {
date: string;
name: string;
}
const events: Event[] = [
{
date: "18 NOVEMBRE 2023",
name: "Sommet de l'Innovation Tech",
},
{
date: "3 DÉCEMBRE 2023",
name: "Lancement de la Révolution IA",
},
{
date: "22 DÉCEMBRE 2023",
name: "Festival du Code d'Hiver",
},
];
export default function EventsSection() {
return (
<section className="w-full bg-gray-950 border-t border-pixel-gold/30 py-16">
<div className="max-w-7xl mx-auto px-8">
<div className="flex flex-col md:flex-row items-center justify-around gap-8">
{events.map((event, index) => (
<div key={index} className="flex flex-col items-center">
<div className="flex flex-col items-center mb-4">
<span className="text-pixel-gold text-xs uppercase tracking-widest mb-2">
Événement
</span>
<div className="w-16 h-px bg-pixel-gold"></div>
</div>
<div className="text-white text-lg font-bold mb-2 uppercase tracking-wide">
{event.date}
</div>
<div className="text-white text-base text-center">
{event.name}
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
export default function HeroSection() {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/got-2.jpg')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Hero Content */}
<div className="relative z-10 w-full max-w-5xl mx-auto px-8 py-16 text-center">
{/* Game Title */}
<h1 className="text-6xl md:text-8xl lg:text-9xl font-gaming font-black mb-4 tracking-tight">
<span
className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
GAME.OF.TECH
</span>
</h1>
{/* Subtitle */}
<div className="text-pixel-gold text-xl md:text-2xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 mb-8 tracking-wider">
<span></span>
<span>Peaksys</span>
<span></span>
</div>
{/* Description */}
<p className="text-white text-base md:text-lg max-w-3xl mx-auto mb-12 leading-relaxed px-4">
Dans un monde numérique de technologie de pointe, les systèmes d'IA
évoluent et où d'anciens codes attendent d'être découverts. Partez
pour un voyage épique pour forger des alliances, conquérir des défis
et raconter votre histoire d'innovation au sein d'une communauté de
gaming tech florissante.
</p>
{/* Call-to-Action Buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
<button className="px-8 py-3 border border-pixel-gold/50 bg-black/60 text-white uppercase text-sm tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition">
PLAY NOW
</button>
<button className="px-8 py-3 border border-pixel-gold/50 bg-black/60 text-white uppercase text-sm tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition flex items-center gap-2">
<span></span>
<span>Watch Trailer</span>
</button>
</div>
</div>
<style jsx>{`
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
`}</style>
</section>
);
}

View File

@@ -1,96 +0,0 @@
"use client";
interface LeaderboardEntry {
rank: number;
username: string;
score: number;
level: number;
}
// Format number with consistent locale to avoid hydration mismatch
const formatScore = (score: number): string => {
return score.toLocaleString("en-US");
};
const mockLeaderboard: LeaderboardEntry[] = [
{ rank: 1, username: "DragonSlayer99", score: 125000, level: 85 },
{ rank: 2, username: "MineMaster", score: 118500, level: 82 },
{ rank: 3, username: "CraftKing", score: 112000, level: 80 },
{ rank: 4, username: "PixelWarrior", score: 105500, level: 78 },
{ rank: 5, username: "FarminePro", score: 99000, level: 75 },
{ rank: 6, username: "GoldDigger", score: 92500, level: 73 },
{ rank: 7, username: "EpicGamer", score: 87000, level: 71 },
{ rank: 8, username: "Legendary", score: 81500, level: 69 },
{ rank: 9, username: "MysticMiner", score: 76000, level: 67 },
{ rank: 10, username: "TopPlayer", score: 70500, level: 65 },
];
export default function Leaderboard() {
return (
<section className="w-full bg-black py-16 px-6 border-t-2 border-pixel-dark-purple">
<div className="max-w-4xl mx-auto">
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12 text-pixel-gold text-pixel">
LEADERBOARD
</h2>
<div className="bg-black/80 border-2 border-pixel-gold/30 rounded-lg overflow-hidden backdrop-blur-sm">
{/* Header */}
<div className="bg-gray-900/80 border-b-2 border-pixel-gold/30 grid grid-cols-12 gap-4 p-4 font-bold text-sm text-gray-300">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-5">Player</div>
<div className="col-span-3 text-right">Score</div>
<div className="col-span-3 text-right">Level</div>
</div>
{/* Entries */}
<div className="divide-y divide-pixel-gold/10">
{mockLeaderboard.map((entry) => (
<div
key={entry.rank}
className={`grid grid-cols-12 gap-4 p-4 hover:bg-gray-900/50 transition ${
entry.rank <= 3 ? "bg-gray-950/50" : "bg-black/40"
}`}
>
<div className="col-span-1 text-center">
<span
className={`inline-block w-8 h-8 rounded-full flex items-center justify-center font-bold ${
entry.rank === 1
? "bg-pixel-gold text-black"
: entry.rank === 2
? "bg-gray-600 text-white"
: entry.rank === 3
? "bg-orange-800 text-white"
: "bg-gray-900 text-gray-400 border border-gray-800"
}`}
>
{entry.rank}
</span>
</div>
<div className="col-span-5 flex items-center">
<span className="font-bold text-pixel-gold">
{entry.username}
</span>
</div>
<div className="col-span-3 text-right flex items-center justify-end">
<span className="font-mono text-gray-300">
{formatScore(entry.score)}
</span>
</div>
<div className="col-span-3 text-right flex items-center justify-end">
<span className="font-bold text-gray-400">
Lv.{entry.level}
</span>
</div>
</div>
))}
</div>
</div>
{/* Additional info */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>Compete with players worldwide and climb the ranks!</p>
</div>
</div>
</section>
);
}

View File

@@ -1,156 +0,0 @@
"use client";
interface LeaderboardEntry {
rank: number;
username: string;
score: number;
level: number;
avatar?: string;
}
// Format number with consistent locale to avoid hydration mismatch
const formatScore = (score: number): string => {
return score.toLocaleString("en-US");
};
const mockLeaderboard: LeaderboardEntry[] = [
{ rank: 1, username: "TechMaster2024", score: 125000, level: 85 },
{ rank: 2, username: "CodeWarrior", score: 118500, level: 82 },
{ rank: 3, username: "AIGenius", score: 112000, level: 80 },
{ rank: 4, username: "DevLegend", score: 105500, level: 78 },
{ rank: 5, username: "InnovationPro", score: 99000, level: 75 },
{ rank: 6, username: "TechNinja", score: 92500, level: 73 },
{ rank: 7, username: "DigitalHero", score: 87000, level: 71 },
{ rank: 8, username: "CodeCrusher", score: 81500, level: 69 },
{ rank: 9, username: "TechWizard", score: 76000, level: 67 },
{ rank: 10, username: "InnovationKing", score: 70500, level: 65 },
{ rank: 11, username: "DevMaster", score: 68000, level: 64 },
{ rank: 12, username: "TechElite", score: 65500, level: 63 },
{ rank: 13, username: "CodeChampion", score: 63000, level: 62 },
{ rank: 14, username: "AIVisionary", score: 60500, level: 61 },
{ rank: 15, username: "TechPioneer", score: 58000, level: 60 },
];
export default function LeaderboardSection() {
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24 pb-16">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/leaderboard-bg.jpg')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Content */}
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
{/* Title Section */}
<div className="text-center mb-12">
<h1 className="text-5xl md:text-7xl font-gaming font-black mb-4 tracking-tight">
<span
className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"
style={{
textShadow: "0 0 30px rgba(218, 165, 32, 0.5)",
}}
>
LEADERBOARD
</span>
</h1>
<div className="text-pixel-gold text-lg md:text-xl font-gaming-subtitle font-semibold flex items-center justify-center gap-2 tracking-wide">
<span></span>
<span>Top Players</span>
<span></span>
</div>
</div>
{/* Leaderboard Table */}
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg overflow-hidden backdrop-blur-sm">
{/* Header */}
<div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-4 p-4 font-bold text-xs uppercase tracking-widest text-gray-300">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-6">Player</div>
<div className="col-span-3 text-right">Score</div>
<div className="col-span-2 text-right">Level</div>
</div>
{/* Entries */}
<div className="divide-y divide-pixel-gold/10">
{mockLeaderboard.map((entry) => (
<div
key={entry.rank}
className={`grid grid-cols-12 gap-4 p-4 hover:bg-gray-900/50 transition ${
entry.rank <= 3
? "bg-gradient-to-r from-pixel-gold/10 via-pixel-gold/5 to-transparent"
: "bg-black/40"
}`}
>
{/* Rank */}
<div className="col-span-1 flex items-center justify-center">
<span
className={`inline-flex items-center justify-center w-10 h-10 rounded-full font-bold text-sm ${
entry.rank === 1
? "bg-gradient-to-br from-pixel-gold to-orange-500 text-black shadow-lg shadow-pixel-gold/50"
: entry.rank === 2
? "bg-gradient-to-br from-gray-400 to-gray-500 text-black"
: entry.rank === 3
? "bg-gradient-to-br from-orange-700 to-orange-800 text-white"
: "bg-gray-900 text-gray-400 border border-gray-800"
}`}
>
{entry.rank}
</span>
</div>
{/* Player */}
<div className="col-span-6 flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-800 to-gray-900 border border-pixel-gold/30 flex items-center justify-center">
<span className="text-pixel-gold text-xs font-bold">
{entry.username.charAt(0).toUpperCase()}
</span>
</div>
<span
className={`font-bold ${
entry.rank <= 3 ? "text-pixel-gold" : "text-white"
}`}
>
{entry.username}
</span>
{entry.rank <= 3 && (
<span className="text-pixel-gold text-xs"></span>
)}
</div>
{/* Score */}
<div className="col-span-3 flex items-center justify-end">
<span className="font-mono text-gray-300">
{formatScore(entry.score)}
</span>
</div>
{/* Level */}
<div className="col-span-2 flex items-center justify-end">
<span className="font-bold text-gray-400">
Lv.{entry.level}
</span>
</div>
</div>
))}
</div>
</div>
{/* Footer Info */}
<div className="mt-8 text-center">
<p className="text-gray-500 text-sm">
Compete with players worldwide and climb the ranks!
</p>
<p className="text-gray-600 text-xs mt-2">
Rankings update every hour
</p>
</div>
</div>
</section>
);
}

View File

@@ -1,51 +0,0 @@
"use client";
import Link from "next/link";
import PlayerStats from "./PlayerStats";
export default function Navigation() {
return (
<nav className="w-full fixed top-0 left-0 z-50 px-8 py-3 bg-black/80 backdrop-blur-sm border-b border-gray-800/30">
<div className="max-w-7xl mx-auto flex items-center justify-between">
{/* Logo - Left */}
<div className="flex flex-col">
<div className="text-white text-xl font-gaming font-bold tracking-tight">
GAME.OF.TECH
</div>
<div className="text-pixel-gold text-xs font-gaming-subtitle font-semibold flex items-center gap-1 tracking-wide">
<span></span>
<span>Game of Tech</span>
<span></span>
</div>
</div>
{/* Navigation Links - Center */}
<div className="flex items-center gap-6">
<Link
href="/"
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
>
HOME
</Link>
<Link
href="/events"
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
>
EVENTS
</Link>
<Link
href="/leaderboard"
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
>
LEADERBOARD
</Link>
</div>
{/* Player Stats - Right */}
<div>
<PlayerStats />
</div>
</div>
</nav>
);
}

Some files were not shown because too many files have changed in this diff Show More