Compare commits

...

99 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
228 changed files with 31904 additions and 14887 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.

28
.env
View File

@@ -5,6 +5,28 @@
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. # 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 # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="file:./data/dev.db" # DATABASE_URL="file:./data/dev.db"
AUTH_SECRET="your-secret-key-change-this-in-production" # AUTH_SECRET="your-secret-key-change-this-in-production"
AUTH_URL="http://localhost:3000" # 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

@@ -1,17 +1,26 @@
# name: Deploy with Docker Compose name: Deploy with Docker Compose
# on: on:
# push: push:
# branches: branches:
# - main # adapte la branche que tu veux déployer - main # adapte la branche que tu veux déployer
# jobs: jobs:
# deploy: deploy:
# runs-on: mac-orbstack-runner # le nom que tu as donné au runner runs-on: mac-orbstack-runner # le nom que tu as donné au runner
# steps: steps:
# - name: Checkout - name: Checkout
# uses: actions/checkout@v4 uses: actions/checkout@v4
# - name: Deploy stack - name: Deploy stack
# run: | env:
# docker compose up -d 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

7
.gitignore vendored
View File

@@ -25,6 +25,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env
.env*.local .env*.local
# vercel # vercel
@@ -41,3 +42,9 @@ dev.db*
# prisma # prisma
/app/generated/prisma /app/generated/prisma
prisma/generated/
# database data
data/postgres/
data/*.db
data/*.db-journal

View File

@@ -6,7 +6,8 @@ WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Stage 2: Builder # Stage 2: Builder
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
@@ -18,7 +19,9 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV DATABASE_URL="file:/tmp/build.db" # 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 RUN pnpm prisma generate
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -38,27 +41,48 @@ RUN addgroup --system --gid 1001 nodejs && \
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=builder /app/public ./public # Copier les fichiers nécessaires
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml 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
ENV DATABASE_URL="file:/app/data/dev.db" # Installer seulement les dépendances de production puis générer Prisma Client
RUN pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 --prod && \ # 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 pnpm dlx prisma generate
# Ne pas définir ENV DATABASE_URL ici - elle sera définie par docker-compose.yml au runtime
# Create data directory for SQLite database # Create uploads directories
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data RUN mkdir -p /app/public/uploads /app/public/uploads/backgrounds && \
chown -R nextjs:nodejs /app/public/uploads
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \ RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
echo 'set -e' >> /app/entrypoint.sh && \ echo 'set -e' >> /app/entrypoint.sh && \
echo 'mkdir -p /app/data' >> /app/entrypoint.sh && \ echo 'mkdir -p /app/public/uploads' >> /app/entrypoint.sh && \
echo 'pnpm dlx prisma migrate deploy || true' >> /app/entrypoint.sh && \ echo 'mkdir -p /app/public/uploads/backgrounds' >> /app/entrypoint.sh && \
echo 'exec node server.js' >> /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 && \ chmod +x /app/entrypoint.sh && \
chown nextjs:nodejs /app/entrypoint.sh chown nextjs:nodejs /app/entrypoint.sh
@@ -67,6 +91,5 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV DATABASE_URL="file:/app/data/dev.db"
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -24,22 +24,61 @@ docker-compose logs -f
## Variables d'environnement ## Variables d'environnement
Créez un fichier `.env` à la racine du projet avec les variables suivantes : Créez un fichier `.env` à la racine du projet à partir du template `.env.example` :
```env ```bash
NEXTAUTH_SECRET=your-secret-key-here cp .env.example .env
NEXTAUTH_URL=http://localhost:3000
DATABASE_URL=file:./prisma/dev.db
``` ```
## Base de données Puis modifiez les valeurs dans `.env` selon votre configuration :
La base de données SQLite est persistée via un volume Docker. Les migrations Prisma sont appliquées automatiquement au démarrage du conteneur. ```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 : Pour appliquer manuellement les migrations :
```bash ```bash
docker-compose exec app node node_modules/.bin/prisma migrate deploy 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 ## Commandes utiles
@@ -52,8 +91,8 @@ docker-compose down
docker-compose build --no-cache docker-compose build --no-cache
# Accéder au shell du conteneur # Accéder au shell du conteneur
docker-compose exec app sh docker-compose exec got-app sh
# Voir les logs en temps réel # Voir les logs en temps réel
docker-compose logs -f app 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 ## Installation
@@ -27,9 +27,13 @@ pnpm start
- `/app` - Pages et layout Next.js - `/app` - Pages et layout Next.js
- `/components` - Composants React réutilisables - `/components` - Composants React réutilisables
- `Navigation.tsx` - Barre de navigation - `Navigation.tsx` - Barre de navigation avec logo cliquable
- `HeroSection.tsx` - Section principale avec logo et description - `HeroSection.tsx` - Section principale avec description
- `Leaderboard.tsx` - Tableau des classements - `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 ## Technologies
@@ -37,3 +41,5 @@ pnpm start
- React 18 - React 18
- TypeScript - TypeScript
- Tailwind CSS - 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>
);
}

View File

@@ -1,44 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role } from "@/prisma/generated/prisma/client";
import NavigationWrapper from "@/components/NavigationWrapper";
import AdminPanel from "@/components/AdminPanel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminPage() { export default async function AdminPage() {
const session = await auth(); redirect("/admin/preferences");
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== Role.ADMIN) {
redirect("/");
}
// Récupérer les préférences globales du site
let sitePreferences = await prisma.sitePreferences.findUnique({
where: { id: "global" },
});
// Si elles n'existent pas, créer une entrée par défaut
if (!sitePreferences) {
sitePreferences = await prisma.sitePreferences.create({
data: {
id: "global",
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
},
});
}
return (
<main className="min-h-screen bg-black relative">
<NavigationWrapper />
<AdminPanel initialPreferences={sitePreferences} />
</main>
);
} }

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

@@ -1,123 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role, EventType } from "@/prisma/generated/prisma/client";
export async function PUT(
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 } = await params;
const body = await request.json();
const { date, name, description, type, room, time, maxPlaces } = body;
// Le statut est ignoré s'il est fourni, il sera calculé automatiquement
// Vérifier que l'événement existe
const existingEvent = await prisma.event.findUnique({
where: { id },
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
const updateData: {
date?: Date;
name?: string;
description?: string;
type?: EventType;
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
} = {};
if (date !== undefined) {
const eventDate = new Date(date);
if (isNaN(eventDate.getTime())) {
return NextResponse.json(
{ error: "Format de date invalide" },
{ status: 400 }
);
}
updateData.date = eventDate;
}
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (type !== undefined) {
if (!Object.values(EventType).includes(type)) {
return NextResponse.json(
{ error: "Type d'événement invalide" },
{ status: 400 }
);
}
updateData.type = type as EventType;
}
// Le statut est toujours calculé automatiquement, on ignore s'il est fourni
if (room !== undefined) updateData.room = room || null;
if (time !== undefined) updateData.time = time || null;
if (maxPlaces !== undefined)
updateData.maxPlaces = maxPlaces ? parseInt(maxPlaces) : null;
const event = await prisma.event.update({
where: { id },
data: updateData,
});
return NextResponse.json(event);
} catch (error) {
console.error("Error updating event:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de l'événement" },
{ status: 500 }
);
}
}
export async function DELETE(
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 } = await params;
// Vérifier que l'événement existe
const existingEvent = await prisma.event.findUnique({
where: { id },
});
if (!existingEvent) {
return NextResponse.json(
{ error: "Événement non trouvé" },
{ status: 404 }
);
}
await prisma.event.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting event:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'événement" },
{ status: 500 }
);
}
}

View File

@@ -1,8 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
import { Role, EventType } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
import { calculateEventStatus } from "@/lib/eventStatus";
export async function GET() { export async function GET() {
try { try {
@@ -12,34 +11,22 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
const events = await prisma.event.findMany({ const events = await eventService.getEventsWithStatus();
orderBy: {
date: "desc",
},
include: {
_count: {
select: {
registrations: true,
},
},
},
});
// Transformer les données pour inclure le nombre d'inscriptions // Transformer les données pour la sérialisation
// Le statut est calculé automatiquement en fonction de la date
const eventsWithCount = events.map((event) => ({ const eventsWithCount = events.map((event) => ({
id: event.id, id: event.id,
date: event.date.toISOString(), date: event.date.toISOString(),
name: event.name, name: event.name,
description: event.description, description: event.description,
type: event.type, type: event.type,
status: calculateEventStatus(event.date), status: event.status,
room: event.room, room: event.room,
time: event.time, time: event.time,
maxPlaces: event.maxPlaces, maxPlaces: event.maxPlaces,
createdAt: event.createdAt.toISOString(), createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(), updatedAt: event.updatedAt.toISOString(),
registrationsCount: event._count.registrations, registrationsCount: event.registrationsCount,
})); }));
return NextResponse.json(eventsWithCount); return NextResponse.json(eventsWithCount);
@@ -51,60 +38,3 @@ export async function GET() {
); );
} }
} }
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 body = await request.json();
const { date, name, description, type, room, time, maxPlaces } = body;
if (!date || !name || !description || !type) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
// Convertir la date string en Date object
const eventDate = new Date(date);
if (isNaN(eventDate.getTime())) {
return NextResponse.json(
{ error: "Format de date invalide" },
{ status: 400 }
);
}
// Valider les enums
if (!Object.values(EventType).includes(type)) {
return NextResponse.json(
{ error: "Type d'événement invalide" },
{ status: 400 }
);
}
const event = await prisma.event.create({
data: {
date: eventDate,
name,
description,
type: type as EventType,
room: room || null,
time: time || null,
maxPlaces: maxPlaces ? parseInt(maxPlaces) : null,
},
});
return NextResponse.json(event);
} catch (error) {
console.error("Error creating event:", error);
return NextResponse.json(
{ error: "Erreur lors de la création de l'événement" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventFeedbackService } from "@/services/events/event-feedback.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -15,72 +15,14 @@ export async function GET() {
} }
// Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur // Récupérer tous les feedbacks avec les détails de l'événement et de l'utilisateur
const feedbacks = await prisma.eventFeedback.findMany({ const feedbacks = await eventFeedbackService.getAllFeedbacks();
include: {
event: {
select: {
id: true,
name: true,
date: true,
type: true,
},
},
user: {
select: {
id: true,
username: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Calculer les statistiques par événement // Calculer les statistiques par événement
const eventStats = await prisma.eventFeedback.groupBy({ const statistics = await eventFeedbackService.getFeedbackStatistics();
by: ["eventId"],
_avg: {
rating: true,
},
_count: {
id: true,
},
});
// Récupérer les détails des événements pour les stats
const eventIds = eventStats.map((stat) => stat.eventId);
const events = await prisma.event.findMany({
where: {
id: {
in: eventIds,
},
},
select: {
id: true,
name: true,
date: true,
type: true,
},
});
// Combiner les stats avec les détails des événements
const statsWithDetails = eventStats.map((stat) => {
const event = events.find((e) => e.id === stat.eventId);
return {
eventId: stat.eventId,
eventName: event?.name || "Événement supprimé",
eventDate: event?.date || null,
eventType: event?.type || null,
averageRating: stat._avg.rating || 0,
feedbackCount: stat._count.id,
};
});
return NextResponse.json({ return NextResponse.json({
feedbacks, feedbacks,
statistics: statsWithDetails, statistics,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching feedbacks:", error); console.error("Error fetching feedbacks:", error);

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

@@ -15,25 +15,18 @@ export async function GET() {
const images: string[] = []; const images: string[] = [];
// Lister les images dans public/ // Lister uniquement les images dans public/uploads/backgrounds/
const publicDir = join(process.cwd(), "public"); const publicDir = join(process.cwd(), "public");
if (existsSync(publicDir)) { const uploadsDir = join(publicDir, "uploads");
const files = await readdir(publicDir); const backgroundsDir = join(uploadsDir, "backgrounds");
if (existsSync(backgroundsDir)) {
const files = await readdir(backgroundsDir);
const imageFiles = files.filter( const imageFiles = files.filter(
(file) => (file) =>
file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".") file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".")
); );
images.push(...imageFiles.map((file) => `/${file}`)); images.push(...imageFiles.map((file) => `/api/backgrounds/${file}`));
}
// Lister les images dans public/uploads/
const uploadsDir = join(publicDir, "uploads");
if (existsSync(uploadsDir)) {
const uploadFiles = await readdir(uploadsDir);
const imageFiles = uploadFiles.filter((file) =>
file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
images.push(...imageFiles.map((file) => `/uploads/${file}`));
} }
return NextResponse.json({ images }); return NextResponse.json({ images });

View File

@@ -31,24 +31,25 @@ export async function POST(request: Request) {
); );
} }
// Créer le dossier uploads s'il n'existe pas // Créer le dossier uploads/backgrounds s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads"); const uploadsDir = join(process.cwd(), "public", "uploads");
if (!existsSync(uploadsDir)) { const backgroundsDir = join(uploadsDir, "backgrounds");
await mkdir(uploadsDir, { recursive: true }); if (!existsSync(backgroundsDir)) {
await mkdir(backgroundsDir, { recursive: true });
} }
// Générer un nom de fichier unique // Générer un nom de fichier unique
const timestamp = Date.now(); const timestamp = Date.now();
const filename = `${timestamp}-${file.name}`; const filename = `${timestamp}-${file.name}`;
const filepath = join(uploadsDir, filename); const filepath = join(backgroundsDir, filename);
// Convertir le fichier en buffer et l'écrire // Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer); await writeFile(filepath, buffer);
// Retourner l'URL de l'image // Retourner l'URL de l'image via l'API
const imageUrl = `/uploads/${filename}`; const imageUrl = `/api/backgrounds/${filename}`;
return NextResponse.json({ url: imageUrl }); return NextResponse.json({ url: imageUrl });
} catch (error) { } catch (error) {
console.error("Error uploading image:", error); console.error("Error uploading image:", error);

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -11,22 +11,9 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
// Récupérer les préférences globales du site // Récupérer les préférences globales du site (ou créer si elles n'existent pas)
let sitePreferences = await prisma.sitePreferences.findUnique({ const sitePreferences =
where: { id: "global" }, await sitePreferencesService.getOrCreateSitePreferences();
});
// Si elles n'existent pas, créer une entrée par défaut
if (!sitePreferences) {
sitePreferences = await prisma.sitePreferences.create({
data: {
id: "global",
homeBackground: null,
eventsBackground: null,
leaderboardBackground: null,
},
});
}
return NextResponse.json(sitePreferences); return NextResponse.json(sitePreferences);
} catch (error) { } catch (error) {
@@ -37,46 +24,3 @@ export async function GET() {
); );
} }
} }
export async function PUT(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 body = await request.json();
const { homeBackground, eventsBackground, leaderboardBackground } = body;
const preferences = await prisma.sitePreferences.upsert({
where: { id: "global" },
update: {
homeBackground:
homeBackground === "" ? null : (homeBackground ?? undefined),
eventsBackground:
eventsBackground === "" ? null : (eventsBackground ?? undefined),
leaderboardBackground:
leaderboardBackground === ""
? null
: (leaderboardBackground ?? undefined),
},
create: {
id: "global",
homeBackground: homeBackground === "" ? null : (homeBackground ?? null),
eventsBackground:
eventsBackground === "" ? null : (eventsBackground ?? null),
leaderboardBackground:
leaderboardBackground === "" ? null : (leaderboardBackground ?? null),
},
});
return NextResponse.json(preferences);
} catch (error) {
console.error("Error updating admin preferences:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour des préférences" },
{ status: 500 }
);
}
}

View File

@@ -1,198 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role } from "@/prisma/generated/prisma/client";
export async function PUT(
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 } = await params;
const body = await request.json();
const { hpDelta, xpDelta, score, level, role } = body;
// Récupérer l'utilisateur actuel
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Calculer les nouvelles valeurs
let newHp = user.hp;
let newXp = user.xp;
let newLevel = user.level;
let newMaxXp = user.maxXp;
// Appliquer les changements de HP
if (hpDelta !== undefined) {
newHp = Math.max(0, Math.min(user.maxHp, user.hp + hpDelta));
}
// Appliquer les changements de XP
if (xpDelta !== undefined) {
newXp = user.xp + xpDelta;
newLevel = user.level;
newMaxXp = user.maxXp;
// Gérer le niveau up si nécessaire (quand on ajoute de l'XP)
if (newXp >= newMaxXp && newXp > 0) {
while (newXp >= newMaxXp) {
newXp -= newMaxXp;
newLevel += 1;
// Augmenter le maxXp pour le prochain niveau (formule simple)
newMaxXp = Math.floor(newMaxXp * 1.2);
}
}
// Gérer le niveau down si nécessaire (quand on enlève de l'XP)
if (newXp < 0 && newLevel > 1) {
while (newXp < 0 && newLevel > 1) {
newLevel -= 1;
// Calculer le maxXp du niveau précédent
newMaxXp = Math.floor(newMaxXp / 1.2);
newXp += newMaxXp;
}
// S'assurer que l'XP ne peut pas être négative
newXp = Math.max(0, newXp);
}
// S'assurer que le niveau minimum est 1
if (newLevel < 1) {
newLevel = 1;
newXp = 0;
}
}
// Appliquer les changements directs (score, level, role)
const updateData: {
hp: number;
xp: number;
level: number;
maxXp: number;
score?: number;
role?: Role;
} = {
hp: newHp,
xp: newXp,
level: newLevel,
maxXp: newMaxXp,
};
if (score !== undefined) {
updateData.score = Math.max(0, score);
}
if (level !== undefined) {
// Si le niveau est modifié directement, utiliser cette valeur
const targetLevel = Math.max(1, level);
updateData.level = targetLevel;
// Recalculer le maxXp pour le nouveau niveau
// Formule: maxXp = 5000 * (1.2 ^ (level - 1))
let calculatedMaxXp = 5000;
for (let i = 1; i < targetLevel; i++) {
calculatedMaxXp = Math.floor(calculatedMaxXp * 1.2);
}
updateData.maxXp = calculatedMaxXp;
// Réinitialiser l'XP si le niveau change directement (sauf si on modifie aussi l'XP)
if (targetLevel !== user.level && xpDelta === undefined) {
updateData.xp = 0;
}
}
if (role !== undefined) {
if (role === "ADMIN" || role === "USER") {
updateData.role = role as Role;
}
}
// Mettre à jour l'utilisateur
const updatedUser = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
score: true,
level: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
avatar: true,
},
});
return NextResponse.json(updatedUser);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour de l'utilisateur" },
{ status: 500 }
);
}
}
export async function DELETE(
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 } = await params;
// Vérifier que l'utilisateur existe
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Empêcher la suppression de soi-même
if (user.id === session.user.id) {
return NextResponse.json(
{ error: "Vous ne pouvez pas supprimer votre propre compte" },
{ status: 400 }
);
}
// Supprimer l'utilisateur (les relations seront supprimées en cascade)
await prisma.user.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { Role } from "@/prisma/generated/prisma/client"; import { Role } from "@/prisma/generated/prisma/client";
export async function GET() { export async function GET() {
@@ -12,7 +12,10 @@ export async function GET() {
} }
// Récupérer tous les utilisateurs avec leurs stats // Récupérer tous les utilisateurs avec leurs stats
const users = await prisma.user.findMany({ const users = await userService.getAllUsers({
orderBy: {
score: "desc",
},
select: { select: {
id: true, id: true,
username: true, username: true,
@@ -27,9 +30,6 @@ export async function GET() {
avatar: true, avatar: true,
createdAt: true, createdAt: true,
}, },
orderBy: {
score: "desc",
},
}); });
return NextResponse.json(users); return NextResponse.json(users);

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

@@ -1,115 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventRegistrationService } from "@/services/events/event-registration.service";
import { calculateEventStatus } from "@/lib/eventStatus";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté pour vous inscrire" },
{ status: 401 }
);
}
const { id: eventId } = await params;
// Vérifier si l'événement existe
const event = await prisma.event.findUnique({
where: { id: eventId },
});
if (!event) {
return NextResponse.json(
{ error: "Événement introuvable" },
{ status: 404 }
);
}
const eventStatus = calculateEventStatus(event.date);
if (eventStatus !== "UPCOMING") {
return NextResponse.json(
{ error: "Vous ne pouvez vous inscrire qu'aux événements à venir" },
{ status: 400 }
);
}
// Vérifier si l'utilisateur est déjà inscrit
const existingRegistration = await prisma.eventRegistration.findUnique({
where: {
userId_eventId: {
userId: session.user.id,
eventId: eventId,
},
},
});
if (existingRegistration) {
return NextResponse.json(
{ error: "Vous êtes déjà inscrit à cet événement" },
{ status: 400 }
);
}
// Créer l'inscription
const registration = await prisma.eventRegistration.create({
data: {
userId: session.user.id,
eventId: eventId,
},
});
return NextResponse.json(
{ message: "Inscription réussie", registration },
{ status: 201 }
);
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Vous devez être connecté" },
{ status: 401 }
);
}
const { id: eventId } = await params;
// Supprimer l'inscription
await prisma.eventRegistration.deleteMany({
where: {
userId: session.user.id,
eventId: eventId,
},
});
return NextResponse.json({ message: "Inscription annulée" });
} catch (error) {
console.error("Unregistration error:", error);
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'annulation" },
{ status: 500 }
);
}
}
export async function GET( export async function GET(
request: Request, request: Request,
@@ -124,16 +15,12 @@ export async function GET(
const { id: eventId } = await params; const { id: eventId } = await params;
const registration = await prisma.eventRegistration.findUnique({ const isRegistered = await eventRegistrationService.checkUserRegistration(
where: { session.user.id,
userId_eventId: { eventId
userId: session.user.id, );
eventId: eventId,
},
},
});
return NextResponse.json({ registered: !!registration }); return NextResponse.json({ registered: isRegistered });
} catch (error) { } catch (error) {
console.error("Check registration error:", error); console.error("Check registration error:", error);
return NextResponse.json({ registered: false }); return NextResponse.json({ registered: false });

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
export async function GET( export async function GET(
request: Request, request: Request,
@@ -8,9 +8,7 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
const event = await prisma.event.findUnique({ const event = await eventService.getEventById(id);
where: { id },
});
if (!event) { if (!event) {
return NextResponse.json( return NextResponse.json(

View File

@@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
export async function GET() { export async function GET() {
try { try {
const events = await prisma.event.findMany({ const events = await eventService.getAllEvents({
orderBy: { orderBy: { date: "asc" },
date: "asc",
},
}); });
return NextResponse.json(events); return NextResponse.json(events);

View File

@@ -1,70 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { eventFeedbackService } from "@/services/events/event-feedback.service";
export async function POST(
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;
const body = await request.json();
const { rating, comment } = body;
// Valider la note (1-5)
if (!rating || rating < 1 || rating > 5) {
return NextResponse.json(
{ error: "La note doit être entre 1 et 5" },
{ status: 400 }
);
}
// Vérifier que l'événement existe
const event = await prisma.event.findUnique({
where: { id: eventId },
});
if (!event) {
return NextResponse.json(
{ error: "Événement introuvable" },
{ status: 404 }
);
}
// Créer ou mettre à jour le feedback (unique par utilisateur/événement)
const feedback = await prisma.eventFeedback.upsert({
where: {
userId_eventId: {
userId: session.user.id,
eventId,
},
},
update: {
rating,
comment: comment || null,
},
create: {
userId: session.user.id,
eventId,
rating,
comment: comment || null,
},
});
return NextResponse.json({ success: true, feedback });
} catch (error) {
console.error("Error saving feedback:", error);
return NextResponse.json(
{ error: "Erreur lors de l'enregistrement du feedback" },
{ status: 500 }
);
}
}
export async function GET( export async function GET(
request: Request, request: Request,
@@ -79,23 +15,10 @@ export async function GET(
const { eventId } = await params; const { eventId } = await params;
// Récupérer le feedback de l'utilisateur pour cet événement // Récupérer le feedback de l'utilisateur pour cet événement
const feedback = await prisma.eventFeedback.findUnique({ const feedback = await eventFeedbackService.getUserFeedback(
where: { session.user.id,
userId_eventId: { eventId
userId: session.user.id, );
eventId,
},
},
include: {
event: {
select: {
id: true,
name: true,
date: true,
},
},
},
});
return NextResponse.json({ feedback }); return NextResponse.json({ feedback });
} catch (error) { } catch (error) {

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

@@ -1,49 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userStatsService } from "@/services/users/user-stats.service";
export async function GET() { export async function GET() {
try { try {
const users = await prisma.user.findMany({ const leaderboard = await userStatsService.getLeaderboard(10);
orderBy: {
score: "desc",
},
take: 10,
select: {
id: true,
username: true,
email: true,
score: true,
level: true,
avatar: true,
bio: true,
characterClass: true,
},
});
const leaderboard = users.map(
(
user: {
id: string;
username: string;
email: string;
score: number;
level: number;
avatar: string | null;
bio: string | null;
characterClass: string | null;
},
index: number
) => ({
rank: index + 1,
username: user.username,
email: user.email,
score: user.score,
level: user.level,
avatar: user.avatar,
bio: user.bio,
characterClass: user.characterClass,
})
);
return NextResponse.json(leaderboard); return NextResponse.json(leaderboard);
} catch (error) { } catch (error) {

View File

@@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
export async function GET() { export async function GET() {
try { try {
// Récupérer les préférences globales du site (pas besoin d'authentification) // Récupérer les préférences globales du site (pas besoin d'authentification)
let sitePreferences = await prisma.sitePreferences.findUnique({ const sitePreferences = await sitePreferencesService.getSitePreferences();
where: { id: "global" },
});
// Si elles n'existent pas, retourner des valeurs par défaut // Si elles n'existent pas, retourner des valeurs par défaut
if (!sitePreferences) { if (!sitePreferences) {
@@ -14,6 +12,9 @@ export async function GET() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
}); });
} }
@@ -21,6 +22,9 @@ export async function GET() {
homeBackground: sitePreferences.homeBackground, homeBackground: sitePreferences.homeBackground,
eventsBackground: sitePreferences.eventsBackground, eventsBackground: sitePreferences.eventsBackground,
leaderboardBackground: sitePreferences.leaderboardBackground, leaderboardBackground: sitePreferences.leaderboardBackground,
challengesBackground: sitePreferences.challengesBackground,
profileBackground: sitePreferences.profileBackground,
houseBackground: sitePreferences.houseBackground,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching preferences:", error); console.error("Error fetching preferences:", error);
@@ -29,6 +33,9 @@ export async function GET() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
}, },
{ status: 200 } { status: 200 }
); );

View File

@@ -39,24 +39,25 @@ export async function POST(request: Request) {
); );
} }
// Créer le dossier uploads s'il n'existe pas // Créer le dossier uploads/avatars s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads"); const uploadsDir = join(process.cwd(), "public", "uploads");
if (!existsSync(uploadsDir)) { const avatarsDir = join(uploadsDir, "avatars");
await mkdir(uploadsDir, { recursive: true }); if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
} }
// Générer un nom de fichier unique avec l'ID utilisateur // Générer un nom de fichier unique avec l'ID utilisateur
const timestamp = Date.now(); const timestamp = Date.now();
const filename = `avatar-${session.user.id}-${timestamp}-${file.name}`; const filename = `avatar-${session.user.id}-${timestamp}-${file.name}`;
const filepath = join(uploadsDir, filename); const filepath = join(avatarsDir, filename);
// Convertir le fichier en buffer et l'écrire // Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer); await writeFile(filepath, buffer);
// Retourner l'URL de l'image // Retourner l'URL de l'image via l'API
const imageUrl = `/uploads/${filename}`; const imageUrl = `/api/avatars/${filename}`;
return NextResponse.json({ url: imageUrl }); return NextResponse.json({ url: imageUrl });
} catch (error) { } catch (error) {
console.error("Error uploading avatar:", error); console.error("Error uploading avatar:", error);

View File

@@ -1,84 +0,0 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { currentPassword, newPassword, confirmPassword } = body;
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
if (newPassword.length < 6) {
return NextResponse.json(
{
error: "Le nouveau mot de passe doit contenir au moins 6 caractères",
},
{ status: 400 }
);
}
if (newPassword !== confirmPassword) {
return NextResponse.json(
{ error: "Les mots de passe ne correspondent pas" },
{ status: 400 }
);
}
// Récupérer l'utilisateur avec le mot de passe
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { password: true },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Vérifier l'ancien mot de passe
const isPasswordValid = await bcrypt.compare(
currentPassword,
user.password
);
if (!isPasswordValid) {
return NextResponse.json(
{ error: "Mot de passe actuel incorrect" },
{ status: 400 }
);
}
// Hasher le nouveau mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Mettre à jour le mot de passe
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
});
return NextResponse.json({ message: "Mot de passe modifié avec succès" });
} catch (error) {
console.error("Error updating password:", error);
return NextResponse.json(
{ error: "Erreur lors de la modification du mot de passe" },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { CharacterClass } from "@/prisma/generated/prisma/enums";
export async function GET() { export async function GET() {
try { try {
@@ -11,23 +10,20 @@ export async function GET() {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
} }
const user = await prisma.user.findUnique({ const user = await userService.getUserById(session.user.id, {
where: { id: session.user.id }, id: true,
select: { email: true,
id: true, username: true,
email: true, avatar: true,
username: true, bio: true,
avatar: true, characterClass: true,
bio: true, hp: true,
characterClass: true, maxHp: true,
hp: true, xp: true,
maxHp: true, maxXp: true,
xp: true, level: true,
maxXp: true, score: true,
level: true, createdAt: true,
score: true,
createdAt: true,
},
}); });
if (!user) { if (!user) {
@@ -46,136 +42,3 @@ export async function GET() {
); );
} }
} }
export async function PUT(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const body = await request.json();
const { username, avatar, bio, characterClass } = body;
// Validation
if (username !== undefined) {
if (typeof username !== "string" || username.trim().length === 0) {
return NextResponse.json(
{ error: "Le nom d'utilisateur ne peut pas être vide" },
{ status: 400 }
);
}
if (username.length < 3 || username.length > 20) {
return NextResponse.json(
{
error:
"Le nom d'utilisateur doit contenir entre 3 et 20 caractères",
},
{ status: 400 }
);
}
// Vérifier si le username est déjà pris par un autre utilisateur
const existingUser = await prisma.user.findFirst({
where: {
username: username.trim(),
NOT: { id: session.user.id },
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Ce nom d'utilisateur est déjà pris" },
{ status: 400 }
);
}
}
// Validation bio
if (bio !== undefined && bio !== null) {
if (typeof bio !== "string") {
return NextResponse.json(
{ error: "La bio doit être une chaîne de caractères" },
{ status: 400 }
);
}
if (bio.length > 500) {
return NextResponse.json(
{ error: "La bio ne peut pas dépasser 500 caractères" },
{ status: 400 }
);
}
}
// Validation characterClass
const validClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass !== undefined && characterClass !== null) {
if (!validClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
}
// Mettre à jour l'utilisateur
const updateData: {
username?: string;
avatar?: string | null;
bio?: string | null;
characterClass?: CharacterClass | null;
} = {};
if (username !== undefined) {
updateData.username = username.trim();
}
if (avatar !== undefined) {
updateData.avatar = avatar || null;
}
if (bio !== undefined) {
updateData.bio = bio.trim() || null;
}
if (characterClass !== undefined) {
updateData.characterClass = (characterClass as CharacterClass) || null;
}
const updatedUser = await prisma.user.update({
where: { id: session.user.id },
data: updateData,
select: {
id: true,
email: true,
username: true,
avatar: true,
bio: true,
characterClass: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
score: true,
},
});
return NextResponse.json(updatedUser);
} catch (error) {
console.error("Error updating profile:", error);
return NextResponse.json(
{ error: "Erreur lors de la mise à jour du profil" },
{ status: 500 }
);
}
}

View File

@@ -32,25 +32,26 @@ export async function POST(request: Request) {
); );
} }
// Créer le dossier uploads s'il n'existe pas // Créer le dossier uploads/avatars s'il n'existe pas
const uploadsDir = join(process.cwd(), "public", "uploads"); const uploadsDir = join(process.cwd(), "public", "uploads");
if (!existsSync(uploadsDir)) { const avatarsDir = join(uploadsDir, "avatars");
await mkdir(uploadsDir, { recursive: true }); if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
} }
// Générer un nom de fichier unique avec timestamp // Générer un nom de fichier unique avec timestamp
const timestamp = Date.now(); const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 9); const randomId = Math.random().toString(36).substring(2, 9);
const filename = `avatar-register-${timestamp}-${randomId}-${file.name}`; const filename = `avatar-register-${timestamp}-${randomId}-${file.name}`;
const filepath = join(uploadsDir, filename); const filepath = join(avatarsDir, filename);
// Convertir le fichier en buffer et l'écrire // Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer); await writeFile(filepath, buffer);
// Retourner l'URL de l'image // Retourner l'URL de l'image via l'API
const imageUrl = `/uploads/${filename}`; const imageUrl = `/api/avatars/${filename}`;
return NextResponse.json({ url: imageUrl }); return NextResponse.json({ url: imageUrl });
} catch (error) { } catch (error) {
console.error("Error uploading avatar:", error); console.error("Error uploading avatar:", error);

View File

@@ -1,6 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { CharacterClass } from "@/prisma/generated/prisma/enums"; import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -14,139 +18,10 @@ export async function POST(request: Request) {
); );
} }
// Vérifier que l'utilisateur existe et a été créé récemment (dans les 10 dernières minutes) const updatedUser = await userService.validateAndCompleteRegistration(
const user = await prisma.user.findUnique({ userId,
where: { id: userId }, { username, avatar, bio, characterClass }
}); );
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Vérifier que le compte a été créé récemment (dans les 10 dernières minutes)
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
if (user.createdAt < tenMinutesAgo) {
return NextResponse.json(
{ error: "Temps écoulé pour finaliser l'inscription" },
{ status: 400 }
);
}
// Validation username
if (username !== undefined) {
if (typeof username !== "string" || username.trim().length === 0) {
return NextResponse.json(
{ error: "Le nom d'utilisateur ne peut pas être vide" },
{ status: 400 }
);
}
if (username.length < 3 || username.length > 20) {
return NextResponse.json(
{
error:
"Le nom d'utilisateur doit contenir entre 3 et 20 caractères",
},
{ status: 400 }
);
}
// Vérifier si le username est déjà pris par un autre utilisateur
const existingUser = await prisma.user.findFirst({
where: {
username: username.trim(),
NOT: { id: userId },
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Ce nom d'utilisateur est déjà pris" },
{ status: 400 }
);
}
}
// Validation bio
if (bio !== undefined && bio !== null) {
if (typeof bio !== "string") {
return NextResponse.json(
{ error: "La bio doit être une chaîne de caractères" },
{ status: 400 }
);
}
if (bio.length > 500) {
return NextResponse.json(
{ error: "La bio ne peut pas dépasser 500 caractères" },
{ status: 400 }
);
}
}
// Validation characterClass
const validClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass !== undefined && characterClass !== null) {
if (!validClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
}
// Mettre à jour l'utilisateur
const updateData: {
username?: string;
avatar?: string | null;
bio?: string | null;
characterClass?: CharacterClass | null;
} = {};
if (username !== undefined) {
updateData.username = username.trim();
}
if (avatar !== undefined) {
updateData.avatar = avatar || null;
}
if (bio !== undefined) {
if (bio === null || bio === "") {
updateData.bio = null;
} else if (typeof bio === "string") {
updateData.bio = bio.trim() || null;
} else {
updateData.bio = null;
}
}
if (characterClass !== undefined) {
updateData.characterClass = (characterClass as CharacterClass) || null;
}
// Si aucun champ à mettre à jour, retourner succès quand même
if (Object.keys(updateData).length === 0) {
return NextResponse.json({
message: "Profil finalisé avec succès",
userId: user.id,
});
}
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
});
return NextResponse.json({ return NextResponse.json({
message: "Profil finalisé avec succès", message: "Profil finalisé avec succès",
@@ -154,11 +29,17 @@ export async function POST(request: Request) {
}); });
} catch (error) { } catch (error) {
console.error("Error completing registration:", error); console.error("Error completing registration:", error);
const errorMessage =
error instanceof Error ? error.message : "Erreur inconnue"; 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( return NextResponse.json(
{ {
error: `Erreur lors de la finalisation de l'inscription: ${errorMessage}`, error: `Erreur lors de la finalisation de l'inscription: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
}, },
{ status: 500 } { status: 500 }
); );

View File

@@ -1,73 +1,19 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import bcrypt from "bcryptjs"; import { ValidationError, ConflictError } from "@/services/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { email, username, password, bio, characterClass, avatar } = body; const { email, username, password, bio, characterClass, avatar } = body;
if (!email || !username || !password) { const user = await userService.validateAndCreateUser({
return NextResponse.json( email,
{ error: "Email, nom d'utilisateur et mot de passe sont requis" }, username,
{ status: 400 } password,
); bio,
} characterClass,
avatar,
if (password.length < 6) {
return NextResponse.json(
{ error: "Le mot de passe doit contenir au moins 6 caractères" },
{ status: 400 }
);
}
// Valider characterClass si fourni
const validCharacterClasses = [
"WARRIOR",
"MAGE",
"ROGUE",
"RANGER",
"PALADIN",
"ENGINEER",
"MERCHANT",
"SCHOLAR",
"BERSERKER",
"NECROMANCER",
];
if (characterClass && !validCharacterClasses.includes(characterClass)) {
return NextResponse.json(
{ error: "Classe de personnage invalide" },
{ status: 400 }
);
}
// Vérifier si l'email existe déjà
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
return NextResponse.json(
{ error: "Cet email ou nom d'utilisateur est déjà utilisé" },
{ status: 400 }
);
}
// Hasher le mot de passe
const hashedPassword = await bcrypt.hash(password, 10);
// Créer l'utilisateur
const user = await prisma.user.create({
data: {
email,
username,
password: hashedPassword,
bio: bio || null,
characterClass: characterClass || null,
avatar: avatar || null,
},
}); });
return NextResponse.json( return NextResponse.json(
@@ -76,6 +22,11 @@ export async function POST(request: Request) {
); );
} catch (error) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json( return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" }, { error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 } { status: 500 }

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
export async function GET( export async function GET(
request: Request, request: Request,
@@ -19,19 +19,16 @@ export async function GET(
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
const user = await prisma.user.findUnique({ const user = await userService.getUserById(id, {
where: { id }, id: true,
select: { username: true,
id: true, avatar: true,
username: true, hp: true,
avatar: true, maxHp: true,
hp: true, xp: true,
maxHp: true, maxXp: true,
xp: true, level: true,
maxXp: true, score: true,
level: true,
score: true,
},
}); });
if (!user) { if (!user) {

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,17 +1,25 @@
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import EventsPageSection from "@/components/EventsPageSection"; import EventsPageSection from "@/components/events/EventsPageSection";
import { prisma } from "@/lib/prisma"; import { eventService } from "@/services/events/event.service";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function EventsPage() { export default async function EventsPage() {
const events = await prisma.event.findMany({ // Paralléliser les appels indépendants
orderBy: { const session = await auth();
date: "desc",
}, 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 // Sérialiser les dates pour le client
const serializedEvents = events.map((event) => ({ const serializedEvents = events.map((event) => ({
@@ -21,27 +29,11 @@ export default async function EventsPage() {
updatedAt: event.updatedAt.toISOString(), updatedAt: event.updatedAt.toISOString(),
})); }));
const backgroundImage = await getBackgroundImage("events", "/got-2.jpg"); // Construire le map des inscriptions
// Récupérer les inscriptions côté serveur pour éviter le clignotement
const session = await auth();
const initialRegistrations: Record<string, boolean> = {}; const initialRegistrations: Record<string, boolean> = {};
allRegistrations.forEach((reg) => {
if (session?.user?.id) { initialRegistrations[reg.eventId] = true;
// Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback });
const allRegistrations = await prisma.eventRegistration.findMany({
where: {
userId: session.user.id,
},
select: {
eventId: true,
},
});
allRegistrations.forEach((reg) => {
initialRegistrations[reg.eventId] = true;
});
}
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">

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

@@ -1,260 +1,17 @@
"use client"; import FeedbackPageClient from "./FeedbackPageClient";
import { getBackgroundImage } from "@/lib/preferences";
import { useState, useEffect, type FormEvent } from "react"; export const dynamic = "force-dynamic";
import { useSession } from "next-auth/react";
import { useRouter, useParams } from "next/navigation";
import Navigation from "@/components/Navigation";
import { useBackgroundImage } from "@/hooks/usePreferences";
interface Event { interface FeedbackPageProps {
id: string; params: Promise<{
name: string; eventId: string;
date: string; }>;
description: string;
} }
interface Feedback { export default async function FeedbackPage({ params }: FeedbackPageProps) {
id: string; await params; // Ensure params are resolved (Next.js 15 requirement)
rating: number; const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
comment: string | null;
} return <FeedbackPageClient backgroundImage={backgroundImage} />;
export default function FeedbackPage() {
const { status } = useSession();
const router = useRouter();
const params = useParams();
const eventId = params?.eventId as string;
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
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 [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);
try {
const response = await fetch(`/api/feedback/${eventId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
rating,
comment: comment.trim() || null,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || "Erreur lors de l&apos;enregistrement");
return;
}
setSuccess(true);
setExistingFeedback(data.feedback);
// Rediriger après 2 secondes
setTimeout(() => {
router.push("/events");
}, 2000);
} catch {
setError("Erreur lors de l&apos;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 />
<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('${backgroundImage}')`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Feedback Form */}
<div className="relative z-10 w-full max-w-2xl mx-auto px-8">
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-8 backdrop-blur-sm">
<h1 className="text-4xl font-gaming font-black mb-2 text-center">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
FEEDBACK
</span>
</h1>
<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 && (
<div className="bg-green-900/50 border border-green-500/50 text-green-400 px-4 py-3 rounded text-sm mb-6">
Feedback enregistré avec succès ! Redirection...
</div>
)}
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm mb-6">
{error}
</div>
)}
<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>
<div className="flex items-center justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className={`text-4xl transition-transform hover:scale-110 ${
star <= rating
? "text-pixel-gold"
: "text-gray-600 hover:text-gray-500"
}`}
aria-label={`Noter ${star} étoile${star > 1 ? "s" : ""}`}
>
</button>
))}
</div>
<p className="text-gray-500 text-xs text-center mt-2">
{rating > 0 && `${rating}/5`}
</p>
</div>
{/* Comment */}
<div>
<label
htmlFor="comment"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Commentaire (optionnel)
</label>
<textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={6}
maxLength={1000}
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition resize-none"
placeholder="Partagez votre expérience, vos suggestions..."
/>
<p className="text-gray-500 text-xs mt-1 text-right">
{comment.length}/1000 caractères
</p>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={submitting || rating === 0}
className="w-full px-6 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting
? "Enregistrement..."
: existingFeedback
? "Modifier le feedback"
: "Envoyer le feedback"}
</button>
</form>
</div>
</div>
</section>
</main>
);
} }

View File

@@ -2,9 +2,107 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { @layer base {
body { body {
@apply bg-black text-white; background-color: var(--background);
color: var(--foreground);
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
@@ -31,4 +129,65 @@
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -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

@@ -2,7 +2,9 @@ import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Orbitron, Rajdhani } from "next/font/google"; import { Orbitron, Rajdhani } from "next/font/google";
import "./globals.css"; import "./globals.css";
import SessionProvider from "@/components/SessionProvider"; import SessionProvider from "@/components/layout/SessionProvider";
import { ThemeProvider } from "@/contexts/ThemeContext";
import Footer from "@/components/layout/Footer";
const orbitron = Orbitron({ const orbitron = Orbitron({
subsets: ["latin"], subsets: ["latin"],
@@ -27,9 +29,17 @@ export default function RootLayout({
children: ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
<html lang="fr" className={`${orbitron.variable} ${rajdhani.variable}`}> <html
lang="fr"
className={`${orbitron.variable} ${rajdhani.variable} dark-cyan`}
>
<body className="antialiased"> <body className="antialiased">
<SessionProvider>{children}</SessionProvider> <ThemeProvider>
<SessionProvider>
{children}
<Footer />
</SessionProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,60 +1,24 @@
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import LeaderboardSection from "@/components/LeaderboardSection"; import LeaderboardSection from "@/components/leaderboard/LeaderboardSection";
import { prisma } from "@/lib/prisma"; import { userStatsService } from "@/services/users/user-stats.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface LeaderboardEntry {
rank: number;
username: string;
email: string;
score: number;
level: number;
avatar: string | null;
bio: string | null;
characterClass: string | null;
}
export default async function LeaderboardPage() { export default async function LeaderboardPage() {
const users = await prisma.user.findMany({ // Paralléliser les appels DB
orderBy: { const [leaderboard, houseLeaderboard, backgroundImage] = await Promise.all([
score: "desc", userStatsService.getLeaderboard(10),
}, userStatsService.getHouseLeaderboard(10),
take: 10, getBackgroundImage("leaderboard", "/leaderboard-bg.jpg"),
select: { ]);
id: true,
username: true,
email: true,
score: true,
level: true,
avatar: true,
bio: true,
characterClass: true,
},
});
const leaderboard: LeaderboardEntry[] = users.map((user, index) => ({
rank: index + 1,
username: user.username,
email: user.email,
score: user.score,
level: user.level,
avatar: user.avatar,
bio: user.bio,
characterClass: user.characterClass,
}));
const backgroundImage = await getBackgroundImage(
"leaderboard",
"/leaderboard-bg.jpg"
);
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<NavigationWrapper /> <NavigationWrapper />
<LeaderboardSection <LeaderboardSection
leaderboard={leaderboard} leaderboard={leaderboard}
houseLeaderboard={houseLeaderboard}
backgroundImage={backgroundImage} backgroundImage={backgroundImage}
/> />
</main> </main>

View File

@@ -4,7 +4,15 @@ import { useState, type FormEvent } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/navigation/Navigation";
import {
Input,
Button,
Alert,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -46,79 +54,53 @@ export default function LoginPage() {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24"> <BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/got-2.jpg')`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Login Form */} {/* Login Form */}
<div className="relative z-10 w-full max-w-md mx-auto px-8"> <div className="w-full max-w-md mx-auto px-8">
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-8 backdrop-blur-sm"> <Card variant="dark" className="p-8">
<h1 className="text-4xl font-gaming font-black mb-2 text-center"> <SectionTitle
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"> variant="gradient"
CONNEXION size="md"
</span> className="mb-2 text-center"
</h1> >
CONNEXION
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-8"> <p className="text-gray-400 text-sm text-center mb-8">
Connectez-vous à votre compte Connectez-vous à votre compte
</p> </p>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{error && ( {error && <Alert variant="error">{error}</Alert>}
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
<div> <Input
<label id="email"
htmlFor="email" type="email"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" label="Email"
> value={email}
Email onChange={(e) => setEmail(e.target.value)}
</label> required
<input placeholder="votre@email.com"
id="email" />
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="votre@email.com"
/>
</div>
<div> <Input
<label id="password"
htmlFor="password" type="password"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" label="Mot de passe"
> value={password}
Mot de passe onChange={(e) => setPassword(e.target.value)}
</label> required
<input placeholder="••••••••"
id="password" />
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="••••••••"
/>
</div>
<button <Button
type="submit" type="submit"
variant="primary"
size="lg"
disabled={loading} disabled={loading}
className="w-full px-6 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 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full"
> >
{loading ? "Connexion..." : "Se connecter"} {loading ? "Connexion..." : "Se connecter"}
</button> </Button>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
@@ -132,9 +114,9 @@ export default function LoginPage() {
</Link> </Link>
</p> </p>
</div> </div>
</div> </Card>
</div> </div>
</section> </BackgroundSection>
</main> </main>
); );
} }

