Compare commits

...

113 Commits

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

64
.dockerignore Normal file
View File

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

28
.env
View File

@@ -5,6 +5,28 @@
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="file:./dev.db"
AUTH_SECRET="your-secret-key-change-this-in-production"
AUTH_URL="http://localhost:3000"
# DATABASE_URL="file:./data/dev.db"
# AUTH_SECRET="your-secret-key-change-this-in-production"
# AUTH_URL="http://localhost:3000"
# PRISMA_DATA_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/data"
# UPLOADS_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/public/uploads"
# NextAuth Configuration
NEXTAUTH_SECRET=change-this-secret-in-production
NEXTAUTH_URL=http://localhost:3000
# PostgreSQL Configuration
POSTGRES_USER=gotgaming
POSTGRES_PASSWORD=change-this-in-production
POSTGRES_DB=gotgaming
POSTGRES_HOST=localhost
POSTGRES_PORT=5433
# Database URL (construite automatiquement si non définie)
# Si vous définissez cette variable, elle sera utilisée telle quelle
# Sinon, elle sera construite à partir des variables POSTGRES_* ci-dessus
DATABASE_URL=postgresql://gotgaming:change-this-in-production@localhost:5433/gotgaming?schema=public
# Docker Volumes (optionnel)
POSTGRES_DATA_PATH=./data/postgres
UPLOADS_PATH=./public/uploads

19
.env.example Normal file
View File

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

View File

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

7
.gitignore vendored
View File

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

10
.prettierignore Normal file
View File

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

8
.prettierrc Normal file
View File

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

95
Dockerfile Normal file
View File

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

98
README.docker.md Normal file
View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