View File

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

View File

@@ -1,9 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { userService } from "@/services/users/user.service";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/NavigationWrapper"; import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import ProfileForm from "@/components/ProfileForm"; import ProfileForm from "@/components/profile/ProfileForm";
export default async function ProfilePage() { export default async function ProfilePage() {
const session = await auth(); const session = await auth();
@@ -12,9 +12,9 @@ export default async function ProfilePage() {
redirect("/login"); redirect("/login");
} }
const user = await prisma.user.findUnique({ // Paralléliser les appels DB
where: { id: session.user.id }, const [user, backgroundImage] = await Promise.all([
select: { userService.getUserById(session.user.id, {
id: true, id: true,
email: true, email: true,
username: true, username: true,
@@ -28,18 +28,14 @@ export default async function ProfilePage() {
level: true, level: true,
score: true, score: true,
createdAt: true, createdAt: true,
}, }),
}); getBackgroundImage("profile", "/got-background.jpg"),
]);
if (!user) { if (!user) {
redirect("/login"); redirect("/login");
} }
const backgroundImage = await getBackgroundImage(
"home",
"/got-background.jpg"
);
// Convert Date to string for the component // Convert Date to string for the component
const userProfile = { const userProfile = {
...user, ...user,

View File

@@ -3,7 +3,18 @@
import { useState, useRef, type ChangeEvent, type FormEvent } from "react"; import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Navigation from "@/components/Navigation"; 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() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
@@ -161,25 +172,17 @@ export default function RegisterPage() {
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<Navigation /> <Navigation />
<section className="relative w-full min-h-screen flex flex-col items-center justify-center overflow-hidden pt-24"> <BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('/got-2.jpg')`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
{/* Register Form */} {/* Register Form */}
<div className="relative z-10 w-full max-w-md mx-auto px-8"> <div className="w-full max-w-4xl mx-auto px-8">
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-8 backdrop-blur-sm"> <Card variant="dark" className="p-8">
<h1 className="text-4xl font-gaming font-black mb-2 text-center"> <SectionTitle
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent"> variant="gradient"
INSCRIPTION size="lg"
</span> className="mb-2 text-center"
</h1> >
INSCRIPTION
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-4"> <p className="text-gray-400 text-sm text-center mb-4">
{step === 1 {step === 1
? "Créez votre compte pour commencer" ? "Créez votre compte pour commencer"
@@ -215,103 +218,65 @@ export default function RegisterPage() {
{step === 1 ? ( {step === 1 ? (
<form onSubmit={handleStep1Submit} className="space-y-6"> <form onSubmit={handleStep1Submit} className="space-y-6">
{error && ( {error && <Alert variant="error">{error}</Alert>}
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
<div> <Input
<label id="email"
htmlFor="email" name="email"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" type="email"
> label="Email"
Email value={formData.email}
</label> onChange={handleChange}
<input required
id="email" placeholder="votre@email.com"
name="email" />
type="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="votre@email.com"
/>
</div>
<div> <Input
<label id="username"
htmlFor="username" name="username"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" type="text"
> label="Nom d'utilisateur"
Nom d&apos;utilisateur value={formData.username}
</label> onChange={handleChange}
<input required
id="username" placeholder="VotrePseudo"
name="username" />
type="text"
value={formData.username}
onChange={handleChange}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="VotrePseudo"
/>
</div>
<div> <Input
<label id="password"
htmlFor="password" name="password"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" type="password"
> label="Mot de passe"
Mot de passe value={formData.password}
</label> onChange={handleChange}
<input required
id="password" placeholder="••••••••"
name="password" />
type="password"
value={formData.password}
onChange={handleChange}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="••••••••"
/>
</div>
<div> <Input
<label id="confirmPassword"
htmlFor="confirmPassword" name="confirmPassword"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" type="password"
> label="Confirmer le mot de passe"
Confirmer le mot de passe value={formData.confirmPassword}
</label> onChange={handleChange}
<input required
id="confirmPassword" placeholder="••••••••"
name="confirmPassword" />
type="password"
value={formData.confirmPassword}
onChange={handleChange}
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="••••••••"
/>
</div>
<button <Button
type="submit" type="submit"
variant="primary"
size="lg"
disabled={loading} disabled={loading}
className="w-full px-6 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 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full"
> >
{loading ? "Création..." : "Suivant"} {loading ? "Création..." : "Suivant"}
</button> </Button>
</form> </form>
) : ( ) : (
<form onSubmit={handleStep2Submit} className="space-y-6"> <form onSubmit={handleStep2Submit} className="space-y-6">
{error && ( {error && <Alert variant="error">{error}</Alert>}
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{/* Avatar Selection */} {/* Avatar Selection */}
<div> <div>
@@ -321,23 +286,13 @@ export default function RegisterPage() {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
{/* Preview */} {/* Preview */}
<div className="relative"> <div className="relative">
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 overflow-hidden bg-gray-900 flex items-center justify-center"> <Avatar
{formData.avatar ? ( src={formData.avatar}
<img username={formData.username || "User"}
src={formData.avatar} size="xl"
alt="Avatar" borderClassName="border-4 border-pixel-gold/50"
className="w-full h-full object-cover" fallbackText={formData.username ? undefined : "?"}
/> />
) : formData.username ? (
<span className="text-pixel-gold text-3xl font-bold">
{formData.username.charAt(0).toUpperCase()}
</span>
) : (
<span className="text-pixel-gold text-3xl font-bold">
?
</span>
)}
</div>
{uploadingAvatar && ( {uploadingAvatar && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full"> <div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-xs"> <div className="text-pixel-gold text-xs">
@@ -396,83 +351,54 @@ export default function RegisterPage() {
className="hidden" className="hidden"
id="avatar-upload" id="avatar-upload"
/> />
<label <label htmlFor="avatar-upload">
htmlFor="avatar-upload" <Button
className="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 cursor-pointer inline-block" variant="primary"
> size="md"
{uploadingAvatar as="span"
? "Upload en cours..." className="cursor-pointer"
: "Upload un avatar custom"} >
{uploadingAvatar
? "Upload en cours..."
: "Upload un avatar custom"}
</Button>
</label> </label>
</div> </div>
</div> </div>
</div> </div>
<div> <Input
<label id="username-step2"
htmlFor="username-step2" name="username"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" type="text"
> label="Nom d'utilisateur"
Nom d&apos;utilisateur value={formData.username}
</label> onChange={handleChange}
<input required
id="username-step2" placeholder="VotrePseudo"
name="username" minLength={3}
type="text" maxLength={20}
value={formData.username} />
onChange={handleChange} <p className="text-gray-500 text-xs mt-1">3-20 caractères</p>
required
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition"
placeholder="VotrePseudo"
minLength={3}
maxLength={20}
/>
<p className="text-gray-500 text-xs mt-1">3-20 caractères</p>
</div>
<div> <Textarea
<label id="bio"
htmlFor="bio" name="bio"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider" label="Bio (optionnel)"
> value={formData.bio}
Bio (optionnel) onChange={handleChange}
</label> rows={4}
<textarea maxLength={500}
id="bio" showCharCount
name="bio" placeholder="Parlez-nous de vous..."
value={formData.bio} />
onChange={handleChange}
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition resize-none"
rows={4}
maxLength={500}
placeholder="Parlez-nous de vous..."
/>
<p className="text-gray-500 text-xs mt-1">
{formData.bio.length}/500 caractères
</p>
</div>
<div> <div>
<label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider"> <label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">
Classe de Personnage (optionnel) Classe de Personnage (optionnel)
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[ {CHARACTER_CLASSES.map((cls) => (
{ value: "WARRIOR", name: "Guerrier", icon: "⚔️" },
{ value: "MAGE", name: "Mage", icon: "🔮" },
{ value: "ROGUE", name: "Voleur", icon: "🗡️" },
{ value: "RANGER", name: "Rôdeur", icon: "🏹" },
{ value: "PALADIN", name: "Paladin", icon: "🛡️" },
{ value: "ENGINEER", name: "Ingénieur", icon: "⚙️" },
{ value: "MERCHANT", name: "Marchand", icon: "💰" },
{ value: "SCHOLAR", name: "Érudit", icon: "📚" },
{ value: "BERSERKER", name: "Berserker", icon: "🔥" },
{
value: "NECROMANCER",
name: "Nécromancien",
icon: "💀",
},
].map((cls) => (
<button <button
key={cls.value} key={cls.value}
type="button" type="button"
@@ -485,16 +411,16 @@ export default function RegisterPage() {
: cls.value, : cls.value,
}) })
} }
className={`p-3 border-2 rounded-lg text-left transition-all ${ className={`p-4 border-2 rounded-lg text-left transition-all ${
formData.characterClass === cls.value formData.characterClass === cls.value
? "border-pixel-gold bg-pixel-gold/20" ? "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" : "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50 hover:bg-black/60"
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 mb-1">
<span className="text-xl">{cls.icon}</span> <span className="text-2xl">{cls.icon}</span>
<span <span
className={`font-bold text-xs uppercase tracking-wider ${ className={`font-bold text-sm uppercase tracking-wider ${
formData.characterClass === cls.value formData.characterClass === cls.value
? "text-pixel-gold" ? "text-pixel-gold"
: "text-white" : "text-white"
@@ -503,26 +429,33 @@ export default function RegisterPage() {
{cls.name} {cls.name}
</span> </span>
</div> </div>
<p className="text-xs text-gray-400 leading-tight">
{cls.desc}
</p>
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <Button
type="button" type="button"
variant="secondary"
size="lg"
onClick={() => setStep(1)} onClick={() => setStep(1)}
className="flex-1 px-6 py-3 border border-gray-600/50 bg-black/40 text-gray-400 uppercase text-sm tracking-widest rounded hover:bg-gray-900/40 hover:border-gray-500 transition" className="flex-1"
> >
Retour Retour
</button> </Button>
<button <Button
type="submit" type="submit"
variant="primary"
size="lg"
disabled={loading} disabled={loading}
className="flex-1 px-6 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 disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1"
> >
{loading ? "Finalisation..." : "Terminer"} {loading ? "Finalisation..." : "Terminer"}
</button> </Button>
</div> </div>
</form> </form>
)} )}
@@ -538,9 +471,9 @@ export default function RegisterPage() {
</Link> </Link>
</p> </p>
</div> </div>
</div> </Card>
</div> </div>
</section> </BackgroundSection>
</main> </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>
);
}

View File

@@ -1,300 +0,0 @@
"use client";
import { useState } from "react";
import ImageSelector from "@/components/ImageSelector";
import UserManagement from "@/components/UserManagement";
import EventManagement from "@/components/EventManagement";
import FeedbackManagement from "@/components/FeedbackManagement";
interface SitePreferences {
id: string;
homeBackground: string | null;
eventsBackground: string | null;
leaderboardBackground: string | null;
}
interface AdminPanelProps {
initialPreferences: SitePreferences;
}
type AdminSection = "preferences" | "users" | "events" | "feedbacks";
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
const [activeSection, setActiveSection] =
useState<AdminSection>("preferences");
const [preferences, setPreferences] = useState<SitePreferences | null>(
initialPreferences
);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
homeBackground: initialPreferences.homeBackground || "",
eventsBackground: initialPreferences.eventsBackground || "",
leaderboardBackground: initialPreferences.leaderboardBackground || "",
});
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
try {
const response = await fetch("/api/admin/preferences", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (response.ok) {
const data = await response.json();
setPreferences(data);
setIsEditing(false);
}
} catch (error) {
console.error("Error updating preferences:", error);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
homeBackground: preferences.homeBackground || "",
eventsBackground: preferences.eventsBackground || "",
leaderboardBackground: preferences.leaderboardBackground || "",
});
}
};
return (
<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">
<h1 className="text-4xl font-gaming font-black mb-8 text-center">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
ADMIN
</span>
</h1>
{/* Navigation Tabs */}
<div className="flex gap-4 mb-8 justify-center">
<button
onClick={() => setActiveSection("preferences")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "preferences"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Préférences UI
</button>
<button
onClick={() => setActiveSection("users")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "users"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Utilisateurs
</button>
<button
onClick={() => setActiveSection("events")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "events"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Événements
</button>
<button
onClick={() => setActiveSection("feedbacks")}
className={`px-6 py-3 border uppercase text-xs tracking-widest rounded transition ${
activeSection === "feedbacks"
? "border-pixel-gold bg-pixel-gold/10 text-pixel-gold"
: "border-pixel-gold/30 bg-black/60 text-gray-400 hover:border-pixel-gold/50"
}`}
>
Feedbacks
</button>
</div>
{activeSection === "preferences" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Préférences UI Globales
</h2>
<div className="space-y-4">
<div className="bg-black/60 border border-pixel-gold/20 rounded p-4">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-pixel-gold font-bold text-lg">
Images de fond du site
</h3>
<p className="text-gray-400 text-sm">
Ces préférences s&apos;appliquent à tous les utilisateurs
</p>
</div>
{!isEditing && (
<button
onClick={handleEdit}
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
Modifier
</button>
)}
</div>
{isEditing ? (
<div className="space-y-6">
<ImageSelector
value={formData.homeBackground}
onChange={(url) =>
setFormData({
...formData,
homeBackground: url,
})
}
label="Background Home"
/>
<ImageSelector
value={formData.eventsBackground}
onChange={(url) =>
setFormData({
...formData,
eventsBackground: url,
})
}
label="Background Events"
/>
<ImageSelector
value={formData.leaderboardBackground}
onChange={(url) =>
setFormData({
...formData,
leaderboardBackground: url,
})
}
label="Background Leaderboard"
/>
<div className="flex gap-2 pt-4">
<button
onClick={handleSave}
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
>
Enregistrer
</button>
<button
onClick={handleCancel}
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
>
Annuler
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Home:
</span>
{preferences?.homeBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.homeBackground}
alt="Home background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.homeBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Events:
</span>
{preferences?.eventsBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.eventsBackground}
alt="Events background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.eventsBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-pixel-gold font-bold min-w-[120px]">
Leaderboard:
</span>
{preferences?.leaderboardBackground ? (
<div className="flex items-center gap-3">
<img
src={preferences.leaderboardBackground}
alt="Leaderboard background"
className="w-20 h-12 object-cover rounded border border-pixel-gold/30"
onError={(e) => {
e.currentTarget.src = "/got-2.jpg";
}}
/>
<span className="text-xs text-gray-400 truncate max-w-xs">
{preferences.leaderboardBackground}
</span>
</div>
) : (
<span className="text-gray-400">Par défaut</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
{activeSection === "users" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Utilisateurs
</h2>
<UserManagement />
</div>
)}
{activeSection === "events" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Événements
</h2>
<EventManagement />
</div>
)}
{activeSection === "feedbacks" && (
<div className="bg-black/80 border border-pixel-gold/30 rounded-lg p-6 backdrop-blur-sm">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Feedbacks
</h2>
<FeedbackManagement />
</div>
)}
</div>
</section>
);
}

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,446 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { calculateEventStatus } from "@/lib/eventStatus";
interface Event {
id: string;
date: string;
name: string;
description: string;
type: "ATELIER" | "KATA" | "PRESENTATION" | "LEARNING_HOUR";
status: "UPCOMING" | "LIVE" | "PAST";
room?: string | null;
time?: string | null;
maxPlaces?: number | null;
createdAt: string;
updatedAt: string;
registrationsCount?: number;
}
interface EventFormData {
date: string;
name: string;
description: string;
type: "ATELIER" | "KATA" | "PRESENTATION" | "LEARNING_HOUR";
room?: string;
time?: string;
maxPlaces?: number;
}
const eventTypes: Event["type"][] = [
"ATELIER",
"KATA",
"PRESENTATION",
"LEARNING_HOUR",
];
const getEventTypeLabel = (type: Event["type"]) => {
switch (type) {
case "ATELIER":
return "Atelier";
case "KATA":
return "Kata";
case "PRESENTATION":
return "Présentation";
case "LEARNING_HOUR":
return "Learning Hour";
default:
return type;
}
};
const getStatusLabel = (status: Event["status"]) => {
switch (status) {
case "UPCOMING":
return "À venir";
case "LIVE":
return "En cours";
case "PAST":
return "Passé";
default:
return status;
}
};
export default function EventManagement() {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<EventFormData>({
date: "",
name: "",
description: "",
type: "ATELIER",
room: "",
time: "",
maxPlaces: undefined,
});
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await fetch("/api/admin/events");
if (response.ok) {
const data = await response.json();
setEvents(data);
}
} catch (error) {
console.error("Error fetching events:", error);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setIsCreating(true);
setEditingEvent(null);
setFormData({
date: "",
name: "",
description: "",
type: "ATELIER",
room: "",
time: "",
maxPlaces: undefined,
});
};
const handleEdit = (event: Event) => {
setEditingEvent(event);
setIsCreating(false);
setFormData({
date: event.date,
name: event.name,
description: event.description,
type: event.type,
room: event.room || "",
time: event.time || "",
maxPlaces: event.maxPlaces || undefined,
});
};
const handleSave = async () => {
setSaving(true);
try {
let response;
if (isCreating) {
response = await fetch("/api/admin/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
} else if (editingEvent) {
response = await fetch(`/api/admin/events/${editingEvent.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
}
if (response?.ok) {
await fetchEvents();
setEditingEvent(null);
setIsCreating(false);
setFormData({
date: "",
name: "",
description: "",
type: "ATELIER",
room: "",
time: "",
maxPlaces: undefined,
});
} else {
const error = await response?.json();
alert(error.error || "Erreur lors de la sauvegarde");
}
} catch (error) {
console.error("Error saving event:", error);
alert("Erreur lors de la sauvegarde");
} finally {
setSaving(false);
}
};
const handleDelete = async (eventId: string) => {
if (!confirm("Êtes-vous sûr de vouloir supprimer cet événement ?")) {
return;
}
try {
const response = await fetch(`/api/admin/events/${eventId}`, {
method: "DELETE",
});
if (response.ok) {
await fetchEvents();
} else {
const error = await response.json();
alert(error.error || "Erreur lors de la suppression");
}
} catch (error) {
console.error("Error deleting event:", error);
alert("Erreur lors de la suppression");
}
};
const handleCancel = () => {
setEditingEvent(null);
setIsCreating(false);
setFormData({
date: "",
name: "",
description: "",
type: "ATELIER",
room: "",
time: "",
maxPlaces: undefined,
});
};
if (loading) {
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-gaming font-bold text-pixel-gold">
Événements ({events.length})
</h3>
{!isCreating && !editingEvent && (
<button
onClick={handleCreate}
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition"
>
+ Nouvel événement
</button>
)}
</div>
{(isCreating || editingEvent) && (
<div className="bg-black/60 border border-pixel-gold/20 rounded p-4 mb-4">
<h4 className="text-pixel-gold font-bold mb-4">
{isCreating ? "Créer un événement" : "Modifier l'événement"}
</h4>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-300 mb-1">Date</label>
<input
type="date"
value={formData.date}
onChange={(e) =>
setFormData({ ...formData, date: e.target.value })
}
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">Nom</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Nom de l'événement"
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Description de l'événement"
rows={4}
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1">Type</label>
<select
value={formData.type}
onChange={(e) =>
setFormData({
...formData,
type: e.target.value as Event["type"],
})
}
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
>
{eventTypes.map((type) => (
<option key={type} value={type}>
{getEventTypeLabel(type)}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-gray-300 mb-1">
Salle
</label>
<input
type="text"
value={formData.room || ""}
onChange={(e) =>
setFormData({ ...formData, room: e.target.value })
}
placeholder="Ex: Nautilus"
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">
Heure
</label>
<input
type="text"
value={formData.time || ""}
onChange={(e) =>
setFormData({ ...formData, time: e.target.value })
}
placeholder="Ex: 11h-12h"
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
<div>
<label className="block text-sm text-gray-300 mb-1">
Places max
</label>
<input
type="number"
value={formData.maxPlaces || ""}
onChange={(e) =>
setFormData({
...formData,
maxPlaces: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Ex: 25"
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 border border-green-500/50 bg-green-900/20 text-green-400 uppercase text-xs tracking-widest rounded hover:bg-green-900/30 transition disabled:opacity-50"
>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
<button
onClick={handleCancel}
className="px-4 py-2 border border-gray-600/50 bg-gray-900/20 text-gray-400 uppercase text-xs tracking-widest rounded hover:bg-gray-900/30 transition"
>
Annuler
</button>
</div>
</div>
</div>
)}
{events.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucun événement trouvé
</div>
) : (
<div className="space-y-3">
{events.map((event) => (
<div
key={event.id}
className="bg-black/60 border border-pixel-gold/20 rounded p-4"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="text-pixel-gold font-bold text-lg">
{event.name}
</h4>
<span className="px-2 py-1 bg-pixel-gold/20 border border-pixel-gold/50 text-pixel-gold text-xs uppercase rounded">
{getEventTypeLabel(event.type)}
</span>
<span
className={`px-2 py-1 text-xs uppercase rounded ${(() => {
const status = calculateEventStatus(event.date);
return status === "UPCOMING"
? "bg-green-900/50 border border-green-500/50 text-green-400"
: status === "LIVE"
? "bg-yellow-900/50 border border-yellow-500/50 text-yellow-400"
: "bg-gray-900/50 border border-gray-500/50 text-gray-400";
})()}`}
>
{getStatusLabel(calculateEventStatus(event.date))}
</span>
</div>
<p className="text-gray-400 text-sm mb-2">
{event.description}
</p>
<div className="flex flex-wrap items-center gap-4 mt-2">
<p className="text-gray-500 text-xs">
Date: {new Date(event.date).toLocaleDateString("fr-FR")}
</p>
{event.room && (
<p className="text-gray-500 text-xs">
📍 Salle: {event.room}
</p>
)}
{event.time && (
<p className="text-gray-500 text-xs">
🕐 Heure: {event.time}
</p>
)}
{event.maxPlaces && (
<p className="text-gray-500 text-xs">
👥 Places: {event.maxPlaces}
</p>
)}
<span className="px-2 py-1 bg-blue-900/30 border border-blue-500/50 text-blue-400 text-xs rounded">
{event.registrationsCount || 0} inscrit
{event.registrationsCount !== 1 ? "s" : ""}
</span>
</div>
</div>
{!isCreating && !editingEvent && (
<div className="flex gap-2 ml-4">
<button
onClick={() => handleEdit(event)}
className="px-3 py-1 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 transition"
>
Modifier
</button>
<button
onClick={() => handleDelete(event.id)}
className="px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 uppercase text-xs tracking-widest rounded hover:bg-red-900/30 transition"
>
Supprimer
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,43 +0,0 @@
interface Event {
id: string;
date: string;
name: string;
}
interface EventsSectionProps {
events: Event[];
}
export default function EventsSection({ events }: EventsSectionProps) {
if (events.length === 0) {
return null;
}
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">
{new Date(event.date).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</div>
<div className="text-white text-base text-center">
{event.name}
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,229 +0,0 @@
"use client";
import { useState, useEffect } from "react";
interface Feedback {
id: string;
rating: number;
comment: string | null;
createdAt: string;
event: {
id: string;
name: string;
date: string;
type: string;
};
user: {
id: string;
username: string;
email: string;
};
}
interface EventStatistics {
eventId: string;
eventName: string;
eventDate: string | null;
eventType: string | null;
averageRating: number;
feedbackCount: number;
}
export default function FeedbackManagement() {
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [statistics, setStatistics] = useState<EventStatistics[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
useEffect(() => {
fetchFeedbacks();
}, []);
const fetchFeedbacks = async () => {
try {
const response = await fetch("/api/admin/feedback");
if (!response.ok) {
setError("Erreur lors du chargement des feedbacks");
return;
}
const data = await response.json();
setFeedbacks(data.feedbacks || []);
setStatistics(data.statistics || []);
} catch {
setError("Erreur lors du chargement des feedbacks");
} finally {
setLoading(false);
}
};
const getEventTypeLabel = (type: string) => {
switch (type) {
case "ATELIER":
return "Atelier";
case "KATA":
return "Kata";
case "PRESENTATION":
return "Présentation";
case "LEARNING_HOUR":
return "Learning Hour";
default:
return type;
}
};
const renderStars = (rating: number) => {
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-lg ${
star <= rating ? "text-pixel-gold" : "text-gray-600"
}`}
>
</span>
))}
<span className="text-gray-400 text-sm ml-2">({rating}/5)</span>
</div>
);
};
const filteredFeedbacks = selectedEvent
? feedbacks.filter((f) => f.event.id === selectedEvent)
: feedbacks;
if (loading) {
return (
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-8">
<p className="text-gray-400 text-center">Chargement...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Statistiques par événement */}
{statistics.length > 0 && (
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-6">
<h3 className="text-pixel-gold font-bold text-lg mb-4">
Statistiques par événement
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{statistics.map((stat) => (
<div
key={stat.eventId}
className={`bg-black/40 border rounded p-4 cursor-pointer transition ${
selectedEvent === stat.eventId
? "border-pixel-gold bg-pixel-gold/10"
: "border-pixel-gold/30 hover:border-pixel-gold/50"
}`}
onClick={() =>
setSelectedEvent(
selectedEvent === stat.eventId ? null : stat.eventId
)
}
>
<div className="flex items-start justify-between mb-2">
<h4 className="text-white font-semibold text-sm">
{stat.eventName}
</h4>
<span className="text-pixel-gold text-xs uppercase">
{stat.eventType && getEventTypeLabel(stat.eventType)}
</span>
</div>
<div className="flex items-center gap-2 mb-2">
{renderStars(Math.round(stat.averageRating))}
</div>
<div className="text-gray-400 text-xs">
Moyenne: {stat.averageRating.toFixed(2)}/5
</div>
<div className="text-gray-400 text-xs">
{stat.feedbackCount} feedback
{stat.feedbackCount > 1 ? "s" : ""}
</div>
</div>
))}
</div>
{selectedEvent && (
<button
onClick={() => setSelectedEvent(null)}
className="mt-4 text-pixel-gold text-sm hover:text-orange-400 transition"
>
Voir tous les feedbacks
</button>
)}
</div>
)}
{/* Liste des feedbacks */}
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-6">
<h3 className="text-pixel-gold font-bold text-lg mb-4">
{selectedEvent
? `Feedbacks pour: ${
statistics.find((s) => s.eventId === selectedEvent)?.eventName
}`
: "Tous les feedbacks"}
</h3>
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm mb-4">
{error}
</div>
)}
{filteredFeedbacks.length === 0 ? (
<p className="text-gray-400 text-center py-8">
Aucun feedback pour le moment
</p>
) : (
<div className="space-y-4">
{filteredFeedbacks.map((feedback) => (
<div
key={feedback.id}
className="bg-black/40 border border-pixel-gold/20 rounded p-4"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="text-white font-semibold">
{feedback.user.username}
</h4>
<span className="text-gray-500 text-xs">
{feedback.user.email}
</span>
</div>
<div className="text-pixel-gold text-sm font-semibold mb-2">
{feedback.event.name}
</div>
<div className="text-gray-500 text-xs mb-2">
{new Date(feedback.createdAt).toLocaleDateString(
"fr-FR",
{
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}
)}
</div>
</div>
<div>{renderStars(feedback.rating)}</div>
</div>
{feedback.comment && (
<div className="mt-3 pt-3 border-t border-pixel-gold/20">
<p className="text-gray-300 text-sm whitespace-pre-wrap">
{feedback.comment}
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,288 +0,0 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { useSession } from "next-auth/react";
interface Event {
id: string;
name: string;
date: string;
description: string;
}
interface Feedback {
id: string;
rating: number;
comment: string | null;
}
interface FeedbackModalProps {
eventId: string | null;
eventName?: string;
onClose: () => void;
}
export default function FeedbackModal({
eventId,
eventName,
onClose,
}: FeedbackModalProps) {
const { status } = useSession();
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 [rating, setRating] = useState(0);
const [comment, setComment] = useState("");
// Réinitialiser les états quand eventId change
useEffect(() => {
if (eventId) {
setEvent(null);
setExistingFeedback(null);
setRating(0);
setComment("");
setError("");
setSuccess(false);
setLoading(true);
}
}, [eventId]);
const fetchEventAndFeedback = async () => {
if (!eventId) return;
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 || "");
} else {
// Pas de feedback existant, réinitialiser
setRating(0);
setComment("");
}
} else {
// Pas de feedback existant, réinitialiser
setRating(0);
setComment("");
}
} catch {
setError("Erreur lors du chargement des données");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (status === "unauthenticated") {
onClose();
return;
}
if (status === "authenticated" && eventId) {
fetchEventAndFeedback();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, eventId]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!eventId) return;
setError("");
setSuccess(false);
if (rating === 0) {
setError("Veuillez sélectionner une note");
return;
}
setSubmitting(true);
try {
const response = await fetch(`/api/feedback/${eventId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
rating,
comment: comment.trim() || null,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || "Erreur lors de l'enregistrement");
return;
}
setSuccess(true);
setExistingFeedback(data.feedback);
// Fermer la modale après 1.5 secondes
setTimeout(() => {
onClose();
}, 1500);
} catch {
setError("Erreur lors de l'enregistrement");
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
onClose();
}
};
if (!eventId) return null;
return (
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={handleClose}
>
<div
className="bg-black border-2 border-pixel-gold/70 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-4xl font-gaming font-black">
<span className="bg-gradient-to-r from-pixel-gold via-orange-400 to-pixel-gold bg-clip-text text-transparent">
FEEDBACK
</span>
</h1>
<button
onClick={handleClose}
disabled={submitting}
className="text-gray-400 hover:text-pixel-gold text-3xl font-bold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
×
</button>
</div>
{loading ? (
<div className="text-white text-center py-8">Chargement...</div>
) : !event ? (
<div className="text-red-400 text-center py-8">
Événement introuvable
</div>
) : (
<>
<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 && (
<div className="bg-green-900/50 border border-green-500/50 text-green-400 px-4 py-3 rounded text-sm mb-6">
Feedback enregistré avec succès !
</div>
)}
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm mb-6">
{error}
</div>
)}
<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>
<div className="flex items-center justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
disabled={submitting}
className={`text-4xl transition-transform hover:scale-110 disabled:hover:scale-100 ${
star <= rating
? "text-pixel-gold"
: "text-gray-600 hover:text-gray-500"
}`}
aria-label={`Noter ${star} étoile${star > 1 ? "s" : ""}`}
>
</button>
))}
</div>
<p className="text-gray-500 text-xs text-center mt-2">
{rating > 0 && `${rating}/5`}
</p>
</div>
{/* Comment */}
<div>
<label
htmlFor="comment"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Commentaire (optionnel)
</label>
<textarea
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={6}
maxLength={1000}
disabled={submitting}
className="w-full px-4 py-3 bg-black/60 border border-pixel-gold/30 rounded text-white placeholder-gray-500 focus:outline-none focus:border-pixel-gold transition resize-none disabled:opacity-50"
placeholder="Partagez votre expérience, vos suggestions..."
/>
<p className="text-gray-500 text-xs mt-1 text-right">
{comment.length}/1000 caractères
</p>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={submitting || rating === 0}
className="w-full px-6 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting
? "Enregistrement..."
: existingFeedback
? "Modifier le feedback"
: "Envoyer le feedback"}
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
"use client";
import { useBackgroundImage } from "@/hooks/usePreferences";
import Link from "next/link";
import { useState, useEffect, useRef } from "react";
export default function HeroSection() {
const backgroundImage = useBackgroundImage("home", "/got-2.jpg");
const titleRef = useRef<HTMLSpanElement>(null);
const [mousePosition, setMousePosition] = useState({ x: 50, y: 50 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (titleRef.current) {
const rect = titleRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMousePosition({
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
});
}
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
// Calculer la position du gradient basée sur la souris avec plus d'amplitude
const gradientPosition = mousePosition.x;
const glowIntensity = 12 + mousePosition.x / 5;
const glowOpacity = 0.4 + mousePosition.y / 250;
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('${backgroundImage}')`,
}}
>
{/* Dark overlay for readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80 z-[1]"></div>
</div>
{/* Hero Content */}
<div className="relative z-10 w-full max-w-5xl xl:max-w-6xl mx-auto px-8 py-16 text-center flex flex-col items-center">
{/* Game Title */}
<div className="w-full flex justify-center mb-4">
<h1 className="text-6xl md:text-8xl lg:text-9xl xl:text-9xl font-gaming font-black tracking-tight relative">
<span
ref={titleRef}
className="title-animated inline-block relative z-10"
style={{
backgroundImage: `linear-gradient(90deg, #daa520 0%, #ffa500 ${Math.max(
10,
gradientPosition - 20
)}%, #ff8c00 ${gradientPosition}%, #ffa500 ${Math.min(
90,
gradientPosition + 20
)}%, #daa520 100%)`,
backgroundSize: "200% auto",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
color: "transparent",
transition: "background-image 0.15s ease-out",
filter: `drop-shadow(0 0 ${glowIntensity}px rgba(255, 140, 0, ${glowOpacity}))`,
}}
>
GAME.OF.TECH
</span>
{/* Glow effect qui suit la souris */}
<span
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `linear-gradient(90deg, transparent 0%, rgba(255, 140, 0, 0.3) ${gradientPosition}%, transparent 100%)`,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
filter: `blur(${10 + mousePosition.x / 20}px)`,
opacity: 0.5,
zIndex: 0,
transition: "all 0.15s ease-out",
}}
aria-hidden="true"
>
GAME.OF.TECH
</span>
</h1>
</div>
{/* 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&apos;IA évoluent et d&apos;anciens codes attendent d&apos;être
découverts. Partez pour un voyage épique pour forger des alliances,
conquérir des défis et raconter votre histoire d&apos;innovation au
sein d&apos;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">
<Link href="/events">
<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">
<span>See events</span>
</button>
</Link>
<Link href="/leaderboard">
<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>See leaderboard</span>
</button>
</Link>
</div>
</div>
</section>
);
}

View File

@@ -1,305 +0,0 @@
"use client";
import { useState } from "react";
interface LeaderboardEntry {
rank: number;
username: string;
email: string;
score: number;
level: number;
avatar?: string | null;
bio?: string | null;
characterClass?: string | null;
}
interface LeaderboardSectionProps {
leaderboard: LeaderboardEntry[];
backgroundImage: string;
}
// Format number with consistent locale to avoid hydration mismatch
const formatScore = (score: number): string => {
return score.toLocaleString("en-US");
};
export default function LeaderboardSection({
leaderboard,
backgroundImage,
}: LeaderboardSectionProps) {
const [selectedEntry, setSelectedEntry] = useState<LeaderboardEntry | null>(
null
);
return (
<section className="relative w-full min-h-screen flex flex-col items-center justify-center pt-24 pb-16">
{/* Background Image */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url('${backgroundImage}')`,
}}
>
{/* 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 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 overflow-visible">
{leaderboard.map((entry) => (
<div
key={entry.rank}
className={`grid grid-cols-12 gap-4 p-4 hover:bg-gray-900/50 transition relative ${
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">
{entry.avatar ? (
<div className="w-10 h-10 rounded-full border border-pixel-gold/30 overflow-hidden">
<img
src={entry.avatar}
alt={entry.username}
className="w-full h-full object-cover"
/>
</div>
) : (
<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>
)}
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition"
onClick={() => setSelectedEntry(entry)}
>
<span
className={`font-bold ${
entry.rank <= 3 ? "text-pixel-gold" : "text-white"
}`}
>
{entry.username}
</span>
{entry.characterClass && (
<span className="text-xs text-gray-400 uppercase tracking-wider">
[{entry.characterClass === "WARRIOR" && "⚔️"}
{entry.characterClass === "MAGE" && "🔮"}
{entry.characterClass === "ROGUE" && "🗡️"}
{entry.characterClass === "RANGER" && "🏹"}
{entry.characterClass === "PALADIN" && "🛡️"}
{entry.characterClass === "ENGINEER" && "⚙️"}
{entry.characterClass === "MERCHANT" && "💰"}
{entry.characterClass === "SCHOLAR" && "📚"}
{entry.characterClass === "BERSERKER" && "🔥"}
{entry.characterClass === "NECROMANCER" && "💀"}]
</span>
)}
{entry.rank <= 3 && (
<span className="text-pixel-gold text-xs"></span>
)}
</div>
</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>
{/* Character Modal */}
{selectedEntry && (
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={() => setSelectedEntry(null)}
>
<div
className="bg-black border-2 border-pixel-gold/70 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-pixel-gold uppercase tracking-wider">
{selectedEntry.username}
</h2>
<button
onClick={() => setSelectedEntry(null)}
className="text-gray-400 hover:text-pixel-gold text-2xl font-bold transition"
>
×
</button>
</div>
{/* Avatar and Class */}
<div className="flex items-center gap-6 mb-6">
{selectedEntry.avatar ? (
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 overflow-hidden">
<img
src={selectedEntry.avatar}
alt={selectedEntry.username}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-24 h-24 rounded-full border-4 border-pixel-gold/50 bg-gray-900 flex items-center justify-center">
<span className="text-pixel-gold text-4xl font-bold">
{selectedEntry.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<div className="text-xs text-gray-400 uppercase tracking-widest mb-2">
Rank #{selectedEntry.rank}
</div>
<div className="text-sm text-gray-300 mb-2">
{selectedEntry.email}
</div>
{selectedEntry.characterClass && (
<div className="flex items-center gap-2">
<span className="text-2xl">
{selectedEntry.characterClass === "WARRIOR" && "⚔️"}
{selectedEntry.characterClass === "MAGE" && "🔮"}
{selectedEntry.characterClass === "ROGUE" && "🗡️"}
{selectedEntry.characterClass === "RANGER" && "🏹"}
{selectedEntry.characterClass === "PALADIN" && "🛡️"}
{selectedEntry.characterClass === "ENGINEER" && "⚙️"}
{selectedEntry.characterClass === "MERCHANT" && "💰"}
{selectedEntry.characterClass === "SCHOLAR" && "📚"}
{selectedEntry.characterClass === "BERSERKER" && "🔥"}
{selectedEntry.characterClass === "NECROMANCER" && "💀"}
</span>
<span className="text-lg font-bold text-pixel-gold uppercase tracking-wider">
{selectedEntry.characterClass === "WARRIOR" &&
"Guerrier"}
{selectedEntry.characterClass === "MAGE" && "Mage"}
{selectedEntry.characterClass === "ROGUE" && "Voleur"}
{selectedEntry.characterClass === "RANGER" && "Rôdeur"}
{selectedEntry.characterClass === "PALADIN" &&
"Paladin"}
{selectedEntry.characterClass === "ENGINEER" &&
"Ingénieur"}
{selectedEntry.characterClass === "MERCHANT" &&
"Marchand"}
{selectedEntry.characterClass === "SCHOLAR" && "Érudit"}
{selectedEntry.characterClass === "BERSERKER" &&
"Berserker"}
{selectedEntry.characterClass === "NECROMANCER" &&
"Nécromancien"}
</span>
</div>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-black/60 border border-pixel-gold/30 rounded p-4">
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
Score
</div>
<div className="text-2xl font-bold text-pixel-gold">
{formatScore(selectedEntry.score)}
</div>
</div>
<div className="bg-black/60 border border-pixel-gold/30 rounded p-4">
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
Niveau
</div>
<div className="text-2xl font-bold text-pixel-gold">
Lv.{selectedEntry.level}
</div>
</div>
</div>
{/* Bio */}
{selectedEntry.bio && (
<div className="border-t border-pixel-gold/30 pt-6">
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-3 font-bold">
Bio
</div>
<p className="text-gray-200 leading-relaxed whitespace-pre-wrap break-words">
{selectedEntry.bio}
</p>
</div>
)}
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -1,110 +0,0 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import PlayerStats from "./PlayerStats";
interface UserData {
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
}
interface NavigationProps {
initialUserData?: UserData | null;
initialIsAdmin?: boolean;
}
export default function Navigation({
initialUserData,
initialIsAdmin,
}: NavigationProps) {
const { data: session } = useSession();
// Utiliser initialUserData pour déterminer l'état de connexion pendant l'hydratation
// Cela évite le clignottement au reload
const isAuthenticated = initialUserData !== null || session !== null;
const isAdmin = initialIsAdmin ?? session?.user?.role === "ADMIN";
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>Peaksys</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>
{isAdmin && (
<Link
href="/admin"
className="text-pixel-gold hover:text-orange-400 transition text-xs font-normal uppercase tracking-widest"
>
ADMIN
</Link>
)}
</div>
{/* Right Side */}
<div className="flex items-center gap-4">
{isAuthenticated ? (
<>
<PlayerStats initialUserData={initialUserData} />
<button
onClick={() => signOut()}
className="text-gray-400 hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
>
Déconnexion
</button>
</>
) : (
<>
<Link
href="/login"
className="text-white hover:text-pixel-gold transition text-xs font-normal uppercase tracking-widest"
>
Connexion
</Link>
<Link
href="/register"
className="px-4 py-2 border border-pixel-gold/50 bg-black/60 text-white uppercase text-xs tracking-widest rounded hover:bg-pixel-gold/10 hover:border-pixel-gold transition"
>
Inscription
</Link>
</>
)}
</div>
</div>
</nav>
);
}

View File

@@ -1,41 +0,0 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import Navigation from "./Navigation";
interface UserData {
username: string;
avatar: string | null;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
level: number;
}
export default async function NavigationWrapper() {
const session = await auth();
let userData: UserData | null = null;
const isAdmin = session?.user?.role === "ADMIN";
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
username: true,
avatar: true,
hp: true,
maxHp: true,
xp: true,
maxXp: true,
level: true,
},
});
if (user) {
userData = user;
}
}
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />;
}

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