@@ -1,42 +1,7 @@
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 default async function AdminPage() {
const session = await auth();
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>
);
redirect("/admin/preferences");
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Role, EventType } from "@/prisma/generated/prisma/client";
import { calculateEventStatus } from "@/lib/eventStatus";
import { eventService } from "@/services/events/event.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
try {
@@ -12,34 +11,22 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const events = await prisma.event.findMany({
orderBy: {
date: "desc",
},
include: {
_count: {
select: {
registrations: true,
},
},
},
});
const events = await eventService.getEventsWithStatus();
// Transformer les données pour inclure le nombre d'inscriptions
// Le statut est calculé automatiquement en fonction de la date
// Transformer les données pour la sérialisation
const eventsWithCount = events.map((event) => ({
id: event.id,
date: event.date.toISOString(),
name: event.name,
description: event.description,
type: event.type,
status: calculateEventStatus(event.date),
status: event.status,
room: event.room,
time: event.time,
maxPlaces: event.maxPlaces,
createdAt: event.createdAt.toISOString(),
updatedAt: event.updatedAt.toISOString(),
registrationsCount: event._count.registrations,
registrationsCount: event.registrationsCount,
}));
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

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

View File

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

View File

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

View File

@@ -17,7 +17,10 @@ export async function POST(request: Request) {
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
@@ -28,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");
if (!existsSync(uploadsDir)) {
await mkdir(uploadsDir, { recursive: true });
const backgroundsDir = join(uploadsDir, "backgrounds");
if (!existsSync(backgroundsDir)) {
await mkdir(backgroundsDir, { recursive: true });
}
// Générer un nom de fichier unique
const timestamp = Date.now();
const filename = `${timestamp}-${file.name}`;
const filepath = join(uploadsDir, filename);
const filepath = join(backgroundsDir, filename);
// Convertir le fichier en buffer et l'écrire
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filepath, buffer);
// Retourner l'URL de l'image
const imageUrl = `/uploads/${filename}`;
// Retourner l'URL de l'image via l'API
const imageUrl = `/api/backgrounds/${filename}`;
return NextResponse.json({ url: imageUrl });
} catch (error) {
console.error("Error uploading image:", error);
@@ -55,4 +59,3 @@ export async function POST(request: Request) {
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
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";
export async function GET() {
@@ -11,22 +11,9 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
// 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,
},
});
}
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
return NextResponse.json(sitePreferences);
} catch (error) {
@@ -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,199 +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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { userService } from "@/services/users/user.service";
import { Role } from "@/prisma/generated/prisma/client";
export async function GET() {
@@ -12,7 +12,10 @@ export async function GET() {
}
// Récupérer tous les utilisateurs avec leurs stats
const users = await prisma.user.findMany({
const users = await userService.getAllUsers({
orderBy: {
score: "desc",
},
select: {
id: true,
username: true,
@@ -27,9 +30,6 @@ export async function GET() {
avatar: true,
createdAt: true,
},
orderBy: {
score: "desc",
},
});
return NextResponse.json(users);
@@ -41,4 +41,3 @@ export async function GET() {
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,115 +1,6 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 }
);
}
}
import { eventRegistrationService } from "@/services/events/event-registration.service";
export async function GET(
request: Request,
@@ -124,19 +15,14 @@ export async function GET(
const { id: eventId } = await params;
const registration = await prisma.eventRegistration.findUnique({
where: {
userId_eventId: {
userId: session.user.id,
eventId: eventId,
},
},
});
const isRegistered = await eventRegistrationService.checkUserRegistration(
session.user.id,
eventId
);
return NextResponse.json({ registered: !!registration });
return NextResponse.json({ registered: isRegistered });
} catch (error) {
console.error("Check registration error:", error);
return NextResponse.json({ registered: false });
}
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +1,9 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { userStatsService } from "@/services/users/user-stats.service";
export async function GET() {
try {
const users = await prisma.user.findMany({
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,
})
);
const leaderboard = await userStatsService.getLeaderboard(10);
return NextResponse.json(leaderboard);
} catch (error) {

View File

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

View File

@@ -16,7 +16,10 @@ export async function POST(request: Request) {
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
return NextResponse.json(
{ error: "Aucun fichier fourni" },
{ status: 400 }
);
}
// Vérifier le type de fichier
@@ -36,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");
if (!existsSync(uploadsDir)) {
await mkdir(uploadsDir, { recursive: true });
const avatarsDir = join(uploadsDir, "avatars");
if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
}
// Générer un nom de fichier unique avec l'ID utilisateur
const timestamp = Date.now();
const filename = `avatar-${session.user.id}-${timestamp}-${file.name}`;
const filepath = join(uploadsDir, filename);
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
const imageUrl = `/uploads/${filename}`;
// 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);
@@ -63,4 +67,3 @@ export async function POST(request: Request) {
);
}
}

View File

@@ -1,80 +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 { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { CharacterClass } from "@/prisma/generated/prisma/enums";
import { userService } from "@/services/users/user.service";
export async function GET() {
try {
@@ -11,9 +10,7 @@ export async function GET() {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
const user = await userService.getUserById(session.user.id, {
id: true,
email: true,
username: true,
@@ -27,11 +24,13 @@ export async function GET() {
level: true,
score: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json({ error: "Utilisateur non trouvé" }, { status: 404 });
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
return NextResponse.json(user);
@@ -43,134 +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");
if (!existsSync(uploadsDir)) {
await mkdir(uploadsDir, { recursive: true });
const avatarsDir = join(uploadsDir, "avatars");
if (!existsSync(avatarsDir)) {
await mkdir(avatarsDir, { recursive: true });
}
// Générer un nom de fichier unique avec timestamp
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 9);
const filename = `avatar-register-${timestamp}-${randomId}-${file.name}`;
const filepath = join(uploadsDir, filename);
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
const imageUrl = `/uploads/${filename}`;
// 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);
@@ -60,4 +61,3 @@ export async function POST(request: Request) {
);
}
}

View File

@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { CharacterClass } from "@/prisma/generated/prisma/enums";
import { userService } from "@/services/users/user.service";
import {
ValidationError,
NotFoundError,
ConflictError,
} from "@/services/errors";
export async function POST(request: Request) {
try {
@@ -14,136 +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 user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
const updatedUser = await userService.validateAndCompleteRegistration(
userId,
{ username, avatar, bio, characterClass }
);
}
// 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({
message: "Profil finalisé avec succès",
@@ -151,11 +29,19 @@ export async function POST(request: Request) {
});
} catch (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(
{ 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 }
);
}
}

View File

@@ -1,73 +1,19 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { userService } from "@/services/users/user.service";
import { ValidationError, ConflictError } from "@/services/errors";
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, username, password, bio, characterClass, avatar } = body;
if (!email || !username || !password) {
return NextResponse.json(
{ error: "Email, nom d'utilisateur et mot de passe sont requis" },
{ status: 400 }
);
}
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: {
const user = await userService.validateAndCreateUser({
email,
username,
password: hashedPassword,
bio: bio || null,
characterClass: characterClass || null,
avatar: avatar || null,
},
password,
bio,
characterClass,
avatar,
});
return NextResponse.json(
@@ -76,10 +22,14 @@ export async function POST(request: Request) {
);
} catch (error) {
console.error("Registration error:", error);
if (error instanceof ValidationError || error instanceof ConflictError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json(
{ error: "Une erreur est survenue lors de l'inscription" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { userService } from "@/services/users/user.service";
export async function GET(
request: Request,
@@ -19,9 +19,7 @@ export async function GET(
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
}
const user = await prisma.user.findUnique({
where: { id },
select: {
const user = await userService.getUserById(id, {
id: true,
username: true,
avatar: true,
@@ -31,11 +29,13 @@ export async function GET(
maxXp: true,
level: true,
score: true,
},
});
if (!user) {
return NextResponse.json({ error: "Utilisateur non trouvé" }, { status: 404 });
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
return NextResponse.json(user);
@@ -47,4 +47,3 @@ export async function GET(
);
}
}

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,16 +1,25 @@
import NavigationWrapper from "@/components/NavigationWrapper";
import EventsPageSection from "@/components/EventsPageSection";
import { prisma } from "@/lib/prisma";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import EventsPageSection from "@/components/events/EventsPageSection";
import { eventService } from "@/services/events/event.service";
import { eventRegistrationService } from "@/services/events/event-registration.service";
import { getBackgroundImage } from "@/lib/preferences";
import { auth } from "@/lib/auth";
import { calculateEventStatus } from "@/lib/eventStatus";
export const dynamic = "force-dynamic";
export default async function EventsPage() {
const events = await prisma.event.findMany({
orderBy: {
date: "desc",
},
});
// Paralléliser les appels indépendants
const session = await auth();
const [events, backgroundImage, allRegistrations] = await Promise.all([
eventService.getAllEvents({
orderBy: { date: "desc" },
}),
getBackgroundImage("events", "/got-2.jpg"),
session?.user?.id
? eventRegistrationService.getUserRegistrations(session.user.id)
: Promise.resolve([]),
]);
// Sérialiser les dates pour le client
const serializedEvents = events.map((event) => ({
@@ -20,36 +29,11 @@ export default async function EventsPage() {
updatedAt: event.updatedAt.toISOString(),
}));
const backgroundImage = await getBackgroundImage("events", "/got-2.jpg");
// Récupérer les inscriptions côté serveur pour éviter le clignotement
const session = await auth();
// Construire le map des inscriptions
const initialRegistrations: Record<string, boolean> = {};
if (session?.user?.id) {
const upcomingEvents = events.filter(
(e) => calculateEventStatus(e.date) === "UPCOMING"
);
const eventIds = upcomingEvents.map((e) => e.id);
if (eventIds.length > 0) {
const registrations = await prisma.eventRegistration.findMany({
where: {
userId: session.user.id,
eventId: {
in: eventIds,
},
},
select: {
eventId: true,
},
});
registrations.forEach((reg) => {
allRegistrations.forEach((reg) => {
initialRegistrations[reg.eventId] = true;
});
}
}
return (
<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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -4,7 +4,15 @@ import { useState, type FormEvent } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Navigation from "@/components/Navigation";
import Navigation from "@/components/navigation/Navigation";
import {
Input,
Button,
Alert,
Card,
BackgroundSection,
SectionTitle,
} from "@/components/ui";
export default function LoginPage() {
const router = useRouter();
@@ -46,79 +54,53 @@ export default function LoginPage() {
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('/got-2.jpg')`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Login Form */}
<div className="relative z-10 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">
<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">
<div className="w-full max-w-md mx-auto px-8">
<Card variant="dark" className="p-8">
<SectionTitle
variant="gradient"
size="md"
className="mb-2 text-center"
>
CONNEXION
</span>
</h1>
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-8">
Connectez-vous à votre compte
</p>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}
<div>
<label
htmlFor="email"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Email
</label>
<input
<Input
id="email"
type="email"
label="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>
<label
htmlFor="password"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Mot de passe
</label>
<input
<Input
id="password"
type="password"
label="Mot de passe"
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"
variant="primary"
size="lg"
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"}
</button>
</Button>
</form>
<div className="mt-6 text-center">
@@ -132,9 +114,9 @@ export default function LoginPage() {
</Link>
</p>
</div>
</Card>
</div>
</div>
</section>
</BackgroundSection>
</main>
);
}

View File

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

View File

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

View File

@@ -3,7 +3,18 @@
import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
import { useRouter } from "next/navigation";
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() {
const router = useRouter();
@@ -161,25 +172,17 @@ export default function RegisterPage() {
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('/got-2.jpg')`,
}}
>
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
</div>
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
{/* Register Form */}
<div className="relative z-10 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">
<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">
<div className="w-full max-w-4xl mx-auto px-8">
<Card variant="dark" className="p-8">
<SectionTitle
variant="gradient"
size="lg"
className="mb-2 text-center"
>
INSCRIPTION
</span>
</h1>
</SectionTitle>
<p className="text-gray-400 text-sm text-center mb-4">
{step === 1
? "Créez votre compte pour commencer"
@@ -215,103 +218,65 @@ export default function RegisterPage() {
{step === 1 ? (
<form onSubmit={handleStep1Submit} className="space-y-6">
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}
<div>
<label
htmlFor="email"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Email
</label>
<input
<Input
id="email"
name="email"
type="email"
label="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>
<label
htmlFor="username"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Nom d&apos;utilisateur
</label>
<input
<Input
id="username"
name="username"
type="text"
label="Nom d'utilisateur"
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>
<label
htmlFor="password"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Mot de passe
</label>
<input
<Input
id="password"
name="password"
type="password"
label="Mot de passe"
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>
<label
htmlFor="confirmPassword"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Confirmer le mot de passe
</label>
<input
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
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"
variant="primary"
size="lg"
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"}
</button>
</Button>
</form>
) : (
<form onSubmit={handleStep2Submit} className="space-y-6">
{error && (
<div className="bg-red-900/50 border border-red-500/50 text-red-400 px-4 py-3 rounded text-sm">
{error}
</div>
)}
{error && <Alert variant="error">{error}</Alert>}
{/* Avatar Selection */}
<div>
@@ -321,23 +286,13 @@ export default function RegisterPage() {
<div className="flex flex-col items-center gap-4">
{/* Preview */}
<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">
{formData.avatar ? (
<img
<Avatar
src={formData.avatar}
alt="Avatar"
className="w-full h-full object-cover"
username={formData.username || "User"}
size="xl"
borderClassName="border-4 border-pixel-gold/50"
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 && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-full">
<div className="text-pixel-gold text-xs">
@@ -396,83 +351,54 @@ export default function RegisterPage() {
className="hidden"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
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"
<label htmlFor="avatar-upload">
<Button
variant="primary"
size="md"
as="span"
className="cursor-pointer"
>
{uploadingAvatar
? "Upload en cours..."
: "Upload un avatar custom"}
</Button>
</label>
</div>
</div>
</div>
<div>
<label
htmlFor="username-step2"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Nom d&apos;utilisateur
</label>
<input
<Input
id="username-step2"
name="username"
type="text"
label="Nom d'utilisateur"
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"
minLength={3}
maxLength={20}
/>
<p className="text-gray-500 text-xs mt-1">3-20 caractères</p>
</div>
<div>
<label
htmlFor="bio"
className="block text-sm font-semibold text-gray-300 mb-2 uppercase tracking-wider"
>
Bio (optionnel)
</label>
<textarea
<Textarea
id="bio"
name="bio"
label="Bio (optionnel)"
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}
showCharCount
placeholder="Parlez-nous de vous..."
/>
<p className="text-gray-500 text-xs mt-1">
{formData.bio.length}/500 caractères
</p>
</div>
<div>
<label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">
Classe de Personnage (optionnel)
</label>
<div className="grid grid-cols-2 gap-2">
{[
{ 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) => (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CHARACTER_CLASSES.map((cls) => (
<button
key={cls.value}
type="button"
@@ -485,16 +411,16 @@ export default function RegisterPage() {
: 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
? "border-pixel-gold bg-pixel-gold/20"
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50"
? "border-pixel-gold bg-pixel-gold/20 shadow-lg shadow-pixel-gold/30"
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50 hover:bg-black/60"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xl">{cls.icon}</span>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{cls.icon}</span>
<span
className={`font-bold text-xs uppercase tracking-wider ${
className={`font-bold text-sm uppercase tracking-wider ${
formData.characterClass === cls.value
? "text-pixel-gold"
: "text-white"
@@ -503,26 +429,33 @@ export default function RegisterPage() {
{cls.name}
</span>
</div>
<p className="text-xs text-gray-400 leading-tight">
{cls.desc}
</p>
</button>
))}
</div>
</div>
<div className="flex gap-4">
<button
<Button
type="button"
variant="secondary"
size="lg"
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
</button>
<button
</Button>
<Button
type="submit"
variant="primary"
size="lg"
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"}
</button>
</Button>
</div>
</form>
)}
@@ -538,9 +471,9 @@ export default function RegisterPage() {
</Link>
</p>
</div>
</Card>
</div>
</div>
</section>
</BackgroundSection>
</main>
);
}

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

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

View File

@@ -1,280 +0,0 @@
"use client";
import { useState } from "react";
import ImageSelector from "@/components/ImageSelector";
import UserManagement from "@/components/UserManagement";
import EventManagement from "@/components/EventManagement";
interface SitePreferences {
id: string;
homeBackground: string | null;
eventsBackground: string | null;
leaderboardBackground: string | null;
}
interface AdminPanelProps {
initialPreferences: SitePreferences;
}
type AdminSection = "preferences" | "users" | "events";
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>
</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>
)}
</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>
);
}

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