Compare commits

...

42 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
143 changed files with 20813 additions and 14094 deletions

30
.env
View File

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

@@ -20,5 +20,7 @@ jobs:
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }} PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
UPLOADS_PATH: ${{ vars.UPLOADS_PATH }} UPLOADS_PATH: ${{ vars.UPLOADS_PATH }}
POSTGRES_DATA_PATH: ${{ vars.POSTGRES_DATA_PATH }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: | run: |
docker compose up -d --build docker compose up -d --build

7
.gitignore vendored
View File

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

View File

@@ -19,10 +19,10 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV DATABASE_URL="file:/app/data/dev.db" # ARG pour DATABASE_URL au build (valeur factice par défaut, car prisma generate n'a pas besoin de vraie DB)
RUN pnpm prisma generate && \ ARG DATABASE_URL_BUILD="postgresql://user:pass@localhost:5432/db"
pnpm prisma migrate deploy && \ ENV DATABASE_URL=$DATABASE_URL_BUILD
pnpm prisma db push RUN pnpm prisma generate
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build RUN pnpm build
@@ -34,7 +34,7 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN apk add --no-cache python3 make g++ sqlite RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
@@ -47,19 +47,44 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/next.config.js ./next.config.js COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/prisma ./prisma # Copier le répertoire prisma complet (schema + migrations)
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules # Copier prisma.config.ts (nécessaire pour Prisma 7)
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
ENV DATABASE_URL="file:/app/data/dev.db" # Installer seulement les dépendances de production puis générer Prisma Client
# 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
# Nettoyer les dépendances de développement # Create uploads directories
RUN pnpm prune --prod RUN mkdir -p /app/public/uploads /app/public/uploads/backgrounds && \
chown -R nextjs:nodejs /app/public/uploads
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
# Create data directory for SQLite database and uploads directories echo 'set -e' >> /app/entrypoint.sh && \
RUN mkdir -p /app/data /app/public/uploads /app/public/uploads/backgrounds && \ echo 'mkdir -p /app/public/uploads' >> /app/entrypoint.sh && \
chown -R nextjs:nodejs /app/data /app/public/uploads 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 USER nextjs
@@ -67,4 +92,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["pnpm", "start"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -24,26 +24,48 @@ docker-compose logs -f
## Variables d'environnement ## Variables d'environnement
Créez un fichier `.env` à la racine du projet avec les variables suivantes : Créez un fichier `.env` à la racine du projet à partir du template `.env.example` :
```bash
cp .env.example .env
```
Puis modifiez les valeurs dans `.env` selon votre configuration :
```env ```env
# NextAuth Configuration
NEXTAUTH_SECRET=your-secret-key-here NEXTAUTH_SECRET=your-secret-key-here
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
DATABASE_URL=file:./prisma/dev.db
# 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 ## Volumes persistants
### Base de données ### Base de données PostgreSQL
La base de données SQLite est persistée via un volume Docker. Par défaut, elle est stockée dans `/Volumes/EXTERNAL_USB/sites/got-gaming/data`, mais vous pouvez la personnaliser avec la variable d'environnement `PRISMA_DATA_PATH`. 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. Les migrations Prisma sont appliquées automatiquement au démarrage du conteneur.
Pour appliquer manuellement les migrations : Pour appliquer manuellement les migrations :
```bash ```bash
docker-compose exec got-app node node_modules/.bin/prisma migrate deploy docker-compose exec got-app pnpm dlx prisma migrate deploy
``` ```
### Images uploadées ### Images uploadées

View File

@@ -41,5 +41,5 @@ pnpm start
- React 18 - React 18
- TypeScript - TypeScript
- Tailwind CSS - Tailwind CSS
- Prisma (SQLite) - Prisma (PostgreSQL)
- NextAuth.js - NextAuth.js

View File

@@ -166,3 +166,105 @@ export async function deleteChallenge(challengeId: string) {
}; };
} }
} }
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",
};
}
}

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

@@ -19,6 +19,14 @@ export async function updateSitePreferences(data: {
homeBackground?: string | null; homeBackground?: string | null;
eventsBackground?: string | null; eventsBackground?: string | null;
leaderboardBackground?: 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 { try {
await checkAdminAccess()(); await checkAdminAccess()();
@@ -27,12 +35,23 @@ export async function updateSitePreferences(data: {
homeBackground: data.homeBackground, homeBackground: data.homeBackground,
eventsBackground: data.eventsBackground, eventsBackground: data.eventsBackground,
leaderboardBackground: data.leaderboardBackground, 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("/admin");
revalidatePath("/"); revalidatePath("/");
revalidatePath("/events"); revalidatePath("/events");
revalidatePath("/leaderboard"); revalidatePath("/leaderboard");
revalidatePath("/challenges");
revalidatePath("/profile");
revalidatePath("/houses");
return { success: true, data: preferences }; return { success: true, data: preferences };
} catch (error) { } catch (error) {

View File

@@ -127,3 +127,5 @@ export async function cancelChallenge(challengeId: string) {
}; };
} }
} }

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,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,41 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
import { Role } from "@/prisma/generated/prisma/client";
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import AdminPanel from "@/components/admin/AdminPanel";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function AdminPage() { export default async function AdminPage() {
const session = await auth(); redirect("/admin/preferences");
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== Role.ADMIN) {
redirect("/");
}
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
const sitePreferences =
await sitePreferencesService.getOrCreateSitePreferences();
return (
<main className="min-h-screen bg-black relative">
{/* Background Image */}
<div
className="absolute 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 />
<AdminPanel initialPreferences={sitePreferences} />
</main>
);
} }

View File

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

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

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

View File

@@ -11,12 +11,8 @@ export async function GET() {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
} }
// Récupérer tous les défis (PENDING et ACCEPTED) pour l'admin // Récupérer tous les défis pour l'admin (PENDING, ACCEPTED, CANCELLED, COMPLETED, REJECTED)
const allChallenges = await challengeService.getAllChallenges(); const challenges = await challengeService.getAllChallenges();
// Filtrer pour ne garder que PENDING et ACCEPTED
const challenges = allChallenges.filter(
(c) => c.status === "PENDING" || c.status === "ACCEPTED"
);
return NextResponse.json(challenges); return NextResponse.json(challenges);
} catch (error) { } catch (error) {

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -27,3 +27,5 @@ export async function GET() {
); );
} }
} }

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

@@ -12,6 +12,9 @@ export async function GET() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
}); });
} }
@@ -19,6 +22,9 @@ export async function GET() {
homeBackground: sitePreferences.homeBackground, homeBackground: sitePreferences.homeBackground,
eventsBackground: sitePreferences.eventsBackground, eventsBackground: sitePreferences.eventsBackground,
leaderboardBackground: sitePreferences.leaderboardBackground, leaderboardBackground: sitePreferences.leaderboardBackground,
challengesBackground: sitePreferences.challengesBackground,
profileBackground: sitePreferences.profileBackground,
houseBackground: sitePreferences.houseBackground,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching preferences:", error); console.error("Error fetching preferences:", error);
@@ -27,6 +33,9 @@ export async function GET() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
}, },
{ status: 200 } { status: 200 }
); );

View File

@@ -39,3 +39,5 @@ export async function GET() {
); );
} }
} }

View File

@@ -3,6 +3,8 @@ import { auth } from "@/lib/auth";
import { getBackgroundImage } from "@/lib/preferences"; import { getBackgroundImage } from "@/lib/preferences";
import NavigationWrapper from "@/components/navigation/NavigationWrapper"; import NavigationWrapper from "@/components/navigation/NavigationWrapper";
import ChallengesSection from "@/components/challenges/ChallengesSection"; 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 const dynamic = "force-dynamic";
@@ -13,15 +15,41 @@ export default async function ChallengesPage() {
redirect("/login"); redirect("/login");
} }
const backgroundImage = await getBackgroundImage( const [challengesRaw, users, backgroundImage] = await Promise.all([
"home", challengeService.getUserChallenges(session.user.id),
"/got-background.jpg" 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 ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen bg-black relative">
<NavigationWrapper /> <NavigationWrapper />
<ChallengesSection backgroundImage={backgroundImage} /> <ChallengesSection
initialChallenges={challenges}
initialUsers={users}
backgroundImage={backgroundImage}
/>
</main> </main>
); );
} }

View File

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

View File

@@ -128,6 +128,9 @@ export default function FeedbackPageClient({
}); });
} }
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
// Rediriger après 2 secondes // Rediriger après 2 secondes
setTimeout(() => { setTimeout(() => {
router.push("/events"); router.push("/events");

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

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

View File

@@ -7,7 +7,11 @@ import { getBackgroundImage } from "@/lib/preferences";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Home() { export default async function Home() {
const events = await eventService.getUpcomingEvents(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 // Convert Date objects to strings for serialization
const serializedEvents = events.map((event) => ({ const serializedEvents = events.map((event) => ({
@@ -15,11 +19,8 @@ export default async function Home() {
date: event.date.toISOString(), date: event.date.toISOString(),
})); }));
// Récupérer l'image de fond côté serveur
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
return ( return (
<main className="min-h-screen bg-black relative"> <main className="min-h-screen relative" style={{ backgroundColor: "var(--background)" }}>
<NavigationWrapper /> <NavigationWrapper />
<HeroSection backgroundImage={backgroundImage} /> <HeroSection backgroundImage={backgroundImage} />
<EventsSection events={serializedEvents} /> <EventsSection events={serializedEvents} />

View File

@@ -12,31 +12,30 @@ export default async function ProfilePage() {
redirect("/login"); redirect("/login");
} }
const user = await userService.getUserById(session.user.id, { // Paralléliser les appels DB
id: true, const [user, backgroundImage] = await Promise.all([
email: true, userService.getUserById(session.user.id, {
username: true, id: true,
avatar: true, email: true,
bio: true, username: true,
characterClass: true, avatar: true,
hp: true, bio: true,
maxHp: true, characterClass: true,
xp: true, hp: true,
maxXp: true, maxHp: true,
level: true, xp: true,
score: true, maxXp: true,
createdAt: true, level: true,
}); score: true,
createdAt: true,
}),
getBackgroundImage("profile", "/got-background.jpg"),
]);
if (!user) { if (!user) {
redirect("/login"); redirect("/login");
} }
const backgroundImage = await getBackgroundImage(
"home",
"/got-background.jpg"
);
// Convert Date to string for the component // Convert Date to string for the component
const userProfile = { const userProfile = {
...user, ...user,

View File

@@ -6,6 +6,7 @@ import {
Button, Button,
Input, Input,
Textarea, Textarea,
Select,
Card, Card,
Badge, Badge,
Alert, Alert,
@@ -22,6 +23,7 @@ export default function StyleGuidePage() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [textareaValue, setTextareaValue] = useState(""); const [textareaValue, setTextareaValue] = useState("");
const [selectValue, setSelectValue] = useState("");
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
return ( return (
@@ -29,7 +31,7 @@ export default function StyleGuidePage() {
<Navigation /> <Navigation />
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24 pb-16"> <BackgroundSection backgroundImage="/got-2.jpg" className="pt-24 pb-16">
<div className="w-full max-w-6xl mx-auto px-8"> <div className="w-full max-w-6xl mx-auto px-8">
<SectionTitle variant="gradient" size="xl" className="mb-12"> <SectionTitle variant="gradient" size="xl" className="mb-16">
STYLE GUIDE STYLE GUIDE
</SectionTitle> </SectionTitle>
<p className="text-gray-400 text-center mb-12 max-w-3xl mx-auto"> <p className="text-gray-400 text-center mb-12 max-w-3xl mx-auto">
@@ -170,6 +172,74 @@ export default function StyleGuidePage() {
</div> </div>
</Card> </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 */} {/* Badges */}
<Card variant="dark" className="p-6 mb-8"> <Card variant="dark" className="p-6 mb-8">
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Badges</h2> <h2 className="text-2xl font-bold text-pixel-gold mb-6">Badges</h2>
@@ -187,6 +257,9 @@ export default function StyleGuidePage() {
<div> <div>
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3> <h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<Badge variant="default" size="xs">
Extra Small
</Badge>
<Badge variant="default" size="sm"> <Badge variant="default" size="sm">
Small Small
</Badge> </Badge>

View File

@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui";
const adminSections = [
{ id: "preferences", label: "Préférences UI", path: "/admin/preferences" },
{ id: "users", label: "Utilisateurs", path: "/admin/users" },
{ id: "events", label: "Événements", path: "/admin/events" },
{ id: "feedbacks", label: "Feedbacks", path: "/admin/feedbacks" },
{ id: "challenges", label: "Défis", path: "/admin/challenges" },
{ id: "houses", label: "Maisons", path: "/admin/houses" },
];
export default function AdminNavigation() {
const pathname = usePathname();
return (
<div className="flex gap-4 mb-8 justify-center flex-wrap">
{adminSections.map((section) => {
const isActive = pathname === section.path ||
(section.path === "/admin/preferences" && pathname === "/admin");
return (
<Button
key={section.id}
as={Link}
href={section.path}
variant={isActive ? "primary" : "secondary"}
size="md"
className={isActive ? "bg-pixel-gold/10" : ""}
>
{section.label}
</Button>
);
})}
</div>
);
}

View File

@@ -1,137 +0,0 @@
"use client";
import { useState } from "react";
import UserManagement from "@/components/admin/UserManagement";
import EventManagement from "@/components/admin/EventManagement";
import FeedbackManagement from "@/components/admin/FeedbackManagement";
import ChallengeManagement from "@/components/admin/ChallengeManagement";
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
import { Button, Card, SectionTitle } from "@/components/ui";
interface SitePreferences {
id: string;
homeBackground: string | null;
eventsBackground: string | null;
leaderboardBackground: string | null;
}
interface AdminPanelProps {
initialPreferences: SitePreferences;
}
type AdminSection =
| "preferences"
| "users"
| "events"
| "feedbacks"
| "challenges";
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
const [activeSection, setActiveSection] =
useState<AdminSection>("preferences");
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">
<SectionTitle variant="gradient" size="md" className="mb-8 text-center">
ADMIN
</SectionTitle>
{/* Navigation Tabs */}
<div className="flex gap-4 mb-8 justify-center flex-wrap">
<Button
onClick={() => setActiveSection("preferences")}
variant={activeSection === "preferences" ? "primary" : "secondary"}
size="md"
className={
activeSection === "preferences" ? "bg-pixel-gold/10" : ""
}
>
Préférences UI
</Button>
<Button
onClick={() => setActiveSection("users")}
variant={activeSection === "users" ? "primary" : "secondary"}
size="md"
className={activeSection === "users" ? "bg-pixel-gold/10" : ""}
>
Utilisateurs
</Button>
<Button
onClick={() => setActiveSection("events")}
variant={activeSection === "events" ? "primary" : "secondary"}
size="md"
className={activeSection === "events" ? "bg-pixel-gold/10" : ""}
>
Événements
</Button>
<Button
onClick={() => setActiveSection("feedbacks")}
variant={activeSection === "feedbacks" ? "primary" : "secondary"}
size="md"
className={activeSection === "feedbacks" ? "bg-pixel-gold/10" : ""}
>
Feedbacks
</Button>
<Button
onClick={() => setActiveSection("challenges")}
variant={activeSection === "challenges" ? "primary" : "secondary"}
size="md"
className={activeSection === "challenges" ? "bg-pixel-gold/10" : ""}
>
Défis
</Button>
</div>
{activeSection === "preferences" && (
<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={initialPreferences} />
</div>
</Card>
)}
{activeSection === "users" && (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Utilisateurs
</h2>
<UserManagement />
</Card>
)}
{activeSection === "events" && (
<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 />
</Card>
)}
{activeSection === "feedbacks" && (
<Card variant="dark" className="p-6">
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
Gestion des Feedbacks
</h2>
<FeedbackManagement />
</Card>
)}
{activeSection === "challenges" && (
<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 />
</Card>
)}
</div>
</section>
);
}

View File

@@ -10,6 +10,10 @@ interface SitePreferences {
homeBackground: string | null; homeBackground: string | null;
eventsBackground: string | null; eventsBackground: string | null;
leaderboardBackground: string | null; leaderboardBackground: string | null;
challengesBackground: string | null;
profileBackground: string | null;
houseBackground: string | null;
eventRegistrationPoints?: number;
} }
interface BackgroundPreferencesProps { interface BackgroundPreferencesProps {
@@ -20,6 +24,9 @@ const DEFAULT_IMAGES = {
home: "/got-2.jpg", home: "/got-2.jpg",
events: "/got-2.jpg", events: "/got-2.jpg",
leaderboard: "/leaderboard-bg.jpg", leaderboard: "/leaderboard-bg.jpg",
challenges: "/got-2.jpg",
profile: "/got-background.jpg",
houses: "/got-2.jpg",
}; };
export default function BackgroundPreferences({ export default function BackgroundPreferences({
@@ -57,6 +64,18 @@ export default function BackgroundPreferences({
initialPreferences.leaderboardBackground, initialPreferences.leaderboardBackground,
DEFAULT_IMAGES.leaderboard DEFAULT_IMAGES.leaderboard
), ),
challengesBackground: getFormValue(
initialPreferences.challengesBackground,
DEFAULT_IMAGES.challenges
),
profileBackground: getFormValue(
initialPreferences.profileBackground,
DEFAULT_IMAGES.profile
),
houseBackground: getFormValue(
initialPreferences.houseBackground,
DEFAULT_IMAGES.houses
),
}), }),
[initialPreferences] [initialPreferences]
); );
@@ -90,6 +109,18 @@ export default function BackgroundPreferences({
formData.leaderboardBackground, formData.leaderboardBackground,
DEFAULT_IMAGES.leaderboard DEFAULT_IMAGES.leaderboard
), ),
challengesBackground: getApiValue(
formData.challengesBackground,
DEFAULT_IMAGES.challenges
),
profileBackground: getApiValue(
formData.profileBackground,
DEFAULT_IMAGES.profile
),
houseBackground: getApiValue(
formData.houseBackground,
DEFAULT_IMAGES.houses
),
}; };
const result = await updateSitePreferences(apiData); const result = await updateSitePreferences(apiData);
@@ -110,6 +141,18 @@ export default function BackgroundPreferences({
result.data.leaderboardBackground, result.data.leaderboardBackground,
DEFAULT_IMAGES.leaderboard DEFAULT_IMAGES.leaderboard
), ),
challengesBackground: getFormValue(
result.data.challengesBackground,
DEFAULT_IMAGES.challenges
),
profileBackground: getFormValue(
result.data.profileBackground,
DEFAULT_IMAGES.profile
),
houseBackground: getFormValue(
result.data.houseBackground,
DEFAULT_IMAGES.houses
),
}); });
setIsEditing(false); setIsEditing(false);
} else { } else {
@@ -138,6 +181,18 @@ export default function BackgroundPreferences({
preferences.leaderboardBackground, preferences.leaderboardBackground,
DEFAULT_IMAGES.leaderboard DEFAULT_IMAGES.leaderboard
), ),
challengesBackground: getFormValue(
preferences.challengesBackground,
DEFAULT_IMAGES.challenges
),
profileBackground: getFormValue(
preferences.profileBackground,
DEFAULT_IMAGES.profile
),
houseBackground: getFormValue(
preferences.houseBackground,
DEFAULT_IMAGES.houses
),
}); });
} }
}; };
@@ -197,6 +252,36 @@ export default function BackgroundPreferences({
} }
label="Background Leaderboard" label="Background Leaderboard"
/> />
<ImageSelector
value={formData.challengesBackground}
onChange={(url) =>
setFormData({
...formData,
challengesBackground: url,
})
}
label="Background Challenges"
/>
<ImageSelector
value={formData.profileBackground}
onChange={(url) =>
setFormData({
...formData,
profileBackground: url,
})
}
label="Background Profile"
/>
<ImageSelector
value={formData.houseBackground}
onChange={(url) =>
setFormData({
...formData,
houseBackground: url,
})
}
label="Background Houses"
/>
<div className="flex flex-col sm:flex-row gap-2 pt-4"> <div className="flex flex-col sm:flex-row gap-2 pt-4">
<Button onClick={handleSave} variant="success" size="md"> <Button onClick={handleSave} variant="success" size="md">
Enregistrer Enregistrer
@@ -376,6 +461,174 @@ export default function BackgroundPreferences({
); );
})()} })()}
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
Challenges:
</span>
{(() => {
const currentImage =
preferences?.challengesBackground &&
preferences.challengesBackground.trim() !== ""
? preferences.challengesBackground
: DEFAULT_IMAGES.challenges;
const isDefault =
!preferences?.challengesBackground ||
preferences.challengesBackground.trim() === "";
return (
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
<img
src={currentImage}
alt="Challenges background"
className="w-full h-full object-cover"
onError={(e) => {
const target = e.currentTarget;
const currentSrc = target.src;
const fallbackSrc = "/got-2.jpg";
if (!currentSrc.includes(fallbackSrc)) {
target.src = fallbackSrc;
} else {
target.style.display = "none";
const fallbackDiv =
target.nextElementSibling as HTMLElement;
if (fallbackDiv) {
fallbackDiv.classList.remove("hidden");
}
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
No image
</div>
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs text-gray-400 truncate min-w-0">
{isDefault ? "Par défaut: " : ""}
{currentImage}
</span>
{isDefault && (
<span className="text-[10px] text-gray-500 italic">
(Image par défaut)
</span>
)}
</div>
</div>
);
})()}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
Profile:
</span>
{(() => {
const currentImage =
preferences?.profileBackground &&
preferences.profileBackground.trim() !== ""
? preferences.profileBackground
: DEFAULT_IMAGES.profile;
const isDefault =
!preferences?.profileBackground ||
preferences.profileBackground.trim() === "";
return (
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
<img
src={currentImage}
alt="Profile background"
className="w-full h-full object-cover"
onError={(e) => {
const target = e.currentTarget;
const currentSrc = target.src;
const fallbackSrc = "/got-background.jpg";
if (!currentSrc.includes(fallbackSrc)) {
target.src = fallbackSrc;
} else {
target.style.display = "none";
const fallbackDiv =
target.nextElementSibling as HTMLElement;
if (fallbackDiv) {
fallbackDiv.classList.remove("hidden");
}
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
No image
</div>
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs text-gray-400 truncate min-w-0">
{isDefault ? "Par défaut: " : ""}
{currentImage}
</span>
{isDefault && (
<span className="text-[10px] text-gray-500 italic">
(Image par défaut)
</span>
)}
</div>
</div>
);
})()}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
Houses:
</span>
{(() => {
const currentImage =
preferences?.houseBackground &&
preferences.houseBackground.trim() !== ""
? preferences.houseBackground
: DEFAULT_IMAGES.houses;
const isDefault =
!preferences?.houseBackground ||
preferences.houseBackground.trim() === "";
return (
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
<img
src={currentImage}
alt="Houses background"
className="w-full h-full object-cover"
onError={(e) => {
const target = e.currentTarget;
const currentSrc = target.src;
const fallbackSrc = "/got-2.jpg";
if (!currentSrc.includes(fallbackSrc)) {
target.src = fallbackSrc;
} else {
target.style.display = "none";
const fallbackDiv =
target.nextElementSibling as HTMLElement;
if (fallbackDiv) {
fallbackDiv.classList.remove("hidden");
}
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
No image
</div>
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs text-gray-400 truncate min-w-0">
{isDefault ? "Par défaut: " : ""}
{currentImage}
</span>
{isDefault && (
<span className="text-[10px] text-gray-500 italic">
(Image par défaut)
</span>
)}
</div>
</div>
);
})()}
</div>
</div> </div>
)} )}
</Card> </Card>

View File

@@ -1,13 +1,24 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { import {
validateChallenge, validateChallenge,
rejectChallenge, rejectChallenge,
updateChallenge, updateChallenge,
deleteChallenge, deleteChallenge,
adminCancelChallenge,
reactivateChallenge,
adminAcceptChallenge,
} from "@/actions/admin/challenges"; } from "@/actions/admin/challenges";
import { Button, Card, Input, Textarea, Alert } from "@/components/ui"; import {
Button,
Card,
Input,
Textarea,
Alert,
Modal,
CloseButton,
} from "@/components/ui";
import { Avatar } from "@/components/ui"; import { Avatar } from "@/components/ui";
interface Challenge { interface Challenge {
@@ -31,9 +42,12 @@ interface Challenge {
acceptedAt: string | null; acceptedAt: string | null;
} }
export default function ChallengeManagement() { interface ChallengeManagementProps {
const [challenges, setChallenges] = useState<Challenge[]>([]); initialChallenges: Challenge[];
const [loading, setLoading] = useState(true); }
export default function ChallengeManagement({ initialChallenges }: ChallengeManagementProps) {
const [challenges, setChallenges] = useState<Challenge[]>(initialChallenges);
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>( const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(
null null
); );
@@ -49,10 +63,6 @@ export default function ChallengeManagement() {
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
fetchChallenges();
}, []);
const fetchChallenges = async () => { const fetchChallenges = async () => {
try { try {
const response = await fetch("/api/admin/challenges"); const response = await fetch("/api/admin/challenges");
@@ -62,8 +72,6 @@ export default function ChallengeManagement() {
} }
} catch (error) { } catch (error) {
console.error("Error fetching challenges:", error); console.error("Error fetching challenges:", error);
} finally {
setLoading(false);
} }
}; };
@@ -89,6 +97,8 @@ export default function ChallengeManagement() {
setWinnerId(""); setWinnerId("");
setAdminComment(""); setAdminComment("");
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors de la validation"); setErrorMessage(result.error || "Erreur lors de la validation");
@@ -115,6 +125,8 @@ export default function ChallengeManagement() {
setSelectedChallenge(null); setSelectedChallenge(null);
setAdminComment(""); setAdminComment("");
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors du rejet"); setErrorMessage(result.error || "Erreur lors du rejet");
@@ -170,6 +182,8 @@ export default function ChallengeManagement() {
if (result.success) { if (result.success) {
setSuccessMessage("Défi supprimé avec succès"); setSuccessMessage("Défi supprimé avec succès");
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors de la suppression"); setErrorMessage(result.error || "Erreur lors de la suppression");
@@ -178,22 +192,86 @@ export default function ChallengeManagement() {
}); });
}; };
if (loading) { const handleCancel = async (challengeId: string) => {
return ( if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
<div className="text-center text-pixel-gold py-8">Chargement...</div> return;
); }
}
startTransition(async () => {
const result = await adminCancelChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi annulé avec succès");
fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(result.error || "Erreur lors de l'annulation");
setTimeout(() => setErrorMessage(null), 5000);
}
});
};
const handleReactivate = async (challengeId: string) => {
if (!confirm("Êtes-vous sûr de vouloir réactiver ce défi ?")) {
return;
}
startTransition(async () => {
const result = await reactivateChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi réactivé avec succès");
fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(result.error || "Erreur lors de la réactivation");
setTimeout(() => setErrorMessage(null), 5000);
}
});
};
const handleAdminAccept = async (challengeId: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir accepter ce défi à la place de l'utilisateur ?"
)
) {
return;
}
startTransition(async () => {
const result = await adminAcceptChallenge(challengeId);
if (result.success) {
setSuccessMessage("Défi accepté avec succès");
fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000);
} else {
setErrorMessage(result.error || "Erreur lors de l'acceptation");
setTimeout(() => setErrorMessage(null), 5000);
}
});
};
if (challenges.length === 0) { if (challenges.length === 0) {
return ( return <div className="text-center text-gray-400 py-8">Aucun défi</div>;
<div className="text-center text-gray-400 py-8">
Aucun défi en attente
</div>
);
} }
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED"); const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
const pendingChallenges = challenges.filter((c) => c.status === "PENDING"); const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
const cancelledChallenges = challenges.filter(
(c) => c.status === "CANCELLED"
);
const completedChallenges = challenges.filter(
(c) => c.status === "COMPLETED"
);
const rejectedChallenges = challenges.filter((c) => c.status === "REJECTED");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -208,12 +286,39 @@ export default function ChallengeManagement() {
</Alert> </Alert>
)} )}
<div className="text-sm text-gray-400 mb-4"> <div className="text-sm text-gray-400 mb-4">
{acceptedChallenges.length} défi {acceptedChallenges.length > 0 && (
{acceptedChallenges.length > 1 ? "s" : ""} en attente de validation <span>
{acceptedChallenges.length} défi
{acceptedChallenges.length > 1 ? "s" : ""} en attente de désignation
du gagnant
</span>
)}
{pendingChallenges.length > 0 && ( {pendingChallenges.length > 0 && (
<span className="ml-2"> <span className={acceptedChallenges.length > 0 ? "ml-2" : ""}>
{pendingChallenges.length} défi {pendingChallenges.length} défi
{pendingChallenges.length > 1 ? "s" : ""} en attente d'acceptation {pendingChallenges.length > 1 ? "s" : ""} en attente
d&apos;acceptation
</span>
)}
{cancelledChallenges.length > 0 && (
<span className="ml-2">
{cancelledChallenges.length} défi
{cancelledChallenges.length > 1 ? "s" : ""} annulé
{cancelledChallenges.length > 1 ? "s" : ""}
</span>
)}
{completedChallenges.length > 0 && (
<span className="ml-2">
{completedChallenges.length} défi
{completedChallenges.length > 1 ? "s" : ""} complété
{completedChallenges.length > 1 ? "s" : ""}
</span>
)}
{rejectedChallenges.length > 0 && (
<span className="ml-2">
{rejectedChallenges.length} défi
{rejectedChallenges.length > 1 ? "s" : ""} rejeté
{rejectedChallenges.length > 1 ? "s" : ""}
</span> </span>
)} )}
</div> </div>
@@ -259,13 +364,27 @@ export default function ChallengeManagement() {
<span <span
className={`px-2 py-1 rounded ${ className={`px-2 py-1 rounded ${
challenge.status === "ACCEPTED" challenge.status === "ACCEPTED"
? "bg-green-500/20 text-green-400" ? "bg-blue-500/20 text-blue-400"
: "bg-yellow-500/20 text-yellow-400" : challenge.status === "COMPLETED"
? "bg-green-500/20 text-green-400"
: challenge.status === "CANCELLED"
? "bg-gray-500/20 text-gray-400"
: challenge.status === "REJECTED"
? "bg-red-500/20 text-red-400"
: "bg-yellow-500/20 text-yellow-400"
}`} }`}
> >
{challenge.status === "ACCEPTED" {challenge.status === "PENDING"
? "Accepté" ? "En attente d'acceptation"
: "En attente d'acceptation"} : challenge.status === "ACCEPTED"
? "En cours - En attente de désignation du gagnant"
: challenge.status === "COMPLETED"
? "Complété"
: challenge.status === "CANCELLED"
? "Annulé"
: challenge.status === "REJECTED"
? "Rejeté"
: challenge.status}
</span> </span>
</div> </div>
{challenge.acceptedAt && ( {challenge.acceptedAt && (
@@ -284,13 +403,44 @@ export default function ChallengeManagement() {
> >
Modifier Modifier
</Button> </Button>
{challenge.status === "PENDING" && (
<Button
onClick={() => handleAdminAccept(challenge.id)}
variant="primary"
size="sm"
disabled={isPending}
>
Accepter le défi
</Button>
)}
{challenge.status === "ACCEPTED" && ( {challenge.status === "ACCEPTED" && (
<Button <Button
onClick={() => setSelectedChallenge(challenge)} onClick={() => setSelectedChallenge(challenge)}
variant="primary" variant="primary"
size="sm" size="sm"
> >
Valider/Rejeter Désigner le gagnant
</Button>
)}
{challenge.status !== "CANCELLED" &&
challenge.status !== "COMPLETED" && (
<Button
onClick={() => handleCancel(challenge.id)}
variant="secondary"
size="sm"
disabled={isPending}
>
Annuler
</Button>
)}
{challenge.status === "CANCELLED" && (
<Button
onClick={() => handleReactivate(challenge.id)}
variant="primary"
size="sm"
disabled={isPending}
>
Réactiver
</Button> </Button>
)} )}
<Button <Button
@@ -312,126 +462,224 @@ export default function ChallengeManagement() {
{/* Modal de validation */} {/* Modal de validation */}
{selectedChallenge && ( {selectedChallenge && (
<div <Modal
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" isOpen={!!selectedChallenge}
onClick={() => { onClose={() => {
setSelectedChallenge(null); setSelectedChallenge(null);
setWinnerId(""); setWinnerId("");
setAdminComment(""); setAdminComment("");
}} }}
size="lg"
> >
<Card <div className="p-6">
variant="dark" <div className="flex items-center justify-between mb-4">
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto" <h2 className="text-2xl font-bold text-pixel-gold">
onClick={(e) => e.stopPropagation()} Désigner le gagnant
>
<div className="p-6">
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
Valider/Rejeter le défi
</h2> </h2>
<CloseButton
onClick={() => {
setSelectedChallenge(null);
setWinnerId("");
setAdminComment("");
}}
size="lg"
/>
</div>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-gray-300 mb-2"> <h3 className="text-lg font-bold text-gray-300 mb-2">
{selectedChallenge.title} {selectedChallenge.title}
</h3> </h3>
<p className="text-gray-400 mb-4"> <p className="text-gray-400 mb-4">
{selectedChallenge.description} {selectedChallenge.description}
</p> </p>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar <Avatar
src={selectedChallenge.challenger.avatar} src={selectedChallenge.challenger.avatar}
username={selectedChallenge.challenger.username} username={selectedChallenge.challenger.username}
size="md" size="md"
/> />
<span className="text-gray-300"> <span className="text-gray-300">
{selectedChallenge.challenger.username} {selectedChallenge.challenger.username}
</span> </span>
</div> </div>
<span className="text-gray-500">VS</span> <span className="text-gray-500">VS</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar <Avatar
src={selectedChallenge.challenged.avatar} src={selectedChallenge.challenged.avatar}
username={selectedChallenge.challenged.username} username={selectedChallenge.challenged.username}
size="md" size="md"
/> />
<span className="text-gray-300"> <span className="text-gray-300">
{selectedChallenge.challenged.username} {selectedChallenge.challenged.username}
</span> </span>
</div>
</div> </div>
</div> </div>
</div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2"> <label className="block text-sm font-bold text-pixel-gold mb-2">
Sélectionner le gagnant Sélectionner le gagnant
</label> </label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenger.id}
checked={winnerId === selectedChallenge.challenger.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenger.username}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenged.id}
checked={winnerId === selectedChallenge.challenged.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenged.username}
</span>
</label>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2">
Commentaire (optionnel)
</label>
<textarea
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
rows={3}
placeholder="Commentaire pour les joueurs..."
/>
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenger.id}
checked={winnerId === selectedChallenge.challenger.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenger.username}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="winner"
value={selectedChallenge.challenged.id}
checked={winnerId === selectedChallenge.challenged.id}
onChange={(e) => setWinnerId(e.target.value)}
className="w-4 h-4"
/>
<span className="text-gray-300">
{selectedChallenge.challenged.username}
</span>
</label>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-bold text-pixel-gold mb-2">
Commentaire (optionnel)
</label>
<textarea
value={adminComment}
onChange={(e) => setAdminComment(e.target.value)}
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
rows={3}
placeholder="Commentaire pour les joueurs..."
/>
</div>
<div className="flex gap-4">
<Button
onClick={handleValidate}
variant="primary"
disabled={!winnerId || isPending}
className="flex-1"
>
{isPending ? "Enregistrement..." : "Confirmer le gagnant"}
</Button>
<Button
onClick={handleReject}
variant="secondary"
disabled={isPending}
className="flex-1"
>
{isPending ? "Rejet..." : "Rejeter le défi"}
</Button>
<Button
onClick={() => {
setSelectedChallenge(null);
setWinnerId("");
setAdminComment("");
}}
variant="secondary"
disabled={isPending}
>
Annuler
</Button>
</div>
</div>
</Modal>
)}
{/* Modal d'édition */}
{editingChallenge && (
<Modal
isOpen={!!editingChallenge}
onClose={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-pixel-gold">
Modifier le défi
</h2>
<CloseButton
onClick={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
size="lg"
/>
</div>
<div className="space-y-4">
<Input
id="edit-title"
label="Titre"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
placeholder="Titre du défi"
/>
<Textarea
id="edit-description"
label="Description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
required
rows={4}
placeholder="Description du défi"
/>
<Input
id="edit-points"
label="Récompense (points)"
type="number"
min="1"
value={editPointsReward}
onChange={(e) =>
setEditPointsReward(parseInt(e.target.value) || 0)
}
required
placeholder="100"
/>
<div className="flex gap-4 pt-4">
<Button <Button
onClick={handleValidate} onClick={handleUpdate}
variant="primary" variant="primary"
disabled={!winnerId || isPending} disabled={
isPending ||
!editTitle ||
!editDescription ||
editPointsReward <= 0
}
className="flex-1" className="flex-1"
> >
{isPending ? "Validation..." : "Valider le défi"} {isPending ? "Mise à jour..." : "Enregistrer"}
</Button>
<Button
onClick={handleReject}
variant="secondary"
disabled={isPending}
className="flex-1"
>
{isPending ? "Rejet..." : "Rejeter le défi"}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setSelectedChallenge(null); setEditingChallenge(null);
setWinnerId(""); setEditTitle("");
setAdminComment(""); setEditDescription("");
setEditPointsReward(0);
}} }}
variant="secondary" variant="secondary"
disabled={isPending} disabled={isPending}
@@ -440,95 +688,8 @@ export default function ChallengeManagement() {
</Button> </Button>
</div> </div>
</div> </div>
</Card> </div>
</div> </Modal>
)}
{/* Modal d'édition */}
{editingChallenge && (
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
>
<Card
variant="dark"
className="max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold text-pixel-gold mb-4">
Modifier le défi
</h2>
<div className="space-y-4">
<Input
id="edit-title"
label="Titre"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
placeholder="Titre du défi"
/>
<Textarea
id="edit-description"
label="Description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
required
rows={4}
placeholder="Description du défi"
/>
<Input
id="edit-points"
label="Récompense (points)"
type="number"
min="1"
value={editPointsReward}
onChange={(e) =>
setEditPointsReward(parseInt(e.target.value) || 0)
}
required
placeholder="100"
/>
<div className="flex gap-4 pt-4">
<Button
onClick={handleUpdate}
variant="primary"
disabled={
isPending ||
!editTitle ||
!editDescription ||
editPointsReward <= 0
}
className="flex-1"
>
{isPending ? "Mise à jour..." : "Enregistrer"}
</Button>
<Button
onClick={() => {
setEditingChallenge(null);
setEditTitle("");
setEditDescription("");
setEditPointsReward(0);
}}
variant="secondary"
disabled={isPending}
>
Annuler
</Button>
</div>
</div>
</div>
</Card>
</div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
import { updateSitePreferences } from "@/actions/admin/preferences";
import { Button, Card, Input } from "@/components/ui";
interface SitePreferences {
id: string;
eventFeedbackPoints: number;
}
interface EventFeedbackPointsPreferencesProps {
initialPreferences: SitePreferences;
}
export default function EventFeedbackPointsPreferences({
initialPreferences,
}: EventFeedbackPointsPreferencesProps) {
const [preferences, setPreferences] = useState<SitePreferences | null>(
initialPreferences
);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
eventFeedbackPoints: initialPreferences.eventFeedbackPoints.toString(),
});
const [isSaving, setIsSaving] = useState(false);
// Synchroniser les préférences quand initialPreferences change
useEffect(() => {
setPreferences(initialPreferences);
setFormData({
eventFeedbackPoints: initialPreferences.eventFeedbackPoints.toString(),
});
}, [initialPreferences]);
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
const points = parseInt(formData.eventFeedbackPoints, 10);
if (isNaN(points) || points < 0) {
alert("Le nombre de points doit être un nombre positif");
return;
}
setIsSaving(true);
try {
const result = await updateSitePreferences({
eventFeedbackPoints: points,
});
if (result.success && result.data) {
setPreferences(result.data);
setFormData({
eventFeedbackPoints: result.data.eventFeedbackPoints.toString(),
});
setIsEditing(false);
} else {
console.error("Error updating preferences:", result.error);
alert(result.error || "Erreur lors de la mise à jour");
}
} catch (error) {
console.error("Error updating preferences:", error);
alert("Erreur lors de la mise à jour");
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
eventFeedbackPoints: preferences.eventFeedbackPoints.toString(),
});
}
};
return (
<Card variant="default" className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
<div className="min-w-0 flex-1">
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Points de feedback sur les événements
</h3>
<p className="text-gray-400 text-xs sm:text-sm">
Nombre de points attribués lorsqu&apos;un utilisateur donne un feedback à un événement (première fois uniquement)
</p>
</div>
{!isEditing && (
<Button
onClick={handleEdit}
variant="primary"
size="sm"
className="whitespace-nowrap flex-shrink-0"
>
Modifier
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-4">
<div>
<label
htmlFor="eventFeedbackPoints"
className="block text-sm font-medium text-pixel-gold mb-2"
>
Points de feedback
</label>
<Input
id="eventFeedbackPoints"
type="number"
min="0"
value={formData.eventFeedbackPoints}
onChange={(e) =>
setFormData({
...formData,
eventFeedbackPoints: e.target.value,
})
}
placeholder="100"
className="w-full"
/>
<p className="text-xs text-gray-400 mt-1">
Les utilisateurs gagneront ce nombre de points lors de leur premier feedback sur un événement
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-4">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={isSaving}
>
{isSaving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button
onClick={handleCancel}
variant="secondary"
size="md"
disabled={isSaving}
>
Annuler
</Button>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
Points actuels:
</span>
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl font-bold text-white">
{preferences?.eventFeedbackPoints ?? 100}
</span>
<span className="text-xs sm:text-sm text-gray-400">points</span>
</div>
</div>
)}
</Card>
);
}

View File

@@ -1,9 +1,19 @@
"use client"; "use client";
import { useState, useEffect, useTransition } from "react"; import { useState, useTransition } from "react";
import { calculateEventStatus } from "@/lib/eventStatus"; import { calculateEventStatus } from "@/lib/eventStatus";
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events"; import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
import { Input, Textarea, Button, Card, Badge } from "@/components/ui"; import {
Input,
Textarea,
Button,
Card,
Badge,
Modal,
CloseButton,
Avatar,
} from "@/components/ui";
import { updateUser } from "@/actions/admin/users";
interface Event { interface Event {
id: string; id: string;
@@ -20,6 +30,24 @@ interface Event {
registrationsCount?: number; registrationsCount?: number;
} }
interface EventRegistration {
id: string;
userId: string;
eventId: string;
createdAt: string;
user: {
id: string;
username: string;
avatar: string | null;
score: number;
level: number;
hp: number;
maxHp: number;
xp: number;
maxXp: number;
};
}
interface EventFormData { interface EventFormData {
date: string; date: string;
name: string; name: string;
@@ -64,12 +92,23 @@ const getStatusLabel = (status: Event["status"]) => {
} }
}; };
export default function EventManagement() { interface EventManagementProps {
const [events, setEvents] = useState<Event[]>([]); initialEvents: Event[];
const [loading, setLoading] = useState(true); }
export default function EventManagement({ initialEvents }: EventManagementProps) {
const [events, setEvents] = useState<Event[]>(initialEvents);
const [editingEvent, setEditingEvent] = useState<Event | null>(null); const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [viewingRegistrations, setViewingRegistrations] =
useState<Event | null>(null);
const [registrations, setRegistrations] = useState<EventRegistration[]>([]);
const [loadingRegistrations, setLoadingRegistrations] = useState(false);
const [editingScores, setEditingScores] = useState<Record<string, number>>(
{}
);
const [savingScore, setSavingScore] = useState<string | null>(null);
const [formData, setFormData] = useState<EventFormData>({ const [formData, setFormData] = useState<EventFormData>({
date: "", date: "",
name: "", name: "",
@@ -80,10 +119,6 @@ export default function EventManagement() {
maxPlaces: undefined, maxPlaces: undefined,
}); });
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => { const fetchEvents = async () => {
try { try {
const response = await fetch("/api/admin/events"); const response = await fetch("/api/admin/events");
@@ -93,8 +128,6 @@ export default function EventManagement() {
} }
} catch (error) { } catch (error) {
console.error("Error fetching events:", error); console.error("Error fetching events:", error);
} finally {
setLoading(false);
} }
}; };
@@ -115,8 +148,10 @@ export default function EventManagement() {
const handleEdit = (event: Event) => { const handleEdit = (event: Event) => {
setEditingEvent(event); setEditingEvent(event);
setIsCreating(false); setIsCreating(false);
// Convertir la date ISO en format YYYY-MM-DD pour l'input date
const dateValue = event.date ? new Date(event.date).toISOString().split('T')[0] : "";
setFormData({ setFormData({
date: event.date, date: dateValue,
name: event.name, name: event.name,
description: event.description, description: event.description,
type: event.type, type: event.type,
@@ -199,9 +234,74 @@ export default function EventManagement() {
}); });
}; };
if (loading) { const handleViewRegistrations = async (event: Event) => {
return <div className="text-center text-gray-400 py-8">Chargement...</div>; setViewingRegistrations(event);
} setLoadingRegistrations(true);
try {
const response = await fetch(
`/api/admin/events/${event.id}/registrations`
);
if (response.ok) {
const data = await response.json();
setRegistrations(data);
// Initialiser les scores d'édition avec les scores actuels
const scoresMap: Record<string, number> = {};
data.forEach((reg: EventRegistration) => {
scoresMap[reg.user.id] = reg.user.score;
});
setEditingScores(scoresMap);
} else {
alert("Erreur lors de la récupération des inscrits");
}
} catch (error) {
console.error("Error fetching registrations:", error);
alert("Erreur lors de la récupération des inscrits");
} finally {
setLoadingRegistrations(false);
}
};
const handleCloseRegistrations = () => {
setViewingRegistrations(null);
setRegistrations([]);
setEditingScores({});
};
const handleScoreChange = (userId: string, newScore: number) => {
setEditingScores({
...editingScores,
[userId]: newScore,
});
};
const handleSaveScore = async (userId: string) => {
const newScore = editingScores[userId];
if (newScore === undefined) return;
setSavingScore(userId);
startTransition(async () => {
try {
const result = await updateUser(userId, { score: newScore });
if (result.success) {
// Mettre à jour le score dans la liste locale
setRegistrations((prev) =>
prev.map((reg) =>
reg.user.id === userId
? { ...reg, user: { ...reg.user, score: newScore } }
: reg
)
);
} else {
alert(result.error || "Erreur lors de la mise à jour du score");
}
} catch (error) {
console.error("Error updating score:", error);
alert("Erreur lors de la mise à jour du score");
} finally {
setSavingScore(null);
}
});
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -221,116 +321,126 @@ export default function EventManagement() {
)} )}
</div> </div>
{/* Modal de création/édition */}
{(isCreating || editingEvent) && ( {(isCreating || editingEvent) && (
<Card variant="default" className="p-3 sm:p-4 mb-4"> <Modal
<h4 className="text-pixel-gold font-bold mb-4 text-base sm:text-lg break-words"> isOpen={isCreating || !!editingEvent}
{isCreating ? "Créer un événement" : "Modifier l'événement"} onClose={handleCancel}
</h4> size="lg"
<div className="space-y-4"> >
<Input <div className="p-6">
type="date" <div className="flex items-center justify-between mb-4">
label="Date" <h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
value={formData.date} {isCreating ? "Créer un événement" : "Modifier l'événement"}
onChange={(e) => </h4>
setFormData({ ...formData, date: e.target.value }) <CloseButton onClick={handleCancel} size="lg" />
} </div>
className="text-xs sm:text-sm px-3 py-2" <div className="space-y-4">
/> <Input
<Input type="date"
type="text" label="Date"
label="Nom" value={formData.date}
value={formData.name} onChange={(e) =>
onChange={(e) => setFormData({ ...formData, date: e.target.value })
setFormData({ ...formData, name: e.target.value }) }
} className="text-xs sm:text-sm px-3 py-2"
placeholder="Nom de l'événement" />
className="text-xs sm:text-sm px-3 py-2" <Input
/> type="text"
<Textarea label="Nom"
label="Description" value={formData.name}
value={formData.description} onChange={(e) =>
onChange={(e) => setFormData({ ...formData, name: e.target.value })
setFormData({ ...formData, description: e.target.value }) }
} placeholder="Nom de l'événement"
placeholder="Description de l'événement" className="text-xs sm:text-sm px-3 py-2"
rows={4} />
className="text-xs sm:text-sm px-3 py-2" <Textarea
/> label="Description"
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> value={formData.description}
<div> onChange={(e) =>
<label className="block text-xs sm:text-sm text-gray-300 mb-1"> setFormData({ ...formData, description: e.target.value })
Type }
</label> placeholder="Description de l'événement"
<select rows={4}
value={formData.type} className="text-xs sm:text-sm px-3 py-2"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs sm: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-xs sm:text-sm"
>
{eventTypes.map((type) => (
<option key={type} value={type}>
{getEventTypeLabel(type)}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
type="text"
label="Salle"
value={formData.room || ""}
onChange={(e) =>
setFormData({ ...formData, room: e.target.value })
}
placeholder="Ex: Nautilus"
className="text-xs sm:text-sm px-3 py-2"
/>
<Input
type="text"
label="Heure"
value={formData.time || ""}
onChange={(e) =>
setFormData({ ...formData, time: e.target.value })
}
placeholder="Ex: 11h-12h"
className="text-xs sm:text-sm px-3 py-2"
/>
<Input
type="number"
label="Places max"
value={formData.maxPlaces || ""}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
type: e.target.value as Event["type"], maxPlaces: e.target.value
? parseInt(e.target.value)
: undefined,
}) })
} }
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm" placeholder="Ex: 25"
className="text-xs sm:text-sm px-3 py-2"
/>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={saving}
> >
{eventTypes.map((type) => ( {saving ? "Enregistrement..." : "Enregistrer"}
<option key={type} value={type}> </Button>
{getEventTypeLabel(type)} <Button onClick={handleCancel} variant="secondary" size="md">
</option> Annuler
))} </Button>
</select>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input
type="text"
label="Salle"
value={formData.room || ""}
onChange={(e) =>
setFormData({ ...formData, room: e.target.value })
}
placeholder="Ex: Nautilus"
className="text-xs sm:text-sm px-3 py-2"
/>
<Input
type="text"
label="Heure"
value={formData.time || ""}
onChange={(e) =>
setFormData({ ...formData, time: e.target.value })
}
placeholder="Ex: 11h-12h"
className="text-xs sm:text-sm px-3 py-2"
/>
<Input
type="number"
label="Places max"
value={formData.maxPlaces || ""}
onChange={(e) =>
setFormData({
...formData,
maxPlaces: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Ex: 25"
className="text-xs sm:text-sm px-3 py-2"
/>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={saving}
>
{saving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button onClick={handleCancel} variant="secondary" size="md">
Annuler
</Button>
</div>
</div> </div>
</Card> </Modal>
)} )}
{events.length === 0 ? ( {events.length === 0 ? (
@@ -392,7 +502,15 @@ export default function EventManagement() {
</div> </div>
</div> </div>
{!isCreating && !editingEvent && ( {!isCreating && !editingEvent && (
<div className="flex gap-2 sm:ml-4 flex-shrink-0"> <div className="flex gap-2 sm:ml-4 flex-shrink-0 flex-wrap">
<Button
onClick={() => handleViewRegistrations(event)}
variant="primary"
size="sm"
className="whitespace-nowrap"
>
Inscrits ({event.registrationsCount || 0})
</Button>
<Button <Button
onClick={() => handleEdit(event)} onClick={() => handleEdit(event)}
variant="primary" variant="primary"
@@ -417,6 +535,116 @@ export default function EventManagement() {
})} })}
</div> </div>
)} )}
{/* Modal des inscrits */}
{viewingRegistrations && (
<Modal
isOpen={!!viewingRegistrations}
onClose={handleCloseRegistrations}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Inscrits à &quot;{viewingRegistrations.name}&quot;
</h4>
<CloseButton onClick={handleCloseRegistrations} size="lg" />
</div>
{loadingRegistrations ? (
<div className="text-center text-gray-400 py-8">
Chargement...
</div>
) : registrations.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucun inscrit pour cet événement
</div>
) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{registrations.map((registration) => {
const user = registration.user;
const currentScore = editingScores[user.id] ?? user.score;
const isSaving = savingScore === user.id;
return (
<Card
key={registration.id}
variant="default"
className="p-3 sm:p-4"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
src={user.avatar}
username={user.username}
size="md"
borderClassName="border-2 border-pixel-gold/50"
/>
<div className="flex-1 min-w-0">
<h5 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
{user.username}
</h5>
<p className="text-gray-400 text-xs sm:text-sm">
Niveau {user.level} HP: {user.hp}/{user.maxHp}
XP: {user.xp}/{user.maxXp}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 flex-shrink-0">
<div className="flex items-center gap-2">
<label className="text-xs sm:text-sm text-gray-300 whitespace-nowrap">
Score:
</label>
<input
type="number"
value={currentScore}
onChange={(e) =>
handleScoreChange(
user.id,
parseInt(e.target.value) || 0
)
}
disabled={isSaving}
className="w-24 px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center disabled:opacity-50"
/>
</div>
<div className="flex gap-1 sm:gap-2">
<button
onClick={() =>
handleScoreChange(user.id, currentScore - 100)
}
disabled={isSaving}
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0 disabled:opacity-50"
>
-100
</button>
<button
onClick={() =>
handleScoreChange(user.id, currentScore + 100)
}
disabled={isSaving}
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0 disabled:opacity-50"
>
+100
</button>
<button
onClick={() => handleSaveScore(user.id)}
disabled={isSaving || currentScore === user.score}
className="px-2 sm:px-3 py-1 border border-pixel-gold/50 bg-pixel-gold/20 text-pixel-gold text-[10px] sm:text-xs rounded hover:bg-pixel-gold/30 transition flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? "..." : "Sauver"}
</button>
</div>
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
</Modal>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
import { updateSitePreferences } from "@/actions/admin/preferences";
import { Button, Card, Input } from "@/components/ui";
interface SitePreferences {
id: string;
eventRegistrationPoints: number;
}
interface EventPointsPreferencesProps {
initialPreferences: SitePreferences;
}
export default function EventPointsPreferences({
initialPreferences,
}: EventPointsPreferencesProps) {
const [preferences, setPreferences] = useState<SitePreferences | null>(
initialPreferences
);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
eventRegistrationPoints: initialPreferences.eventRegistrationPoints.toString(),
});
const [isSaving, setIsSaving] = useState(false);
// Synchroniser les préférences quand initialPreferences change
useEffect(() => {
setPreferences(initialPreferences);
setFormData({
eventRegistrationPoints: initialPreferences.eventRegistrationPoints.toString(),
});
}, [initialPreferences]);
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
const points = parseInt(formData.eventRegistrationPoints, 10);
if (isNaN(points) || points < 0) {
alert("Le nombre de points doit être un nombre positif");
return;
}
setIsSaving(true);
try {
const result = await updateSitePreferences({
eventRegistrationPoints: points,
});
if (result.success && result.data) {
setPreferences(result.data);
setFormData({
eventRegistrationPoints: result.data.eventRegistrationPoints.toString(),
});
setIsEditing(false);
} else {
console.error("Error updating preferences:", result.error);
alert(result.error || "Erreur lors de la mise à jour");
}
} catch (error) {
console.error("Error updating preferences:", error);
alert("Erreur lors de la mise à jour");
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
eventRegistrationPoints: preferences.eventRegistrationPoints.toString(),
});
}
};
return (
<Card variant="default" className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
<div className="min-w-0 flex-1">
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Points d&apos;inscription aux événements
</h3>
<p className="text-gray-400 text-xs sm:text-sm">
Nombre de points attribués lorsqu&apos;un utilisateur s&apos;inscrit à un événement
</p>
</div>
{!isEditing && (
<Button
onClick={handleEdit}
variant="primary"
size="sm"
className="whitespace-nowrap flex-shrink-0"
>
Modifier
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-4">
<div>
<label
htmlFor="eventRegistrationPoints"
className="block text-sm font-medium text-pixel-gold mb-2"
>
Points d&apos;inscription
</label>
<Input
id="eventRegistrationPoints"
type="number"
min="0"
value={formData.eventRegistrationPoints}
onChange={(e) =>
setFormData({
...formData,
eventRegistrationPoints: e.target.value,
})
}
placeholder="100"
className="w-full"
/>
<p className="text-xs text-gray-400 mt-1">
Les utilisateurs gagneront ce nombre de points lors de leur inscription à un événement
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-4">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={isSaving}
>
{isSaving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button
onClick={handleCancel}
variant="secondary"
size="md"
disabled={isSaving}
>
Annuler
</Button>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
Points actuels:
</span>
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl font-bold text-white">
{preferences?.eventRegistrationPoints ?? 100}
</span>
<span className="text-xs sm:text-sm text-gray-400">points</span>
</div>
</div>
)}
</Card>
);
}

View File

@@ -1,11 +1,18 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import {
addFeedbackBonusPoints,
markFeedbackAsRead,
} from "@/actions/admin/feedback";
import { Button } from "@/components/ui";
import Avatar from "@/components/ui/Avatar";
interface Feedback { interface Feedback {
id: string; id: string;
rating: number; rating: number;
comment: string | null; comment: string | null;
isRead: boolean;
createdAt: string; createdAt: string;
event: { event: {
id: string; id: string;
@@ -17,6 +24,8 @@ interface Feedback {
id: string; id: string;
username: string; username: string;
email: string; email: string;
avatar: string | null;
score: number;
}; };
} }
@@ -29,16 +38,23 @@ interface EventStatistics {
feedbackCount: number; feedbackCount: number;
} }
export default function FeedbackManagement() { interface FeedbackManagementProps {
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]); initialFeedbacks: Feedback[];
const [statistics, setStatistics] = useState<EventStatistics[]>([]); initialStatistics: EventStatistics[];
const [loading, setLoading] = useState(true); }
export default function FeedbackManagement({
initialFeedbacks,
initialStatistics,
}: FeedbackManagementProps) {
const [feedbacks, setFeedbacks] = useState<Feedback[]>(initialFeedbacks);
const [statistics, setStatistics] = useState<EventStatistics[]>(initialStatistics);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [selectedEvent, setSelectedEvent] = useState<string | null>(null); const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
const [addingPoints, setAddingPoints] = useState<Record<string, boolean>>(
useEffect(() => { {}
fetchFeedbacks(); );
}, []); const [markingRead, setMarkingRead] = useState<Record<string, boolean>>({});
const fetchFeedbacks = async () => { const fetchFeedbacks = async () => {
try { try {
@@ -52,8 +68,6 @@ export default function FeedbackManagement() {
setStatistics(data.statistics || []); setStatistics(data.statistics || []);
} catch { } catch {
setError("Erreur lors du chargement des feedbacks"); setError("Erreur lors du chargement des feedbacks");
} finally {
setLoading(false);
} }
}; };
@@ -92,17 +106,59 @@ export default function FeedbackManagement() {
); );
}; };
const filteredFeedbacks = selectedEvent const handleAddPoints = async (userId: string, points: number) => {
? feedbacks.filter((f) => f.event.id === selectedEvent) const key = `${userId}-${points}`;
: feedbacks; setAddingPoints((prev) => ({ ...prev, [key]: true }));
setError("");
if (loading) { try {
const result = await addFeedbackBonusPoints(userId, points);
if (result.success) {
// Rafraîchir les données pour voir les nouveaux scores
await fetchFeedbacks();
// Rafraîchir le score dans le header si l'utilisateur est connecté
window.dispatchEvent(new Event("refreshUserScore"));
} else {
setError(result.error || "Erreur lors de l'ajout des points");
}
} catch {
setError("Erreur lors de l'ajout des points");
} finally {
setAddingPoints((prev) => ({ ...prev, [key]: false }));
}
};
const handleMarkAsRead = async (feedbackId: string, isRead: boolean) => {
setMarkingRead((prev) => ({ ...prev, [feedbackId]: true }));
setError("");
try {
const result = await markFeedbackAsRead(feedbackId, isRead);
if (result.success) {
// Rafraîchir les données pour voir le nouveau statut
await fetchFeedbacks();
} else {
setError(result.error || "Erreur lors de la mise à jour");
}
} catch {
setError("Erreur lors de la mise à jour");
} finally {
setMarkingRead((prev) => ({ ...prev, [feedbackId]: false }));
}
};
const filteredFeedbacks = (selectedEvent
? feedbacks.filter((f) => f.event.id === selectedEvent)
: feedbacks
).sort((a, b) => {
// Trier : non lus en premier, puis par date décroissante
if (a.isRead !== b.isRead) {
return a.isRead ? 1 : -1;
}
return ( return (
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-4 sm:p-8"> new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
<p className="text-gray-400 text-center text-sm">Chargement...</p>
</div>
); );
} });
return ( return (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
@@ -184,20 +240,45 @@ export default function FeedbackManagement() {
{filteredFeedbacks.map((feedback) => ( {filteredFeedbacks.map((feedback) => (
<div <div
key={feedback.id} key={feedback.id}
className="bg-black/40 border border-pixel-gold/20 rounded p-3 sm:p-4" className={`bg-black/40 border rounded p-3 sm:p-4 ${
feedback.isRead
? "border-pixel-gold/20 opacity-75"
: "border-pixel-gold/50 bg-pixel-gold/5"
}`}
> >
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 mb-2"> {/* En-tête utilisateur avec avatar */}
<h4 className="text-white font-semibold text-sm sm:text-base break-words"> <div className="flex items-center gap-2 sm:gap-3 mb-3">
{feedback.user.username} <Avatar
</h4> src={feedback.user.avatar}
<span className="text-gray-500 text-[10px] sm:text-xs break-all"> username={feedback.user.username}
{feedback.user.email} size="md"
</span> borderClassName="border-pixel-gold/30"
/>
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 mb-1">
<h4 className="text-white font-semibold text-sm sm:text-base break-words">
{feedback.user.username}
</h4>
<span className="text-pixel-gold font-bold text-xs sm:text-sm">
{feedback.user.score.toLocaleString("fr-FR")} pts
</span>
</div>
<span className="text-gray-500 text-[10px] sm:text-xs break-all">
{feedback.user.email}
</span>
</div>
</div> </div>
<div className="text-pixel-gold text-xs sm:text-sm font-semibold mb-2 break-words"> <div className="flex items-center gap-2 mb-2">
{feedback.event.name} <div className="text-pixel-gold text-xs sm:text-sm font-semibold break-words">
{feedback.event.name}
</div>
{!feedback.isRead && (
<span className="bg-pixel-gold/20 text-pixel-gold text-[10px] px-1.5 py-0.5 rounded uppercase font-semibold">
Non lu
</span>
)}
</div> </div>
<div className="text-gray-500 text-[10px] sm:text-xs mb-2"> <div className="text-gray-500 text-[10px] sm:text-xs mb-2">
{new Date(feedback.createdAt).toLocaleDateString( {new Date(feedback.createdAt).toLocaleDateString(
@@ -212,8 +293,23 @@ export default function FeedbackManagement() {
)} )}
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className="flex flex-col items-end gap-2 flex-shrink-0">
{renderStars(feedback.rating)} {renderStars(feedback.rating)}
<Button
variant={feedback.isRead ? "secondary" : "success"}
size="sm"
onClick={() =>
handleMarkAsRead(feedback.id, !feedback.isRead)
}
disabled={markingRead[feedback.id]}
className="text-xs whitespace-nowrap"
>
{markingRead[feedback.id]
? "..."
: feedback.isRead
? "Marquer non lu"
: "Marquer lu"}
</Button>
</div> </div>
</div> </div>
{feedback.comment && ( {feedback.comment && (
@@ -223,6 +319,39 @@ export default function FeedbackManagement() {
</p> </p>
</div> </div>
)} )}
{/* Boutons pour ajouter des points bonus */}
<div className="mt-3 pt-3 border-t border-pixel-gold/20 flex flex-wrap gap-2">
<span className="text-gray-400 text-xs sm:text-sm mr-2">
Points bonus:
</span>
<Button
variant="primary"
size="sm"
onClick={() => handleAddPoints(feedback.user.id, 10)}
disabled={addingPoints[`${feedback.user.id}-10`]}
className="text-xs"
>
{addingPoints[`${feedback.user.id}-10`] ? "..." : "+10"}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => handleAddPoints(feedback.user.id, 100)}
disabled={addingPoints[`${feedback.user.id}-100`]}
className="text-xs"
>
{addingPoints[`${feedback.user.id}-100`] ? "..." : "+100"}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => handleAddPoints(feedback.user.id, 1000)}
disabled={addingPoints[`${feedback.user.id}-1000`]}
className="text-xs"
>
{addingPoints[`${feedback.user.id}-1000`] ? "..." : "+1000"}
</Button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,447 @@
"use client";
import { useState, useTransition } from "react";
import {
Input,
Textarea,
Button,
Card,
Badge,
Modal,
CloseButton,
Avatar,
} from "@/components/ui";
import { updateHouse, deleteHouse, removeMember } from "@/actions/admin/houses";
interface House {
id: string;
name: string;
description: string | null;
creatorId: string;
creator: {
id: string;
username: string;
avatar: string | null;
};
createdAt: string;
updatedAt: string;
membersCount: number;
memberships: Array<{
id: string;
role: string;
joinedAt: string;
user: {
id: string;
username: string;
avatar: string | null;
score: number;
level: number;
};
}>;
}
interface HouseFormData {
name: string;
description: string;
}
const getRoleLabel = (role: string) => {
switch (role) {
case "OWNER":
return "👑 Propriétaire";
case "ADMIN":
return "⚡ Admin";
case "MEMBER":
return "👤 Membre";
default:
return role;
}
};
const getRoleColor = (role: string) => {
switch (role) {
case "OWNER":
return "var(--accent)";
case "ADMIN":
return "var(--primary)";
case "MEMBER":
return "var(--muted-foreground)";
default:
return "var(--gray)";
}
};
interface HouseManagementProps {
initialHouses: House[];
}
export default function HouseManagement({ initialHouses }: HouseManagementProps) {
const [houses, setHouses] = useState<House[]>(initialHouses);
const [editingHouse, setEditingHouse] = useState<House | null>(null);
const [saving, setSaving] = useState(false);
const [deletingHouseId, setDeletingHouseId] = useState<string | null>(null);
const [viewingMembers, setViewingMembers] = useState<House | null>(null);
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
const [formData, setFormData] = useState<HouseFormData>({
name: "",
description: "",
});
const [, startTransition] = useTransition();
const fetchHouses = async () => {
try {
const response = await fetch("/api/admin/houses");
if (response.ok) {
const data = await response.json();
setHouses(data);
}
} catch (error) {
console.error("Error fetching houses:", error);
}
};
const handleEdit = (house: House) => {
setEditingHouse(house);
setFormData({
name: house.name,
description: house.description || "",
});
};
const handleSave = async () => {
if (!editingHouse) return;
setSaving(true);
startTransition(async () => {
try {
const result = await updateHouse(editingHouse.id, {
name: formData.name,
description: formData.description || null,
});
if (result.success) {
await fetchHouses();
setEditingHouse(null);
setFormData({ name: "", description: "" });
} else {
alert(result.error || "Erreur lors de la mise à jour");
}
} catch (error) {
console.error("Error updating house:", error);
alert("Erreur lors de la mise à jour");
} finally {
setSaving(false);
}
});
};
const handleDelete = async (houseId: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir supprimer cette maison ? Cette action est irréversible et supprimera tous les membres."
)
) {
return;
}
setDeletingHouseId(houseId);
startTransition(async () => {
try {
const result = await deleteHouse(houseId);
if (result.success) {
await fetchHouses();
} else {
alert(result.error || "Erreur lors de la suppression");
}
} catch (error) {
console.error("Error deleting house:", error);
alert("Erreur lors de la suppression");
} finally {
setDeletingHouseId(null);
}
});
};
const handleCancel = () => {
setEditingHouse(null);
setFormData({ name: "", description: "" });
};
const handleRemoveMember = async (houseId: string, memberId: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir retirer ce membre de la maison ? Cette action lui retirera des points."
)
) {
return;
}
setRemovingMemberId(memberId);
startTransition(async () => {
try {
const result = await removeMember(houseId, memberId);
if (result.success) {
// Récupérer les maisons mises à jour
const response = await fetch("/api/admin/houses");
if (response.ok) {
const updatedHouses = await response.json();
setHouses(updatedHouses);
// Mettre à jour la modal si elle est ouverte
if (viewingMembers) {
const updatedHouse = updatedHouses.find((h: House) => h.id === houseId);
if (updatedHouse) {
setViewingMembers(updatedHouse);
} else {
// Si la maison n'existe plus, fermer la modal
setViewingMembers(null);
}
}
}
} else {
alert(result.error || "Erreur lors du retrait du membre");
}
} catch (error) {
console.error("Error removing member:", error);
alert("Erreur lors du retrait du membre");
} finally {
setRemovingMemberId(null);
}
});
};
const formatNumber = (num: number) => {
return num.toLocaleString("en-US");
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-4">
<h3 className="text-lg sm:text-xl font-gaming font-bold text-pixel-gold break-words">
Maisons ({houses.length})
</h3>
</div>
{/* Modal d'édition */}
{editingHouse && (
<Modal isOpen={!!editingHouse} onClose={handleCancel} size="lg">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Modifier la maison
</h4>
<CloseButton onClick={handleCancel} size="lg" />
</div>
<div className="space-y-4">
<Input
type="text"
label="Nom de la maison"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Nom de la maison"
className="text-xs sm:text-sm px-3 py-2"
/>
<Textarea
label="Description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Description de la maison"
rows={4}
className="text-xs sm:text-sm px-3 py-2"
/>
<div className="flex flex-col sm:flex-row gap-2">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={saving}
>
{saving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button onClick={handleCancel} variant="secondary" size="md">
Annuler
</Button>
</div>
</div>
</div>
</Modal>
)}
{/* Modal des membres */}
{viewingMembers && (
<Modal
isOpen={!!viewingMembers}
onClose={() => setViewingMembers(null)}
size="lg"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Membres de &quot;{viewingMembers.name}&quot;
</h4>
<CloseButton onClick={() => setViewingMembers(null)} size="lg" />
</div>
{viewingMembers.memberships.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucun membre dans cette maison
</div>
) : (
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{viewingMembers.memberships.map((membership) => {
const roleColor = getRoleColor(membership.role);
return (
<Card
key={membership.id}
variant="default"
className="p-3 sm:p-4"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar
src={membership.user.avatar}
username={membership.user.username}
size="md"
borderClassName="border-2"
style={{
borderColor: roleColor,
}}
/>
<div className="flex-1 min-w-0">
<h5 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
{membership.user.username}
</h5>
<p className="text-gray-400 text-xs sm:text-sm">
Niveau {membership.user.level} Score:{" "}
{formatNumber(membership.user.score)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant="default"
size="sm"
style={{
color: roleColor,
backgroundColor: `color-mix(in srgb, ${roleColor} 15%, transparent)`,
borderColor: `color-mix(in srgb, ${roleColor} 30%, transparent)`,
}}
>
{getRoleLabel(membership.role)}
</Badge>
{membership.role !== "OWNER" && (
<Button
onClick={() =>
handleRemoveMember(
viewingMembers.id,
membership.user.id
)
}
variant="danger"
size="sm"
disabled={removingMemberId === membership.user.id}
className="whitespace-nowrap"
>
{removingMemberId === membership.user.id
? "..."
: "Retirer"}
</Button>
)}
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
</Modal>
)}
{houses.length === 0 ? (
<div className="text-center text-gray-400 py-8">
Aucune maison trouvée
</div>
) : (
<div className="space-y-3">
{houses.map((house) => {
return (
<Card key={house.id} variant="default" className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
{house.name}
</h4>
<Badge variant="info" size="sm">
{house.membersCount} membre
{house.membersCount !== 1 ? "s" : ""}
</Badge>
</div>
{house.description && (
<p className="text-gray-400 text-xs sm:text-sm mb-2 break-words">
{house.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
<div className="flex items-center gap-2">
<Avatar
src={house.creator.avatar}
username={house.creator.username}
size="sm"
borderClassName="border border-pixel-gold/50"
/>
<p className="text-gray-500 text-[10px] sm:text-xs">
Créée par {house.creator.username}
</p>
</div>
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
Créée le{" "}
{new Date(house.createdAt).toLocaleDateString("fr-FR")}
</p>
</div>
</div>
{!editingHouse && (
<div className="flex gap-2 sm:ml-4 flex-shrink-0 flex-wrap">
<Button
onClick={() => setViewingMembers(house)}
variant="primary"
size="sm"
className="whitespace-nowrap"
>
Membres ({house.membersCount})
</Button>
<Button
onClick={() => handleEdit(house)}
variant="primary"
size="sm"
className="whitespace-nowrap"
>
Modifier
</Button>
<Button
onClick={() => handleDelete(house.id)}
variant="danger"
size="sm"
disabled={deletingHouseId === house.id}
className="whitespace-nowrap"
>
{deletingHouseId === house.id ? "..." : "Supprimer"}
</Button>
</div>
)}
</div>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { useState, useEffect } from "react";
import { updateSitePreferences } from "@/actions/admin/preferences";
import { Button, Card, Input } from "@/components/ui";
interface SitePreferences {
id: string;
houseJoinPoints: number;
houseLeavePoints: number;
houseCreatePoints: number;
}
interface HousePointsPreferencesProps {
initialPreferences: SitePreferences;
}
export default function HousePointsPreferences({
initialPreferences,
}: HousePointsPreferencesProps) {
const [preferences, setPreferences] = useState<SitePreferences | null>(
initialPreferences
);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
houseJoinPoints: initialPreferences.houseJoinPoints.toString(),
houseLeavePoints: initialPreferences.houseLeavePoints.toString(),
houseCreatePoints: initialPreferences.houseCreatePoints.toString(),
});
const [isSaving, setIsSaving] = useState(false);
// Synchroniser les préférences quand initialPreferences change
useEffect(() => {
setPreferences(initialPreferences);
setFormData({
houseJoinPoints: initialPreferences.houseJoinPoints.toString(),
houseLeavePoints: initialPreferences.houseLeavePoints.toString(),
houseCreatePoints: initialPreferences.houseCreatePoints.toString(),
});
}, [initialPreferences]);
const handleEdit = () => {
setIsEditing(true);
};
const handleSave = async () => {
const joinPoints = parseInt(formData.houseJoinPoints, 10);
const leavePoints = parseInt(formData.houseLeavePoints, 10);
const createPoints = parseInt(formData.houseCreatePoints, 10);
if (isNaN(joinPoints) || joinPoints < 0) {
alert("Le nombre de points pour rejoindre une maison doit être un nombre positif");
return;
}
if (isNaN(leavePoints) || leavePoints < 0) {
alert("Le nombre de points pour quitter une maison doit être un nombre positif");
return;
}
if (isNaN(createPoints) || createPoints < 0) {
alert("Le nombre de points pour créer une maison doit être un nombre positif");
return;
}
setIsSaving(true);
try {
const result = await updateSitePreferences({
houseJoinPoints: joinPoints,
houseLeavePoints: leavePoints,
houseCreatePoints: createPoints,
});
if (result.success && result.data) {
setPreferences(result.data);
setFormData({
houseJoinPoints: result.data.houseJoinPoints.toString(),
houseLeavePoints: result.data.houseLeavePoints.toString(),
houseCreatePoints: result.data.houseCreatePoints.toString(),
});
setIsEditing(false);
} else {
console.error("Error updating preferences:", result.error);
alert(result.error || "Erreur lors de la mise à jour");
}
} catch (error) {
console.error("Error updating preferences:", error);
alert("Erreur lors de la mise à jour");
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setIsEditing(false);
if (preferences) {
setFormData({
houseJoinPoints: preferences.houseJoinPoints.toString(),
houseLeavePoints: preferences.houseLeavePoints.toString(),
houseCreatePoints: preferences.houseCreatePoints.toString(),
});
}
};
return (
<Card variant="default" className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
<div className="min-w-0 flex-1">
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
Points des Maisons
</h3>
<p className="text-gray-400 text-xs sm:text-sm">
Nombre de points attribués ou retirés pour les actions liées aux maisons
</p>
</div>
{!isEditing && (
<Button
onClick={handleEdit}
variant="primary"
size="sm"
className="whitespace-nowrap flex-shrink-0"
>
Modifier
</Button>
)}
</div>
{isEditing ? (
<div className="space-y-4">
<div>
<label
htmlFor="houseJoinPoints"
className="block text-sm font-medium text-pixel-gold mb-2"
>
Points pour rejoindre une maison
</label>
<Input
id="houseJoinPoints"
type="number"
min="0"
value={formData.houseJoinPoints}
onChange={(e) =>
setFormData({
...formData,
houseJoinPoints: e.target.value,
})
}
placeholder="100"
className="w-full"
/>
<p className="text-xs text-gray-400 mt-1">
Les utilisateurs gagneront ce nombre de points lorsqu&apos;ils rejoignent une maison
</p>
</div>
<div>
<label
htmlFor="houseLeavePoints"
className="block text-sm font-medium text-pixel-gold mb-2"
>
Points retirés en quittant une maison
</label>
<Input
id="houseLeavePoints"
type="number"
min="0"
value={formData.houseLeavePoints}
onChange={(e) =>
setFormData({
...formData,
houseLeavePoints: e.target.value,
})
}
placeholder="100"
className="w-full"
/>
<p className="text-xs text-gray-400 mt-1">
Les utilisateurs perdront ce nombre de points lorsqu&apos;ils quittent une maison
</p>
</div>
<div>
<label
htmlFor="houseCreatePoints"
className="block text-sm font-medium text-pixel-gold mb-2"
>
Points pour créer une maison
</label>
<Input
id="houseCreatePoints"
type="number"
min="0"
value={formData.houseCreatePoints}
onChange={(e) =>
setFormData({
...formData,
houseCreatePoints: e.target.value,
})
}
placeholder="100"
className="w-full"
/>
<p className="text-xs text-gray-400 mt-1">
Les utilisateurs gagneront ce nombre de points lorsqu&apos;ils créent une maison
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 pt-4">
<Button
onClick={handleSave}
variant="success"
size="md"
disabled={isSaving}
>
{isSaving ? "Enregistrement..." : "Enregistrer"}
</Button>
<Button
onClick={handleCancel}
variant="secondary"
size="md"
disabled={isSaving}
>
Annuler
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
Points pour rejoindre:
</span>
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl font-bold text-white">
{preferences?.houseJoinPoints ?? 100}
</span>
<span className="text-xs sm:text-sm text-gray-400">points</span>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
Points retirés en quittant:
</span>
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl font-bold text-white">
{preferences?.houseLeavePoints ?? 100}
</span>
<span className="text-xs sm:text-sm text-gray-400">points</span>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
Points pour créer:
</span>
<div className="flex items-center gap-2">
<span className="text-lg sm:text-xl font-bold text-white">
{preferences?.houseCreatePoints ?? 100}
</span>
<span className="text-xs sm:text-sm text-gray-400">points</span>
</div>
</div>
</div>
)}
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
"use client";
import { Card, Button, Avatar, Badge } from "@/components/ui";
interface ChallengeCardProps {
challenge: {
id: string;
challenger: {
id: string;
username: string;
avatar: string | null;
};
challenged: {
id: string;
username: string;
avatar: string | null;
};
title: string;
description: string;
pointsReward: number;
status: string;
adminComment: string | null;
winner?: {
id: string;
username: string;
} | null;
createdAt: string;
acceptedAt: string | null;
completedAt: string | null;
};
currentUserId?: string;
onAccept?: (challengeId: string) => void;
onCancel?: (challengeId: string) => void;
isPending?: boolean;
}
const getStatusLabel = (status: string) => {
switch (status) {
case "PENDING":
return "En attente d'acceptation";
case "ACCEPTED":
return "En cours - En attente de désignation du gagnant";
case "COMPLETED":
return "Complété";
case "REJECTED":
return "Rejeté";
case "CANCELLED":
return "Annulé";
default:
return status;
}
};
const getStatusVariant = (
status: string
): "default" | "success" | "warning" | "danger" | "info" => {
switch (status) {
case "PENDING":
return "warning";
case "ACCEPTED":
return "info";
case "COMPLETED":
return "success";
case "REJECTED":
return "danger";
case "CANCELLED":
return "default";
default:
return "default";
}
};
export default function ChallengeCard({
challenge,
currentUserId,
onAccept,
onCancel,
isPending = false,
}: ChallengeCardProps) {
const isChallenger = challenge.challenger.id === currentUserId;
const isChallenged = challenge.challenged.id === currentUserId;
const canAccept = challenge.status === "PENDING" && isChallenged;
const canCancel =
(challenge.status === "PENDING" || challenge.status === "ACCEPTED") &&
(isChallenger || isChallenged);
return (
<Card variant="dark" className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h3 className="text-lg font-bold text-pixel-gold">
{challenge.title}
</h3>
<Badge variant={getStatusVariant(challenge.status)} size="xs">
{getStatusLabel(challenge.status)}
</Badge>
</div>
<p className="text-gray-300 mb-4">{challenge.description}</p>
<div className="flex items-center gap-4 mb-2 flex-wrap">
<div className="flex items-center gap-2">
<Avatar
src={challenge.challenger.avatar}
username={challenge.challenger.username}
size="sm"
/>
<span className="text-sm text-gray-300">
{challenge.challenger.username}
</span>
</div>
<span className="text-gray-500">VS</span>
<div className="flex items-center gap-2">
<Avatar
src={challenge.challenged.avatar}
username={challenge.challenged.username}
size="sm"
/>
<span className="text-sm text-gray-300">
{challenge.challenged.username}
</span>
</div>
</div>
<div className="text-sm text-gray-400">
Récompense:{" "}
<span className="text-pixel-gold font-bold">
{challenge.pointsReward} points
</span>
</div>
{challenge.winner && (
<div className="text-sm text-green-400 mt-2">
🏆 Gagnant: {challenge.winner.username}
</div>
)}
{challenge.adminComment && (
<div className="text-xs text-gray-500 mt-2 italic">
Admin: {challenge.adminComment}
</div>
)}
<div className="text-xs text-gray-500 mt-2">
Créé le: {new Date(challenge.createdAt).toLocaleDateString("fr-FR")}
{challenge.acceptedAt &&
` • Accepté le: ${new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}`}
{challenge.completedAt &&
` • Complété le: ${new Date(challenge.completedAt).toLocaleDateString("fr-FR")}`}
</div>
</div>
<div className="flex flex-col gap-2">
{canAccept && onAccept && (
<Button
onClick={() => onAccept(challenge.id)}
variant="primary"
size="sm"
disabled={isPending}
>
Accepter
</Button>
)}
{canCancel && onCancel && (
<Button
onClick={() => {
if (confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
onCancel(challenge.id);
}
}}
variant="secondary"
size="sm"
disabled={isPending}
>
Annuler
</Button>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { Card, Input, Textarea, Button, Select } from "@/components/ui";
interface User {
id: string;
username: string;
avatar: string | null;
score: number;
level: number;
}
interface ChallengeFormProps {
users: User[];
onSubmit: (data: {
challengedId: string;
title: string;
description: string;
pointsReward: number;
}) => void;
onCancel?: () => void;
isPending?: boolean;
}
export default function ChallengeForm({
users,
onSubmit,
onCancel,
isPending = false,
}: ChallengeFormProps) {
const [challengedId, setChallengedId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [pointsReward, setPointsReward] = useState(100);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!challengedId || !title || !description) {
return;
}
onSubmit({
challengedId,
title,
description,
pointsReward,
});
};
const handleCancel = () => {
setChallengedId("");
setTitle("");
setDescription("");
setPointsReward(100);
onCancel?.();
};
return (
<Card variant="dark" className="p-6 mb-8">
<h2 className="text-xl font-bold text-pixel-gold mb-4">
Créer un nouveau défi
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<Select
label="Défier qui ?"
value={challengedId}
onChange={(e) => setChallengedId(e.target.value)}
>
<option value="">Sélectionner un joueur</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username} (Lv.{user.level} - {user.score} pts)
</option>
))}
</Select>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Titre du défi
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Qui participera à plus d'événements ce mois ?"
/>
</div>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez les règles du défi..."
rows={4}
/>
</div>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Points à gagner (défaut: 100)
</label>
<Input
type="number"
value={pointsReward}
onChange={(e) =>
setPointsReward(parseInt(e.target.value) || 100)
}
min={1}
max={1000}
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
disabled={isPending || !challengedId || !title || !description}
className="flex-1"
>
{isPending ? "Création..." : "Créer le défi"}
</Button>
{onCancel && (
<Button
type="button"
onClick={handleCancel}
variant="secondary"
disabled={isPending}
>
Annuler
</Button>
)}
</div>
</form>
</Card>
);
}

View File

@@ -1,21 +1,15 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { import {
createChallenge, createChallenge,
acceptChallenge, acceptChallenge,
cancelChallenge, cancelChallenge,
} from "@/actions/challenges/create"; } from "@/actions/challenges/create";
import { import { Button, Card, SectionTitle, Alert } from "@/components/ui";
Button, import ChallengeCard from "./ChallengeCard";
Card, import ChallengeForm from "./ChallengeForm";
SectionTitle,
Input,
Textarea,
Alert,
} from "@/components/ui";
import { Avatar } from "@/components/ui";
interface User { interface User {
id: string; id: string;
@@ -52,31 +46,24 @@ interface Challenge {
} }
interface ChallengesSectionProps { interface ChallengesSectionProps {
initialChallenges: Challenge[];
initialUsers: User[];
backgroundImage: string; backgroundImage: string;
} }
export default function ChallengesSection({ export default function ChallengesSection({
initialChallenges,
initialUsers,
backgroundImage, backgroundImage,
}: ChallengesSectionProps) { }: ChallengesSectionProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const [challenges, setChallenges] = useState<Challenge[]>([]); const [challenges, setChallenges] = useState<Challenge[]>(initialChallenges);
const [users, setUsers] = useState<User[]>([]); const [users] = useState<User[]>(initialUsers);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
// Form state
const [challengedId, setChallengedId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [pointsReward, setPointsReward] = useState(100);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showExamples, setShowExamples] = useState(false);
useEffect(() => {
fetchChallenges();
fetchUsers();
}, []);
const fetchChallenges = async () => { const fetchChallenges = async () => {
try { try {
@@ -87,46 +74,24 @@ export default function ChallengesSection({
} }
} catch (error) { } catch (error) {
console.error("Error fetching challenges:", error); console.error("Error fetching challenges:", error);
} finally {
setLoading(false);
} }
}; };
const fetchUsers = async () => { const handleCreateChallenge = (data: {
try { challengedId: string;
const response = await fetch("/api/users"); title: string;
if (response.ok) { description: string;
const data = await response.json(); pointsReward: number;
setUsers(data); }) => {
}
} catch (error) {
console.error("Error fetching users:", error);
}
};
const handleCreateChallenge = () => {
if (!challengedId || !title || !description) {
setErrorMessage("Veuillez remplir tous les champs");
setTimeout(() => setErrorMessage(null), 5000);
return;
}
startTransition(async () => { startTransition(async () => {
const result = await createChallenge({ const result = await createChallenge(data);
challengedId,
title,
description,
pointsReward,
});
if (result.success) { if (result.success) {
setSuccessMessage("Défi créé avec succès !"); setSuccessMessage("Défi créé avec succès !");
setShowCreateForm(false); setShowCreateForm(false);
setChallengedId("");
setTitle("");
setDescription("");
setPointsReward(100);
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors de la création du défi"); setErrorMessage(result.error || "Erreur lors de la création du défi");
@@ -140,8 +105,12 @@ export default function ChallengesSection({
const result = await acceptChallenge(challengeId); const result = await acceptChallenge(challengeId);
if (result.success) { if (result.success) {
setSuccessMessage("Défi accepté ! En attente de validation admin."); setSuccessMessage(
"Défi accepté ! En attente de désignation du gagnant."
);
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors de l'acceptation"); setErrorMessage(result.error || "Erreur lors de l'acceptation");
@@ -151,16 +120,14 @@ export default function ChallengesSection({
}; };
const handleCancelChallenge = (challengeId: string) => { const handleCancelChallenge = (challengeId: string) => {
if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
return;
}
startTransition(async () => { startTransition(async () => {
const result = await cancelChallenge(challengeId); const result = await cancelChallenge(challengeId);
if (result.success) { if (result.success) {
setSuccessMessage("Défi annulé"); setSuccessMessage("Défi annulé");
fetchChallenges(); fetchChallenges();
// Rafraîchir le badge des défis
window.dispatchEvent(new Event("refreshChallenges"));
setTimeout(() => setSuccessMessage(null), 5000); setTimeout(() => setSuccessMessage(null), 5000);
} else { } else {
setErrorMessage(result.error || "Erreur lors de l'annulation"); setErrorMessage(result.error || "Erreur lors de l'annulation");
@@ -169,56 +136,41 @@ export default function ChallengesSection({
}); });
}; };
const getStatusLabel = (status: string) => {
switch (status) {
case "PENDING":
return "En attente d'acceptation";
case "ACCEPTED":
return "Accepté - En attente de validation admin";
case "COMPLETED":
return "Complété";
case "REJECTED":
return "Rejeté";
case "CANCELLED":
return "Annulé";
default:
return status;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "PENDING":
return "text-yellow-400";
case "ACCEPTED":
return "text-blue-400";
case "COMPLETED":
return "text-green-400";
case "REJECTED":
return "text-red-400";
case "CANCELLED":
return "text-gray-400";
default:
return "text-gray-300";
}
};
return ( return (
<section <section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16" {/* Background Image */}
style={{ <div
backgroundImage: `url(${backgroundImage})`, className="fixed inset-0 bg-cover bg-center bg-no-repeat"
backgroundSize: "cover", style={{
backgroundPosition: "center", backgroundImage: `url('${backgroundImage}')`,
backgroundRepeat: "no-repeat", }}
}} >
> {/* Dark overlay for readability */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm"></div> <div
className="absolute inset-0 bg-gradient-to-b"
style={{
background: `linear-gradient(to bottom,
color-mix(in srgb, var(--background) 70%, transparent),
color-mix(in srgb, var(--background) 60%, transparent),
color-mix(in srgb, var(--background) 80%, transparent)
)`,
}}
/>
</div>
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16"> <div className="relative z-10 w-full max-w-6xl mx-auto px-4 sm:px-8 py-16">
<SectionTitle variant="gradient" size="md" className="mb-8 text-center"> <SectionTitle
variant="gradient"
size="xl"
subtitle="Défiez vos collègues et gagnez des points"
className="mb-16"
>
DÉFIS ENTRE JOUEURS DÉFIS ENTRE JOUEURS
</SectionTitle> </SectionTitle>
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
Créez des défis personnalisés, acceptez ceux de vos collègues et
remportez des récompenses en points pour monter dans le classement
</p>
{successMessage && ( {successMessage && (
<Alert variant="success" className="mb-4"> <Alert variant="success" className="mb-4">
@@ -243,84 +195,16 @@ export default function ChallengesSection({
{/* Create Form */} {/* Create Form */}
{showCreateForm && ( {showCreateForm && (
<Card variant="dark" className="p-6 mb-8"> <ChallengeForm
<h2 className="text-xl font-bold text-pixel-gold mb-4"> users={users}
Créer un nouveau défi onSubmit={handleCreateChallenge}
</h2> onCancel={() => setShowCreateForm(false)}
isPending={isPending}
<div className="space-y-4"> />
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Défier qui ?
</label>
<select
value={challengedId}
onChange={(e) => setChallengedId(e.target.value)}
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
>
<option value="">Sélectionner un joueur</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username} (Lv.{user.level} - {user.score} pts)
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Titre du défi
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Qui participera à plus d'événements ce mois ?"
/>
</div>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez les règles du défi..."
rows={4}
/>
</div>
<div>
<label className="block text-sm font-bold text-pixel-gold mb-2">
Points à gagner (défaut: 100)
</label>
<Input
type="number"
value={pointsReward}
onChange={(e) =>
setPointsReward(parseInt(e.target.value) || 100)
}
min={1}
max={1000}
/>
</div>
<Button
onClick={handleCreateChallenge}
variant="primary"
disabled={isPending || !challengedId || !title || !description}
className="w-full"
>
{isPending ? "Création..." : "Créer le défi"}
</Button>
</div>
</Card>
)} )}
{/* Challenges List */} {/* Challenges List */}
{loading ? ( {challenges.length === 0 ? (
<div className="text-center text-pixel-gold py-8">Chargement...</div>
) : challenges.length === 0 ? (
<Card variant="dark" className="p-6 text-center"> <Card variant="dark" className="p-6 text-center">
<p className="text-gray-400"> <p className="text-gray-400">
Vous n&apos;avez aucun défi pour le moment. Vous n&apos;avez aucun défi pour le moment.
@@ -328,120 +212,107 @@ export default function ChallengesSection({
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{challenges.map((challenge) => { {challenges.map((challenge) => (
const currentUserId = session?.user?.id; <ChallengeCard
const isChallenger = challenge.challenger.id === currentUserId; key={challenge.id}
const isChallenged = challenge.challenged.id === currentUserId; challenge={challenge}
const canAccept = challenge.status === "PENDING" && isChallenged; currentUserId={session?.user?.id}
const canCancel = onAccept={handleAcceptChallenge}
(challenge.status === "PENDING" || onCancel={handleCancelChallenge}
challenge.status === "ACCEPTED") && isPending={isPending}
(isChallenger || isChallenged); />
))}
return (
<Card key={challenge.id} variant="dark" className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-bold text-pixel-gold">
{challenge.title}
</h3>
<span
className={`text-xs px-2 py-1 rounded ${getStatusColor(
challenge.status
)} bg-black/40`}
>
{getStatusLabel(challenge.status)}
</span>
</div>
<p className="text-gray-300 mb-4">
{challenge.description}
</p>
<div className="flex items-center gap-4 mb-2">
<div className="flex items-center gap-2">
<Avatar
src={challenge.challenger.avatar}
username={challenge.challenger.username}
size="sm"
/>
<span className="text-sm text-gray-300">
{challenge.challenger.username}
</span>
</div>
<span className="text-gray-500">VS</span>
<div className="flex items-center gap-2">
<Avatar
src={challenge.challenged.avatar}
username={challenge.challenged.username}
size="sm"
/>
<span className="text-sm text-gray-300">
{challenge.challenged.username}
</span>
</div>
</div>
<div className="text-sm text-gray-400">
Récompense:{" "}
<span className="text-pixel-gold font-bold">
{challenge.pointsReward} points
</span>
</div>
{challenge.winner && (
<div className="text-sm text-green-400 mt-2">
🏆 Gagnant: {challenge.winner.username}
</div>
)}
{challenge.adminComment && (
<div className="text-xs text-gray-500 mt-2 italic">
Admin: {challenge.adminComment}
</div>
)}
<div className="text-xs text-gray-500 mt-2">
Créé le:{" "}
{new Date(challenge.createdAt).toLocaleDateString(
"fr-FR"
)}
{challenge.acceptedAt &&
` • Accepté le: ${new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}`}
{challenge.completedAt &&
` • Complété le: ${new Date(challenge.completedAt).toLocaleDateString("fr-FR")}`}
</div>
</div>
<div className="flex flex-col gap-2">
{canAccept && (
<Button
onClick={() => handleAcceptChallenge(challenge.id)}
variant="primary"
size="sm"
disabled={isPending}
>
Accepter
</Button>
)}
{canCancel && (
<Button
onClick={() => handleCancelChallenge(challenge.id)}
variant="secondary"
size="sm"
disabled={isPending}
>
Annuler
</Button>
)}
</div>
</div>
</Card>
);
})}
</div> </div>
)} )}
{/* Examples Section */}
<Card variant="dark" className="mt-8">
<button
onClick={() => setShowExamples(!showExamples)}
className="w-full flex items-center justify-between p-4 text-left"
>
<h3 className="text-lg font-bold text-pixel-gold">
💡 Exemples de défis
</h3>
<span className="text-pixel-gold text-xl">
{showExamples ? "" : "+"}
</span>
</button>
{showExamples && (
<div className="px-4 pb-4 space-y-4 border-t border-[var(--border)] pt-4">
<div className="space-y-3">
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
<h4 className="font-bold text-pixel-gold mb-2">
Qui participera à plus d&apos;événements ce mois ?
</h4>
<p className="text-sm text-gray-300">
Le joueur qui participe au plus grand nombre
d&apos;événements organisés ce mois remporte le défi. Les
événements doivent être validés par un admin pour compter.
</p>
<div className="mt-2 text-xs text-gray-400">
Points suggérés: 150
</div>
</div>
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
<h4 className="font-bold text-pixel-gold mb-2">
Premier à atteindre le niveau 10
</h4>
<p className="text-sm text-gray-300">
Le premier joueur à atteindre le niveau 10 remporte le défi.
Le niveau est calculé automatiquement selon le score total.
</p>
<div className="mt-2 text-xs text-gray-400">
Points suggérés: 200
</div>
</div>
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
<h4 className="font-bold text-pixel-gold mb-2">
Meilleur feedback sur un événement
</h4>
<p className="text-sm text-gray-300">
Le joueur qui donne le feedback le plus détaillé et
constructif sur un événement remporte le défi. L&apos;admin
désignera le gagnant selon la qualité du feedback.
</p>
<div className="mt-2 text-xs text-gray-400">
Points suggérés: 100
</div>
</div>
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
<h4 className="font-bold text-pixel-gold mb-2">
Plus grand nombre de points gagnés cette semaine
</h4>
<p className="text-sm text-gray-300">
Le joueur qui accumule le plus de points cette semaine
remporte le défi. Seuls les points gagnés après
l&apos;acceptation du défi comptent.
</p>
<div className="mt-2 text-xs text-gray-400">
Points suggérés: 250
</div>
</div>
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
<h4 className="font-bold text-pixel-gold mb-2">
Défi créatif : meilleure bio de profil
</h4>
<p className="text-sm text-gray-300">
Le joueur avec la bio de profil la plus créative et
originale remporte le défi. L&apos;admin désignera le
gagnant selon l&apos;originalité et la qualité de la bio.
</p>
<div className="mt-2 text-xs text-gray-400">
Points suggérés: 120
</div>
</div>
</div>
</div>
)}
</Card>
</div> </div>
</section> </section>
); );

View File

@@ -115,6 +115,8 @@ export default function EventsPageSection({
// Ref pour tracker si on a déjà utilisé les données initiales // Ref pour tracker si on a déjà utilisé les données initiales
const hasUsedInitialData = useRef(hasInitialData); const hasUsedInitialData = useRef(hasInitialData);
// Ref pour tracker si on a déjà fait les appels API
const hasFetchedRegistrations = useRef(false);
// Séparer et trier les événements (du plus récent au plus ancien) // Séparer et trier les événements (du plus récent au plus ancien)
// Le statut est calculé automatiquement en fonction de la date // Le statut est calculé automatiquement en fonction de la date
@@ -179,11 +181,24 @@ export default function EventsPageSection({
return; return;
} }
// Si on a déjà fait les appels API, ne pas refaire
if (hasFetchedRegistrations.current) {
return;
}
// Si pas de session, ne rien faire (on garde les données vides) // Si pas de session, ne rien faire (on garde les données vides)
if (!session?.user?.id) { if (!session?.user?.id) {
return; return;
} }
// Si pas d'événements, ne rien faire
if (events.length === 0) {
return;
}
// Marquer qu'on va faire les appels
hasFetchedRegistrations.current = true;
// Charger les inscriptions depuis l'API seulement si on n'a pas de données initiales // Charger les inscriptions depuis l'API seulement si on n'a pas de données initiales
// On charge pour tous les événements (passés et à venir) pour permettre le feedback // On charge pour tous les événements (passés et à venir) pour permettre le feedback
const checkRegistrations = async () => { const checkRegistrations = async () => {
@@ -206,7 +221,8 @@ export default function EventsPageSection({
}; };
checkRegistrations(); checkRegistrations();
}, [session?.user?.id, events]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.user?.id]);
// Fonctions pour le calendrier // Fonctions pour le calendrier
const getDaysInMonth = (date: Date) => { const getDaysInMonth = (date: Date) => {
@@ -564,6 +580,8 @@ export default function EventsPageSection({
...prev, ...prev,
[eventId]: true, [eventId]: true,
})); }));
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
} else { } else {
setError(result.error || "Une erreur est survenue"); setError(result.error || "Une erreur est survenue");
} }
@@ -583,6 +601,8 @@ export default function EventsPageSection({
...prev, ...prev,
[eventId]: false, [eventId]: false,
})); }));
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
} else { } else {
setError(result.error || "Une erreur est survenue"); setError(result.error || "Une erreur est survenue");
} }

View File

@@ -9,34 +9,61 @@ interface EventsSectionProps {
} }
export default function EventsSection({ events }: EventsSectionProps) { export default function EventsSection({ events }: EventsSectionProps) {
if (events.length === 0) {
return null;
}
return ( return (
<section className="w-full bg-gray-950 border-t border-pixel-gold/30 py-16"> <section
className="w-full py-16 border-t relative z-10"
style={{
backgroundColor: "var(--card-column)",
borderColor: "color-mix(in srgb, var(--pixel-gold) 50%, transparent)",
borderTopWidth: "2px",
}}
>
<div className="max-w-7xl mx-auto px-8"> <div className="max-w-7xl mx-auto px-8">
<div className="flex flex-col md:flex-row items-center justify-around gap-8"> {events.length === 0 ? (
{events.map((event, index) => ( <div className="text-center">
<div key={index} className="flex flex-col items-center"> <p
<div className="flex flex-col items-center mb-4"> className="text-base"
<span className="text-pixel-gold text-xs uppercase tracking-widest mb-2"> style={{ color: "var(--muted-foreground)" }}
Événement >
</span> Aucun événement à venir pour le moment
<div className="w-16 h-px bg-pixel-gold"></div> </p>
</div>
) : (
<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-xs uppercase tracking-widest mb-2"
style={{ color: "var(--pixel-gold)" }}
>
Événement
</span>
<div
className="w-16 h-px"
style={{ backgroundColor: "var(--pixel-gold)" }}
></div>
</div>
<div
className="text-lg font-bold mb-2 uppercase tracking-wide"
style={{ color: "var(--foreground)" }}
>
{new Date(event.date).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</div>
<div
className="text-base text-center"
style={{ color: "var(--foreground)" }}
>
{event.name}
</div>
</div> </div>
<div className="text-white text-lg font-bold mb-2 uppercase tracking-wide"> ))}
{new Date(event.date).toLocaleDateString("fr-FR", { </div>
day: "numeric", )}
month: "long",
year: "numeric",
})}
</div>
<div className="text-white text-base text-center">
{event.name}
</div>
</div>
))}
</div>
</div> </div>
</section> </section>
); );

View File

@@ -68,18 +68,22 @@ export default function FeedbackModal({
if (!eventId) return; if (!eventId) return;
try { try {
// Récupérer l'événement // Paralléliser les appels API
const eventResponse = await fetch(`/api/events/${eventId}`); const [eventResponse, feedbackResponse] = await Promise.all([
fetch(`/api/events/${eventId}`),
fetch(`/api/feedback/${eventId}`),
]);
if (!eventResponse.ok) { if (!eventResponse.ok) {
setError("Événement introuvable"); setError("Événement introuvable");
setLoading(false); setLoading(false);
return; return;
} }
const eventData = await eventResponse.json(); const eventData = await eventResponse.json();
setEvent(eventData); setEvent(eventData);
// Récupérer le feedback existant si disponible // Traiter le feedback
const feedbackResponse = await fetch(`/api/feedback/${eventId}`);
if (feedbackResponse.ok) { if (feedbackResponse.ok) {
const feedbackData = await feedbackResponse.json(); const feedbackData = await feedbackResponse.json();
if (feedbackData.feedback) { if (feedbackData.feedback) {
@@ -151,6 +155,9 @@ export default function FeedbackModal({
}); });
} }
// Rafraîchir le score dans le header
window.dispatchEvent(new Event("refreshUserScore"));
// Fermer la modale après 1.5 secondes // Fermer la modale après 1.5 secondes
setTimeout(() => { setTimeout(() => {
onClose(); onClose();

View File

@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Avatar from "@/components/ui/Avatar";
import { requestToJoin } from "@/actions/houses/requests";
import { useTransition } from "react";
import Alert from "@/components/ui/Alert";
interface House {
id: string;
name: string;
description: string | null;
creator: {
id: string;
username: string;
avatar: string | null;
};
memberships?: Array<{
id: string;
role: string;
user: {
id: string;
username: string;
avatar: string | null;
score?: number;
level?: number;
};
}>;
_count?: {
memberships: number;
};
}
interface HouseCardProps {
house: House;
onRequestSent?: () => void;
}
export default function HouseCard({ house, onRequestSent }: HouseCardProps) {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const isMember = house.memberships?.some(
(m) => m.user.id === session?.user?.id
);
const memberCount = house._count?.memberships || house.memberships?.length || 0;
const handleRequestToJoin = () => {
if (!session?.user?.id) return;
setError(null);
setSuccess(null);
startTransition(async () => {
const result = await requestToJoin(house.id);
if (result.success) {
// Rafraîchir le badge d'invitations/demandes dans le header
window.dispatchEvent(new Event("refreshInvitations"));
setSuccess("Demande envoyée avec succès");
onRequestSent?.();
} else {
setError(result.error || "Erreur lors de l'envoi de la demande");
}
});
};
return (
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-lg sm:text-xl font-bold mb-2 break-words" style={{ color: "var(--foreground)" }}>
{house.name}
</h3>
{house.description && (
<p className="text-sm mb-2 break-words" style={{ color: "var(--muted-foreground)" }}>
{house.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs" style={{ color: "var(--muted-foreground)" }}>
<span>Créée par {house.creator.username}</span>
<span className="hidden sm:inline"></span>
<span>{memberCount} membre{memberCount > 1 ? "s" : ""}</span>
</div>
</div>
</div>
{error && (
<Alert variant="error" className="mb-4">
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="mb-4">
{success}
</Alert>
)}
{session?.user?.id && !isMember && (
<Button
onClick={handleRequestToJoin}
disabled={isPending}
variant="primary"
size="sm"
className="w-full sm:w-auto"
>
{isPending ? "Envoi..." : "Demander à rejoindre"}
</Button>
)}
{isMember && (
<div className="text-xs mb-4" style={{ color: "var(--success)" }}>
Vous êtes membre
</div>
)}
{/* Members List */}
{house.memberships && house.memberships.length > 0 && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
<h4 className="text-xs font-bold uppercase tracking-wider mb-3" style={{ color: "var(--muted-foreground)" }}>
Membres ({house.memberships.length})
</h4>
<div className="flex flex-wrap gap-2">
{house.memberships.map((membership) => (
<div
key={membership.id}
className="flex items-center gap-2 p-2 rounded"
style={{ backgroundColor: "var(--card-hover)" }}
title={`${membership.user.username} (${membership.role})${membership.user.score !== undefined ? ` - ${membership.user.score} pts` : ""}`}
>
<Avatar
src={membership.user.avatar}
username={membership.user.username}
size="sm"
className="flex-shrink-0"
borderClassName="border-pixel-gold/30"
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<span className="text-xs font-semibold truncate" style={{ color: "var(--foreground)" }}>
{membership.user.username}
</span>
{membership.role === "OWNER" && (
<span className="text-[10px] uppercase" style={{ color: "var(--accent)" }}>
👑
</span>
)}
</div>
{membership.user.score !== undefined && membership.user.level !== undefined && (
<div className="text-[10px]" style={{ color: "var(--muted-foreground)" }}>
{membership.user.score} pts Lv.{membership.user.level}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Textarea from "@/components/ui/Textarea";
import Alert from "@/components/ui/Alert";
import { createHouse } from "@/actions/houses/create";
import { updateHouse } from "@/actions/houses/update";
interface HouseFormProps {
house?: {
id: string;
name: string;
description: string | null;
};
onSuccess?: () => void;
onCancel?: () => void;
}
export default function HouseForm({
house,
onSuccess,
onCancel,
}: HouseFormProps) {
const [name, setName] = useState(house?.name || "");
const [description, setDescription] = useState(house?.description || "");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = house
? await updateHouse(house.id, { name, description: description || null })
: await createHouse({ name, description: description || null });
if (result.success) {
// Rafraîchir le score dans le header si on crée une maison (pas si on met à jour)
if (!house) {
window.dispatchEvent(new Event("refreshUserScore"));
}
onSuccess?.();
} else {
setError(result.error || "Une erreur est survenue");
}
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && <Alert variant="error">{error}</Alert>}
<Input
label="Nom de la maison"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={3}
maxLength={50}
disabled={isPending}
/>
<Textarea
label="Description (optionnelle)"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
disabled={isPending}
rows={4}
/>
<div className="flex flex-col sm:flex-row gap-2">
<Button type="submit" disabled={isPending} variant="primary" className="w-full sm:w-auto">
{isPending ? "Enregistrement..." : house ? "Modifier" : "Créer"}
</Button>
{onCancel && (
<Button
type="button"
onClick={onCancel}
disabled={isPending}
variant="secondary"
className="w-full sm:w-auto"
>
Annuler
</Button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,628 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import { useSession } from "next-auth/react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import HouseForm from "./HouseForm";
import RequestList from "./RequestList";
import Alert from "@/components/ui/Alert";
import { deleteHouse, leaveHouse, removeMember } from "@/actions/houses/update";
import { inviteUser, cancelInvitation } from "@/actions/houses/invitations";
interface House {
id: string;
name: string;
description: string | null;
creator: {
id: string;
username: string;
avatar: string | null;
};
memberships?: Array<{
id: string;
role: string;
user: {
id: string;
username: string;
avatar: string | null;
score?: number;
level?: number;
};
}>;
}
interface User {
id: string;
username: string;
avatar: string | null;
}
interface HouseInvitation {
id: string;
invitee: {
id: string;
username: string;
avatar: string | null;
};
inviter: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
interface HouseManagementProps {
house: House | null;
users?: User[];
requests?: Array<{
id: string;
requester: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}>;
onUpdate?: () => void;
}
interface Request {
id: string;
requester: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
export default function HouseManagement({
house,
users = [],
requests: initialRequests = [],
onUpdate,
}: HouseManagementProps) {
const { data: session } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [showInviteForm, setShowInviteForm] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [requests, setRequests] = useState<Request[]>(initialRequests);
const [invitations, setInvitations] = useState<HouseInvitation[]>([]);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const userRole = house?.memberships?.find(
(m) => m.user.id === session?.user?.id
)?.role;
const isOwner = userRole === "OWNER";
const isAdmin = userRole === "ADMIN" || isOwner;
const pendingRequests = requests.filter((r) => r.status === "PENDING");
useEffect(() => {
const fetchRequests = async () => {
if (!house || !isAdmin) return;
try {
const response = await fetch(
`/api/houses/${house.id}/requests?status=PENDING`
);
if (response.ok) {
const data = await response.json();
setRequests(data);
}
} catch (error) {
console.error("Error fetching requests:", error);
}
};
fetchRequests();
}, [house, isAdmin]);
const fetchInvitations = useCallback(async () => {
if (!house || !isAdmin) return;
try {
const response = await fetch(
`/api/houses/${house.id}/invitations?status=PENDING`
);
if (response.ok) {
const data = await response.json();
setInvitations(data);
}
} catch (error) {
console.error("Error fetching invitations:", error);
}
}, [house, isAdmin]);
useEffect(() => {
// Utiliser un timeout pour éviter l'appel synchrone de setState dans l'effect
const timeout = setTimeout(() => {
fetchInvitations();
}, 0);
return () => clearTimeout(timeout);
}, [fetchInvitations]);
const handleUpdate = useCallback(() => {
fetchInvitations();
onUpdate?.();
}, [fetchInvitations, onUpdate]);
const handleDelete = () => {
if (
!house ||
!confirm("Êtes-vous sûr de vouloir supprimer cette maison ?")
) {
return;
}
setError(null);
startTransition(async () => {
const result = await deleteHouse(house.id);
if (result.success) {
// Rafraîchir le score dans le header (le créateur perd des points)
window.dispatchEvent(new Event("refreshUserScore"));
handleUpdate();
} else {
setError(result.error || "Erreur lors de la suppression");
}
});
};
const handleLeave = () => {
if (!house || !confirm("Êtes-vous sûr de vouloir quitter cette maison ?")) {
return;
}
setError(null);
startTransition(async () => {
const result = await leaveHouse(house.id);
if (result.success) {
window.dispatchEvent(new Event("refreshUserScore"));
handleUpdate();
} else {
setError(result.error || "Erreur lors de la sortie");
}
});
};
const handleInvite = () => {
if (!house || !selectedUserId) return;
setError(null);
setSuccess(null);
startTransition(async () => {
const result = await inviteUser(house.id, selectedUserId);
if (result.success) {
// Rafraîchir le badge d'invitations/demandes dans le header (pour l'invité)
window.dispatchEvent(new Event("refreshInvitations"));
setSuccess("Invitation envoyée");
setShowInviteForm(false);
setSelectedUserId("");
// Rafraîchir la liste des invitations
await fetchInvitations();
handleUpdate();
} else {
setError(result.error || "Erreur lors de l'envoi de l'invitation");
}
});
};
const availableUsers = users.filter(
(u) =>
u.id !== session?.user?.id &&
!house?.memberships?.some((m) => m.user.id === u.id)
);
if (!house) {
return (
<Card className="p-6">
<h2
className="text-lg sm:text-xl font-bold mb-4"
style={{ color: "var(--foreground)" }}
>
Ma Maison
</h2>
<p
className="text-sm mb-4"
style={{ color: "var(--muted-foreground)" }}
>
Vous n&apos;êtes membre d&apos;aucune maison pour le moment.
</p>
</Card>
);
}
return (
<div className="space-y-6">
<Card
className="p-4 sm:p-6"
style={{
borderColor: `color-mix(in srgb, var(--accent) 40%, var(--border))`,
borderWidth: "2px",
boxShadow: `0 0 20px color-mix(in srgb, var(--accent) 10%, transparent)`,
}}
>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
<div className="flex-1 min-w-0">
<h3
className="text-xl sm:text-2xl font-bold mb-2 break-words"
style={{
color: "var(--accent)",
textShadow: `0 0 10px color-mix(in srgb, var(--accent) 30%, transparent)`,
}}
>
{house.name}
</h3>
{house.description && (
<p
className="text-sm mt-2 break-words"
style={{ color: "var(--muted-foreground)" }}
>
{house.description}
</p>
)}
</div>
<div className="flex flex-wrap gap-2 sm:flex-nowrap">
{isAdmin && (
<>
<Button
onClick={() => setIsEditing(!isEditing)}
variant="secondary"
size="sm"
className="flex-1 sm:flex-none"
>
{isEditing ? "Annuler" : "Modifier"}
</Button>
{isOwner && (
<Button
onClick={handleDelete}
variant="danger"
size="sm"
className="flex-1 sm:flex-none"
>
Supprimer
</Button>
)}
</>
)}
{!isOwner && (
<Button
onClick={handleLeave}
variant="danger"
size="sm"
className="flex-1 sm:flex-none"
>
Quitter
</Button>
)}
</div>
</div>
{error && (
<Alert variant="error" className="mb-4">
{error}
</Alert>
)}
{success && (
<Alert variant="success" className="mb-4">
{success}
</Alert>
)}
{isEditing ? (
<HouseForm
house={house}
onSuccess={() => {
setIsEditing(false);
handleUpdate();
}}
onCancel={() => setIsEditing(false)}
/>
) : (
<div>
<h4
className="text-sm font-semibold uppercase tracking-wider mb-3"
style={{
color: "var(--primary)",
borderBottom: `2px solid color-mix(in srgb, var(--primary) 30%, transparent)`,
paddingBottom: "0.5rem",
}}
>
Membres ({house.memberships?.length ?? 0})
{isAdmin && invitations.length > 0 && (
<span className="ml-2 text-xs normal-case" style={{ color: "var(--muted-foreground)" }}>
{invitations.length} invitation{invitations.length > 1 ? "s" : ""} en cours
</span>
)}
</h4>
<div className="space-y-2">
{(house.memberships || []).map((membership) => {
const isCurrentUser = membership.user.id === session?.user?.id;
const roleColor =
membership.role === "OWNER"
? "var(--accent)"
: membership.role === "ADMIN"
? "var(--primary)"
: "var(--muted-foreground)";
return (
<div
key={membership.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded"
style={{
backgroundColor: isCurrentUser
? "color-mix(in srgb, var(--primary) 10%, var(--card-hover))"
: "var(--card-hover)",
borderLeft: `3px solid ${roleColor}`,
borderColor: isCurrentUser
? "var(--primary)"
: "transparent",
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{membership.user.avatar && (
<img
src={membership.user.avatar}
alt={membership.user.username}
className="w-8 h-8 rounded-full flex-shrink-0 border-2"
style={{ borderColor: roleColor }}
/>
)}
<div className="min-w-0">
<span
className="font-semibold block sm:inline"
style={{
color: isCurrentUser
? "var(--primary)"
: "var(--foreground)",
}}
>
{membership.user.username}
{isCurrentUser && " (Vous)"}
</span>
<span
className="text-xs block sm:inline sm:ml-2"
style={{ color: "var(--muted-foreground)" }}
>
<span style={{ color: "var(--success)" }}>
{membership.user.score} pts
</span>
{" • "}
<span style={{ color: "var(--blue)" }}>
Niveau {membership.user.level}
</span>
</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className="text-xs uppercase px-2 py-1 rounded font-bold"
style={{
color: roleColor,
backgroundColor: `color-mix(in srgb, ${roleColor} 15%, transparent)`,
border: `1px solid color-mix(in srgb, ${roleColor} 30%, transparent)`,
}}
>
{membership.role === "OWNER" && "👑 "}
{membership.role}
</span>
{isAdmin &&
!isCurrentUser &&
(isOwner || membership.role === "MEMBER") &&
membership.role !== "OWNER" && (
<Button
onClick={() => {
if (
confirm(
`Êtes-vous sûr de vouloir retirer ${membership.user.username} de la maison ?`
)
) {
startTransition(async () => {
const result = await removeMember(
house.id,
membership.user.id
);
if (result.success) {
// Rafraîchir le score dans le header (le membre retiré perd des points)
window.dispatchEvent(
new Event("refreshUserScore")
);
handleUpdate();
} else {
setError(
result.error ||
"Erreur lors du retrait du membre"
);
}
});
}
}}
disabled={isPending}
variant="danger"
size="sm"
>
Retirer
</Button>
)}
</div>
</div>
);
})}
</div>
{isAdmin && invitations.length > 0 && (
<div className="mt-4">
<h5
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{
color: "var(--primary)",
opacity: 0.7,
}}
>
Invitations en cours
</h5>
<div className="space-y-2">
{invitations
.filter((inv) => inv.status === "PENDING")
.map((invitation) => (
<div
key={invitation.id}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded"
style={{
backgroundColor: "var(--card-hover)",
borderLeft: `3px solid var(--primary)`,
opacity: 0.8,
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{invitation.invitee.avatar && (
<img
src={invitation.invitee.avatar}
alt={invitation.invitee.username}
className="w-8 h-8 rounded-full flex-shrink-0 border-2"
style={{ borderColor: "var(--primary)" }}
/>
)}
<div className="min-w-0">
<span
className="font-semibold block sm:inline"
style={{ color: "var(--foreground)" }}
>
{invitation.invitee.username}
</span>
<span
className="text-xs block sm:inline sm:ml-2"
style={{ color: "var(--muted-foreground)" }}
>
Invité par {invitation.inviter.username}
</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className="text-xs uppercase px-2 py-1 rounded font-bold"
style={{
color: "var(--primary)",
backgroundColor: `color-mix(in srgb, var(--primary) 15%, transparent)`,
border: `1px solid color-mix(in srgb, var(--primary) 30%, transparent)`,
}}
>
En attente
</span>
<Button
onClick={() => {
if (
confirm(
`Êtes-vous sûr de vouloir annuler l'invitation pour ${invitation.invitee.username} ?`
)
) {
startTransition(async () => {
const result = await cancelInvitation(
invitation.id
);
if (result.success) {
window.dispatchEvent(
new Event("refreshInvitations")
);
handleUpdate();
} else {
setError(
result.error ||
"Erreur lors de l'annulation"
);
}
});
}
}}
disabled={isPending}
variant="danger"
size="sm"
>
Annuler
</Button>
</div>
</div>
))}
</div>
</div>
)}
{isAdmin && (
<div className="mt-4">
{showInviteForm ? (
<div className="space-y-2">
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="w-full p-2 rounded border"
style={{
backgroundColor: "var(--input)",
borderColor: "var(--border)",
color: "var(--foreground)",
}}
>
<option value="">Sélectionner un utilisateur</option>
{availableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
<div className="flex gap-2">
<Button
onClick={handleInvite}
disabled={!selectedUserId || isPending}
variant="primary"
size="sm"
>
{isPending ? "Envoi..." : "Inviter"}
</Button>
<Button
onClick={() => {
setShowInviteForm(false);
setSelectedUserId("");
}}
variant="secondary"
size="sm"
>
Annuler
</Button>
</div>
</div>
) : (
<Button
onClick={() => setShowInviteForm(true)}
variant="primary"
size="sm"
>
Inviter un utilisateur
</Button>
)}
</div>
)}
</div>
)}
</Card>
{isAdmin && pendingRequests.length > 0 && (
<Card className="p-4 sm:p-6">
<h2
className="text-lg sm:text-xl font-bold mb-4"
style={{
color: "var(--purple)",
borderBottom: `2px solid color-mix(in srgb, var(--purple) 30%, transparent)`,
paddingBottom: "0.5rem",
}}
>
Demandes d&apos;adhésion
</h2>
<RequestList requests={pendingRequests} onUpdate={handleUpdate} />
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,265 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import SectionTitle from "@/components/ui/SectionTitle";
import BackgroundSection from "@/components/ui/BackgroundSection";
import HouseCard from "./HouseCard";
import HouseForm from "./HouseForm";
import HouseManagement from "./HouseManagement";
import InvitationList from "./InvitationList";
import Input from "@/components/ui/Input";
interface House {
id: string;
name: string;
description: string | null;
creator: {
id: string;
username: string;
avatar: string | null;
};
memberships?: Array<{
id: string;
role: string;
user: {
id: string;
username: string;
avatar: string | null;
score?: number;
level?: number;
};
}>;
_count?: {
memberships: number;
};
}
interface User {
id: string;
username: string;
avatar: string | null;
}
interface HousesSectionProps {
initialHouses?: House[];
initialMyHouse?: House | null;
initialUsers?: User[];
initialInvitations?: Array<{
id: string;
house: {
id: string;
name: string;
};
inviter: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}>;
backgroundImage: string;
}
export default function HousesSection({
initialHouses = [],
initialMyHouse = null,
initialUsers = [],
initialInvitations = [],
backgroundImage,
}: HousesSectionProps) {
const { data: session } = useSession();
const [houses, setHouses] = useState<House[]>(initialHouses);
const [myHouse, setMyHouse] = useState<House | null>(initialMyHouse);
const [invitations, setInvitations] = useState(initialInvitations);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const fetchHouses = useCallback(async () => {
try {
const params = new URLSearchParams();
if (searchTerm) {
params.append("search", searchTerm);
}
params.append("include", "members,creator");
const response = await fetch(`/api/houses?${params}`);
if (response.ok) {
const data = await response.json();
setHouses(data);
}
} catch (error) {
console.error("Error fetching houses:", error);
}
}, [searchTerm]);
const fetchMyHouse = async () => {
try {
const response = await fetch("/api/houses/my-house");
if (response.ok) {
const data = await response.json();
setMyHouse(data);
} else if (response.status === 404) {
setMyHouse(null);
}
} catch (error) {
console.error("Error fetching my house:", error);
}
};
const fetchInvitations = async () => {
try {
const response = await fetch("/api/invitations?status=PENDING");
if (response.ok) {
const data = await response.json();
setInvitations(data);
}
} catch (error) {
console.error("Error fetching invitations:", error);
}
};
useEffect(() => {
if (searchTerm) {
const timeout = setTimeout(() => {
fetchHouses();
}, 300);
return () => clearTimeout(timeout);
} else {
// Utiliser un timeout pour éviter setState synchrone dans effect
const timeout = setTimeout(() => {
fetchHouses();
}, 0);
return () => clearTimeout(timeout);
}
}, [searchTerm, fetchHouses]);
const handleUpdate = () => {
fetchMyHouse();
fetchHouses();
fetchInvitations();
};
const filteredHouses = houses.filter((house) => {
if (!myHouse) return true;
return house.id !== myHouse.id;
});
return (
<BackgroundSection backgroundImage={backgroundImage}>
{/* Title Section */}
<SectionTitle
variant="gradient"
size="xl"
subtitle="Rejoignez une maison ou créez la vôtre"
className="mb-16"
>
MAISONS
</SectionTitle>
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
Formez des équipes, créez votre propre maison et rivalisez avec les
autres maisons pour dominer le classement collectif
</p>
<div className="space-y-4 sm:space-y-6">
{session?.user && (
<>
{invitations.length > 0 && (
<Card className="p-4 sm:p-6">
<h2
className="text-lg sm:text-xl font-bold mb-4"
style={{ color: "var(--foreground)" }}
>
Mes Invitations
</h2>
<InvitationList
invitations={invitations}
onUpdate={handleUpdate}
/>
</Card>
)}
<Card className="p-4 sm:p-6">
<h2
className="text-lg sm:text-xl font-bold mb-4"
style={{ color: "var(--foreground)" }}
>
Ma Maison
</h2>
{myHouse ? (
<HouseManagement
house={myHouse}
users={initialUsers}
onUpdate={handleUpdate}
/>
) : (
<div>
{showCreateForm ? (
<HouseForm
onSuccess={() => {
setShowCreateForm(false);
handleUpdate();
}}
onCancel={() => setShowCreateForm(false)}
/>
) : (
<div>
<p
className="text-sm mb-4 break-words"
style={{ color: "var(--muted-foreground)" }}
>
Vous n&apos;êtes membre d&apos;aucune maison. Créez-en
une ou demandez à rejoindre une maison existante.
</p>
<Button
onClick={() => setShowCreateForm(true)}
variant="primary"
className="w-full sm:w-auto"
>
Créer une maison
</Button>
</div>
)}
</div>
)}
</Card>
</>
)}
<Card className="p-4 sm:p-6">
<h2
className="text-lg sm:text-xl font-bold mb-4"
style={{ color: "var(--foreground)" }}
>
Toutes les Maisons
</h2>
<div className="mb-4">
<Input
placeholder="Rechercher une maison..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{filteredHouses.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Aucune maison trouvée
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredHouses.map((house) => (
<HouseCard
key={house.id}
house={house}
onRequestSent={handleUpdate}
/>
))}
</div>
)}
</Card>
</div>
</BackgroundSection>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { useState, useTransition } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import {
acceptInvitation,
rejectInvitation,
} from "@/actions/houses/invitations";
import Alert from "@/components/ui/Alert";
interface Invitation {
id: string;
house: {
id: string;
name: string;
};
inviter: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
interface InvitationListProps {
invitations: Invitation[];
onUpdate?: () => void;
}
export default function InvitationList({
invitations,
onUpdate,
}: InvitationListProps) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleAccept = (invitationId: string) => {
setError(null);
startTransition(async () => {
const result = await acceptInvitation(invitationId);
if (result.success) {
// Rafraîchir le score dans le header (l'utilisateur reçoit des points)
window.dispatchEvent(new Event("refreshUserScore"));
// Rafraîchir le badge d'invitations dans le header
window.dispatchEvent(new Event("refreshInvitations"));
onUpdate?.();
} else {
setError(result.error || "Erreur lors de l'acceptation");
}
});
};
const handleReject = (invitationId: string) => {
setError(null);
startTransition(async () => {
const result = await rejectInvitation(invitationId);
if (result.success) {
// Rafraîchir le badge d'invitations dans le header
window.dispatchEvent(new Event("refreshInvitations"));
onUpdate?.();
} else {
setError(result.error || "Erreur lors du refus");
}
});
};
if (invitations.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Aucune invitation en attente
</p>
);
}
return (
<div className="space-y-4">
{error && <Alert variant="error">{error}</Alert>}
{invitations.map((invitation) => (
<Card key={invitation.id} className="p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-bold mb-1 break-words" style={{ color: "var(--foreground)" }}>
Invitation de {invitation.inviter.username}
</h4>
<p className="text-sm mb-2 break-words" style={{ color: "var(--muted-foreground)" }}>
Pour rejoindre la maison <strong>{invitation.house.name}</strong>
</p>
</div>
{invitation.status === "PENDING" && (
<div className="flex gap-2 sm:flex-nowrap">
<Button
onClick={() => handleAccept(invitation.id)}
disabled={isPending}
variant="success"
size="sm"
className="flex-1 sm:flex-none"
>
Accepter
</Button>
<Button
onClick={() => handleReject(invitation.id)}
disabled={isPending}
variant="danger"
size="sm"
className="flex-1 sm:flex-none"
>
Refuser
</Button>
</div>
)}
{invitation.status === "ACCEPTED" && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--success)" }}>
Acceptée
</span>
)}
{invitation.status === "REJECTED" && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--destructive)" }}>
Refusée
</span>
)}
</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState, useTransition } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import {
acceptRequest,
rejectRequest,
} from "@/actions/houses/requests";
import Alert from "@/components/ui/Alert";
interface Request {
id: string;
requester: {
id: string;
username: string;
avatar: string | null;
};
status: string;
createdAt: string;
}
interface RequestListProps {
requests: Request[];
onUpdate?: () => void;
}
export default function RequestList({
requests,
onUpdate,
}: RequestListProps) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleAccept = (requestId: string) => {
setError(null);
startTransition(async () => {
const result = await acceptRequest(requestId);
if (result.success) {
// Rafraîchir le score dans le header (le requester reçoit des points)
window.dispatchEvent(new Event("refreshUserScore"));
// Rafraîchir le badge d'invitations/demandes dans le header (le requester n'a plus de demande en attente)
window.dispatchEvent(new Event("refreshInvitations"));
onUpdate?.();
} else {
setError(result.error || "Erreur lors de l'acceptation");
}
});
};
const handleReject = (requestId: string) => {
setError(null);
startTransition(async () => {
const result = await rejectRequest(requestId);
if (result.success) {
// Rafraîchir le badge d'invitations/demandes dans le header (le requester n'a plus de demande en attente)
window.dispatchEvent(new Event("refreshInvitations"));
onUpdate?.();
} else {
setError(result.error || "Erreur lors du refus");
}
});
};
if (requests.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Aucune demande en attente
</p>
);
}
return (
<div className="space-y-4">
{error && <Alert variant="error">{error}</Alert>}
{requests.map((request) => (
<Card key={request.id} className="p-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-bold mb-1 break-words" style={{ color: "var(--foreground)" }}>
{request.requester.username}
</h4>
<p className="text-sm break-words" style={{ color: "var(--muted-foreground)" }}>
souhaite rejoindre votre maison
</p>
</div>
{request.status === "PENDING" && (
<div className="flex gap-2 sm:flex-nowrap">
<Button
onClick={() => handleAccept(request.id)}
disabled={isPending}
variant="success"
size="sm"
className="flex-1 sm:flex-none"
>
Accepter
</Button>
<Button
onClick={() => handleReject(request.id)}
disabled={isPending}
variant="danger"
size="sm"
className="flex-1 sm:flex-none"
>
Refuser
</Button>
</div>
)}
{request.status === "ACCEPTED" && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--success)" }}>
Acceptée
</span>
)}
{request.status === "REJECTED" && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--destructive)" }}>
Refusée
</span>
)}
</div>
</Card>
))}
</div>
);
}

View File

@@ -5,7 +5,7 @@ import Link from "next/link";
export default function Footer() { export default function Footer() {
return ( return (
<footer <footer
className="w-full py-6 px-4 sm:px-8 border-t" className="w-full py-6 px-4 sm:px-8 border-t relative z-10"
style={{ style={{
backgroundColor: "var(--background)", backgroundColor: "var(--background)",
borderColor: "color-mix(in srgb, var(--gray-800) 30%, transparent)", borderColor: "color-mix(in srgb, var(--gray-800) 30%, transparent)",

View File

@@ -26,8 +26,29 @@ interface LeaderboardEntry {
characterClass?: CharacterClass | null; characterClass?: CharacterClass | null;
} }
interface HouseMember {
id: string;
username: string;
avatar: string | null;
score: number;
level: number;
role: string;
}
interface HouseLeaderboardEntry {
rank: number;
houseId: string;
houseName: string;
totalScore: number;
memberCount: number;
averageScore: number;
description: string | null;
members: HouseMember[];
}
interface LeaderboardSectionProps { interface LeaderboardSectionProps {
leaderboard: LeaderboardEntry[]; leaderboard: LeaderboardEntry[];
houseLeaderboard: HouseLeaderboardEntry[];
backgroundImage: string; backgroundImage: string;
} }
@@ -38,25 +59,34 @@ const formatScore = (score: number): string => {
export default function LeaderboardSection({ export default function LeaderboardSection({
leaderboard, leaderboard,
houseLeaderboard,
backgroundImage, backgroundImage,
}: LeaderboardSectionProps) { }: LeaderboardSectionProps) {
const [selectedEntry, setSelectedEntry] = useState<LeaderboardEntry | null>( const [selectedEntry, setSelectedEntry] = useState<LeaderboardEntry | null>(
null null
); );
const [selectedHouse, setSelectedHouse] = useState<HouseLeaderboardEntry | null>(
null
);
return ( return (
<BackgroundSection backgroundImage={backgroundImage}> <BackgroundSection backgroundImage={backgroundImage}>
{/* Title Section */} {/* Title Section */}
<SectionTitle <SectionTitle
variant="gradient" variant="gradient"
size="lg" size="xl"
subtitle="Top Players" subtitle="Top Players"
className="mb-12 overflow-hidden" className="mb-16"
> >
LEADERBOARD LEADERBOARD
</SectionTitle> </SectionTitle>
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
Consultez le classement des meilleurs joueurs et des maisons les plus
performantes. Montez dans les rangs en participant aux événements et en
relevant des défis
</p>
{/* Leaderboard Table */} {/* Players Leaderboard Table */}
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg backdrop-blur-sm overflow-x-auto"> <div className="bg-black/60 border border-pixel-gold/30 rounded-lg backdrop-blur-sm overflow-x-auto">
{/* Header */} {/* Header */}
<div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 font-bold text-[10px] sm:text-xs uppercase tracking-widest text-gray-300"> <div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 font-bold text-[10px] sm:text-xs uppercase tracking-widest text-gray-300">
@@ -143,6 +173,90 @@ export default function LeaderboardSection({
</div> </div>
</div> </div>
{/* House Leaderboard Table */}
<div className="mt-12">
<SectionTitle
variant="gradient"
size="md"
subtitle="Top Houses"
className="mb-8 overflow-hidden"
>
MAISONS
</SectionTitle>
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg backdrop-blur-sm overflow-x-auto">
{/* Header */}
<div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 font-bold text-[10px] sm:text-xs uppercase tracking-widest text-gray-300">
<div className="col-span-2 sm:col-span-1 text-center">Rank</div>
<div className="col-span-5 sm:col-span-6">Maison</div>
<div className="col-span-3 text-right">Score Total</div>
<div className="col-span-2 text-right">Membres</div>
</div>
{/* Entries */}
<div className="divide-y divide-pixel-gold/10 overflow-visible">
{houseLeaderboard.map((house) => (
<div
key={house.houseId}
className={`grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 hover:bg-gray-900/50 transition relative cursor-pointer ${
house.rank <= 3
? "bg-gradient-to-r from-pixel-gold/10 via-pixel-gold/5 to-transparent"
: "bg-black/40"
}`}
onClick={() => setSelectedHouse(house)}
>
{/* Rank */}
<div className="col-span-2 sm:col-span-1 flex items-center justify-center">
<span
className={`inline-flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-full font-bold text-xs sm:text-sm ${
house.rank === 1
? "bg-gradient-to-br from-pixel-gold to-orange-500 text-black shadow-lg shadow-pixel-gold/50"
: house.rank === 2
? "bg-gradient-to-br from-gray-400 to-gray-500 text-black"
: house.rank === 3
? "bg-gradient-to-br from-orange-700 to-orange-800 text-white"
: "bg-gray-900 text-gray-400 border border-gray-800"
}`}
>
{house.rank}
</span>
</div>
{/* House Name */}
<div className="col-span-5 sm:col-span-6 flex items-center gap-2 sm:gap-3 min-w-0">
<div className="flex items-center gap-1 sm:gap-2 min-w-0">
<span
className={`font-bold text-xs sm:text-sm break-words ${
house.rank <= 3 ? "text-pixel-gold" : "text-white"
}`}
>
{house.houseName}
</span>
{house.rank <= 3 && (
<span className="text-pixel-gold text-xs"></span>
)}
</div>
</div>
{/* Total Score */}
<div className="col-span-3 flex items-center justify-end">
<span className="font-mono text-gray-300 text-xs sm:text-sm">
{formatScore(house.totalScore)}
</span>
</div>
{/* Member Count */}
<div className="col-span-2 flex items-center justify-end">
<span className="font-bold text-gray-400 text-xs sm:text-sm">
{house.memberCount}
</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Footer Info */} {/* Footer Info */}
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
@@ -151,6 +265,112 @@ export default function LeaderboardSection({
<p className="text-gray-600 text-xs mt-2">Rankings update every hour</p> <p className="text-gray-600 text-xs mt-2">Rankings update every hour</p>
</div> </div>
{/* House Modal */}
{selectedHouse && (
<Modal
isOpen={!!selectedHouse}
onClose={() => setSelectedHouse(null)}
size="md"
>
<div className="p-4 sm:p-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl sm:text-3xl font-bold text-pixel-gold uppercase tracking-wider break-words">
{selectedHouse.houseName}
</h2>
<CloseButton onClick={() => setSelectedHouse(null)} size="md" />
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<Card variant="default" className="p-4">
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
Rank
</div>
<div className="text-2xl font-bold text-pixel-gold">
#{selectedHouse.rank}
</div>
</Card>
<Card variant="default" className="p-4">
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
Score Total
</div>
<div className="text-2xl font-bold text-pixel-gold">
{formatScore(selectedHouse.totalScore)}
</div>
</Card>
<Card variant="default" className="p-4">
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
Membres
</div>
<div className="text-2xl font-bold text-pixel-gold">
{selectedHouse.memberCount}
</div>
</Card>
</div>
{/* Members List */}
<div className="border-t border-pixel-gold/30 pt-6 mb-6">
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-4 font-bold">
Membres ({selectedHouse.memberCount})
</div>
<div className="space-y-2">
{selectedHouse.members.map((member) => (
<div
key={member.id}
className="flex items-center justify-between p-3 rounded"
style={{ backgroundColor: "var(--card-hover)" }}
>
<div className="flex items-center gap-3">
<Avatar
src={member.avatar}
username={member.username}
size="sm"
className="flex-shrink-0"
borderClassName="border-pixel-gold/30"
/>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-sm" style={{ color: "var(--foreground)" }}>
{member.username}
</span>
<span className="text-xs uppercase" style={{ color: "var(--accent)" }}>
{member.role}
</span>
</div>
<div className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Niveau {member.level}
</div>
</div>
</div>
<div className="text-right">
<div className="font-mono text-sm font-bold" style={{ color: "var(--foreground)" }}>
{formatScore(member.score)}
</div>
<div className="text-xs" style={{ color: "var(--muted-foreground)" }}>
points
</div>
</div>
</div>
))}
</div>
</div>
{/* Description */}
{selectedHouse.description && (
<div className="border-t border-pixel-gold/30 pt-6">
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-3 font-bold">
Description
</div>
<p className="text-gray-200 leading-relaxed whitespace-pre-wrap break-words">
{selectedHouse.description}
</p>
</div>
)}
</div>
</Modal>
)}
{/* Character Modal */} {/* Character Modal */}
{selectedEntry && ( {selectedEntry && (
<Modal <Modal

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ChallengeBadgeProps {
initialCount?: number;
onNavigate?: () => void;
}
export default function ChallengeBadge({
initialCount = 0,
onNavigate,
}: ChallengeBadgeProps) {
const [count, setCount] = useState(initialCount);
// Utiliser le count initial (déjà récupéré côté serveur)
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
// Écouter les événements de refresh des défis (déclenché après acceptation/annulation)
useEffect(() => {
const handleRefreshChallenges = async () => {
try {
const response = await fetch("/api/challenges/active-count");
const data = await response.json();
setCount(data.count || 0);
} catch (error) {
console.error("Error fetching active challenges count:", error);
}
};
window.addEventListener("refreshChallenges", handleRefreshChallenges);
return () => {
window.removeEventListener("refreshChallenges", handleRefreshChallenges);
};
}, []);
return (
<Link
href="/challenges"
onClick={onNavigate}
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
onNavigate ? "py-2" : ""
}`}
style={{ color: "var(--foreground)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--foreground)")}
title={
count > 0
? `${count} défi${count > 1 ? "s" : ""} actif${count > 1 ? "s" : ""}`
: "Défis"
}
>
<span>DÉFIS</span>
{count > 0 && (
<span
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
style={{
backgroundColor: "var(--accent)",
color: "var(--background)",
}}
>
{count > 9 ? "9+" : count}
</span>
)}
</Link>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface InvitationBadgeProps {
initialCount?: number;
onNavigate?: () => void;
}
export default function InvitationBadge({
initialCount = 0,
onNavigate,
}: InvitationBadgeProps) {
const [count, setCount] = useState(initialCount);
// Utiliser le count initial (déjà récupéré côté serveur)
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
// Écouter les événements de refresh des invitations (déclenché après acceptation/refus)
useEffect(() => {
const handleRefreshInvitations = async () => {
try {
const response = await fetch("/api/invitations/pending-count");
const data = await response.json();
setCount(data.count || 0);
} catch (error) {
console.error("Error fetching pending invitations count:", error);
}
};
window.addEventListener("refreshInvitations", handleRefreshInvitations);
return () => {
window.removeEventListener("refreshInvitations", handleRefreshInvitations);
};
}, []);
return (
<Link
href="/houses"
onClick={onNavigate}
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
onNavigate ? "py-2" : ""
}`}
style={{ color: "var(--foreground)" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--foreground)")}
title={
count > 0
? `${count} action${count > 1 ? "s" : ""} en attente (invitations et demandes)`
: "Maisons"
}
>
<span>MAISONS</span>
{count > 0 && (
<span
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
style={{
backgroundColor: "var(--accent)",
color: "var(--background)",
}}
>
{count > 9 ? "9+" : count}
</span>
)}
</Link>
);
}

View File

@@ -6,6 +6,8 @@ import { useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import PlayerStats from "@/components/profile/PlayerStats"; import PlayerStats from "@/components/profile/PlayerStats";
import { Button, ThemeToggle } from "@/components/ui"; import { Button, ThemeToggle } from "@/components/ui";
import ChallengeBadge from "./ChallengeBadge";
import InvitationBadge from "./InvitationBadge";
interface UserData { interface UserData {
username: string; username: string;
@@ -15,16 +17,21 @@ interface UserData {
xp: number; xp: number;
maxXp: number; maxXp: number;
level: number; level: number;
score: number;
} }
interface NavigationProps { interface NavigationProps {
initialUserData?: UserData | null; initialUserData?: UserData | null;
initialIsAdmin?: boolean; initialIsAdmin?: boolean;
initialActiveChallengesCount?: number;
initialPendingInvitationsCount?: number;
} }
export default function Navigation({ export default function Navigation({
initialUserData, initialUserData,
initialIsAdmin, initialIsAdmin,
initialActiveChallengesCount = 0,
initialPendingInvitationsCount = 0,
}: NavigationProps) { }: NavigationProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -114,19 +121,10 @@ export default function Navigation({
LEADERBOARD LEADERBOARD
</Link> </Link>
{isAuthenticated && ( {isAuthenticated && (
<Link <>
href="/challenges" <InvitationBadge initialCount={initialPendingInvitationsCount} />
className="transition text-xs font-normal uppercase tracking-widest" <ChallengeBadge initialCount={initialActiveChallengesCount} />
style={{ color: "var(--foreground)" }} </>
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--accent-color)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--foreground)")
}
>
DÉFIS
</Link>
)} )}
{isAdmin && ( {isAdmin && (
<Link <Link
@@ -287,20 +285,16 @@ export default function Navigation({
LEADERBOARD LEADERBOARD
</Link> </Link>
{isAuthenticated && ( {isAuthenticated && (
<Link <>
href="/challenges" <InvitationBadge
onClick={() => setIsMenuOpen(false)} initialCount={initialPendingInvitationsCount}
className="transition text-xs font-normal uppercase tracking-widest py-2" onNavigate={() => setIsMenuOpen(false)}
style={{ color: "var(--foreground)" }} />
onMouseEnter={(e) => <ChallengeBadge
(e.currentTarget.style.color = "var(--accent-color)") initialCount={initialActiveChallengesCount}
} onNavigate={() => setIsMenuOpen(false)}
onMouseLeave={(e) => />
(e.currentTarget.style.color = "var(--foreground)") </>
}
>
DÉFIS
</Link>
)} )}
{isAdmin && ( {isAdmin && (
<Link <Link

View File

@@ -1,5 +1,7 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { userService } from "@/services/users/user.service"; import { userService } from "@/services/users/user.service";
import { challengeService } from "@/services/challenges/challenge.service";
import { houseService } from "@/services/houses/house.service";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
interface UserData { interface UserData {
@@ -10,6 +12,7 @@ interface UserData {
xp: number; xp: number;
maxXp: number; maxXp: number;
level: number; level: number;
score: number;
} }
export default async function NavigationWrapper() { export default async function NavigationWrapper() {
@@ -17,22 +20,40 @@ export default async function NavigationWrapper() {
let userData: UserData | null = null; let userData: UserData | null = null;
const isAdmin = session?.user?.role === "ADMIN"; const isAdmin = session?.user?.role === "ADMIN";
let activeChallengesCount = 0;
let pendingHouseActionsCount = 0;
if (session?.user?.id) { if (session?.user?.id) {
const user = await userService.getUserById(session.user.id, { // Paralléliser les appels DB
username: true, const [user, challengesCount, houseActionsCount] = await Promise.all([
avatar: true, userService.getUserById(session.user.id, {
hp: true, username: true,
maxHp: true, avatar: true,
xp: true, hp: true,
maxXp: true, maxHp: true,
level: true, xp: true,
}); maxXp: true,
level: true,
score: true,
}),
challengeService.getActiveChallengesCount(session.user.id),
houseService.getPendingHouseActionsCount(session.user.id),
]);
if (user) { if (user) {
userData = user; userData = user;
} }
activeChallengesCount = challengesCount;
pendingHouseActionsCount = houseActionsCount;
} }
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />; return (
<Navigation
initialUserData={userData}
initialIsAdmin={isAdmin}
initialActiveChallengesCount={activeChallengesCount}
initialPendingInvitationsCount={pendingHouseActionsCount}
/>
);
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { Avatar } from "@/components/ui"; import { Avatar } from "@/components/ui";
@@ -13,6 +13,7 @@ interface UserData {
xp: number; xp: number;
maxXp: number; maxXp: number;
level: number; level: number;
score: number;
} }
interface PlayerStatsProps { interface PlayerStatsProps {
@@ -32,6 +33,7 @@ const defaultUserData: UserData = {
xp: 0, xp: 0,
maxXp: 5000, maxXp: 5000,
level: 1, level: 1,
score: 0,
}; };
export default function PlayerStats({ initialUserData }: PlayerStatsProps) { export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
@@ -40,6 +42,31 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
initialUserData || defaultUserData initialUserData || defaultUserData
); );
const refreshUserData = useCallback(async () => {
if (!session?.user?.id) return;
try {
const res = await fetch(`/api/users/${session.user.id}`);
const data = await res.json();
if (data) {
requestAnimationFrame(() => {
setUserData({
username: data.username || "Guest",
avatar: data.avatar,
hp: data.hp || 1000,
maxHp: data.maxHp || 1000,
xp: data.xp || 0,
maxXp: data.maxXp || 5000,
level: data.level || 1,
score: data.score || 0,
});
});
}
} catch (error) {
console.error("Error refreshing user data:", error);
}
}, [session]);
useEffect(() => { useEffect(() => {
// Si on a déjà des données initiales, ne rien faire (déjà initialisé dans useState) // Si on a déjà des données initiales, ne rien faire (déjà initialisé dans useState)
if (initialUserData) { if (initialUserData) {
@@ -62,6 +89,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
xp: data.xp || 0, xp: data.xp || 0,
maxXp: data.maxXp || 5000, maxXp: data.maxXp || 5000,
level: data.level || 1, level: data.level || 1,
score: data.score || 0,
}); });
}); });
} }
@@ -77,6 +105,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
xp: 0, xp: 0,
maxXp: 5000, maxXp: 5000,
level: 1, level: 1,
score: 0,
}); });
}); });
}); });
@@ -88,51 +117,19 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
} }
}, [session, initialUserData]); }, [session, initialUserData]);
const { username, avatar, hp, maxHp, xp, maxXp, level } = userData; // Écouter les événements de refresh du score
// Calculer les pourcentages cibles
const targetHpPercentage = (hp / maxHp) * 100;
const targetXpPercentage = (xp / maxXp) * 100;
// Initialiser les pourcentages à 0 si on a des données initiales (pour l'animation)
// Sinon utiliser directement les valeurs calculées
const [hpPercentage, setHpPercentage] = useState(
initialUserData ? 0 : targetHpPercentage
);
const [xpPercentage, setXpPercentage] = useState(
initialUserData ? 0 : targetXpPercentage
);
useEffect(() => { useEffect(() => {
// Si on a des données initiales, animer depuis 0 vers la valeur cible const handleRefreshScore = () => {
if (initialUserData) { refreshUserData();
const hpTimer = setTimeout(() => { };
setHpPercentage(targetHpPercentage);
}, 100);
const xpTimer = setTimeout(() => { window.addEventListener("refreshUserScore", handleRefreshScore);
setXpPercentage(targetXpPercentage); return () => {
}, 200); window.removeEventListener("refreshUserScore", handleRefreshScore);
};
}, [refreshUserData]);
return () => { const { username, avatar, level, score } = userData;
clearTimeout(hpTimer);
clearTimeout(xpTimer);
};
}
// Sinon, mettre à jour directement (pour les pages Client Components)
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
requestAnimationFrame(() => {
setHpPercentage(targetHpPercentage);
setXpPercentage(targetXpPercentage);
});
}, [targetHpPercentage, targetXpPercentage, initialUserData]);
const hpColor =
hpPercentage > 60
? "from-green-600 to-green-700"
: hpPercentage > 30
? "from-yellow-600 to-orange-700"
: "from-red-700 to-red-900";
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -150,7 +147,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
</Link> </Link>
{/* Stats */} {/* Stats */}
<div className="flex flex-col gap-1.5 min-w-[180px] sm:min-w-[200px]"> <div className="flex flex-col gap-1.5 min-w-[140px] sm:min-w-[160px]">
{/* Username & Level */} {/* Username & Level */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
@@ -166,57 +163,16 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
</div> </div>
</div> </div>
{/* Bars side by side */} {/* Score Display */}
<div className="flex flex-col gap-1"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="text-gray-400 font-pixel text-xs uppercase">
{/* HP Bar */} Score
<div className="relative h-2 flex-1 bg-gray-900 border border-gray-700 rounded overflow-hidden">
<div
className={`absolute inset-0 bg-gradient-to-r ${hpColor} transition-all duration-1000 ease-out`}
style={{ width: `${hpPercentage}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
</div>
{hpPercentage < 30 && (
<div className="absolute inset-0 border border-red-500 rounded animate-pulse"></div>
)}
</div>
{/* XP Bar */}
<div className="relative h-2 flex-1 bg-gray-900 border border-pixel-gold/30 rounded overflow-hidden">
<div
className="absolute inset-0 bg-gradient-to-r from-pixel-gold/80 via-pixel-gold/70 to-pixel-gold/80 transition-all duration-1000 ease-out"
style={{ width: `${xpPercentage}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
</div>
</div>
</div> </div>
{/* Labels */} <div className="text-pixel-gold font-gaming font-bold text-sm">
<div className="flex items-center gap-2 text-[8px] font-pixel text-gray-400"> {formatNumber(score)}
<div className="flex-1 text-left">
HP {hp} / {maxHp}
</div>
<div className="flex-1 text-right">
XP {formatNumber(xp)} / {formatNumber(maxXp)}
</div>
</div> </div>
</div> </div>
</div> </div>
<style jsx>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
`}</style>
</div> </div>
); );
} }

View File

@@ -171,12 +171,17 @@ export default function ProfileForm({
{/* Title Section */} {/* Title Section */}
<SectionTitle <SectionTitle
variant="gradient" variant="gradient"
size="lg" size="xl"
subtitle="Gérez votre profil" subtitle="Gérez votre profil"
className="mb-12" className="mb-16"
> >
PROFIL PROFIL
</SectionTitle> </SectionTitle>
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
Personnalisez votre avatar, votre bio, votre classe de personnage et
consultez vos statistiques. Gérez vos préférences et votre mot de
passe
</p>
{/* Profile Card */} {/* Profile Card */}
<Card variant="default" className="overflow-hidden"> <Card variant="default" className="overflow-hidden">

View File

@@ -10,6 +10,7 @@ interface AvatarProps {
className?: string; className?: string;
borderClassName?: string; borderClassName?: string;
fallbackText?: string; fallbackText?: string;
style?: React.CSSProperties;
} }
const sizeClasses = { const sizeClasses = {
@@ -28,6 +29,7 @@ export default function Avatar({
className = "", className = "",
borderClassName = "", borderClassName = "",
fallbackText, fallbackText,
style,
}: AvatarProps) { }: AvatarProps) {
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
const prevSrcRef = useRef<string | null | undefined>(undefined); const prevSrcRef = useRef<string | null | undefined>(undefined);
@@ -53,6 +55,7 @@ export default function Avatar({
style={{ style={{
backgroundColor: "var(--card)", backgroundColor: "var(--card)",
borderColor: "var(--border)", borderColor: "var(--border)",
...style,
}} }}
> >
{displaySrc ? ( {displaySrc ? (

View File

@@ -22,7 +22,7 @@ export default function BackgroundSection({
> >
{/* Background Image */} {/* Background Image */}
<div <div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="fixed inset-0 bg-cover bg-center bg-no-repeat z-0"
style={{ style={{
backgroundImage: `url('${backgroundImage}')`, backgroundImage: `url('${backgroundImage}')`,
}} }}

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes, ReactNode } from "react";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> { interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode; children: ReactNode;
variant?: "default" | "success" | "warning" | "danger" | "info"; variant?: "default" | "success" | "warning" | "danger" | "info";
size?: "sm" | "md"; size?: "xs" | "sm" | "md";
} }
const variantClasses = { const variantClasses = {
@@ -17,6 +17,7 @@ const variantClasses = {
}; };
const sizeClasses = { const sizeClasses = {
xs: "px-1.5 py-0.5 text-[9px] sm:text-[10px]",
sm: "px-2 py-1 text-[10px] sm:text-xs", sm: "px-2 py-1 text-[10px] sm:text-xs",
md: "px-3 py-1 text-xs", md: "px-3 py-1 text-xs",
}; };

View File

@@ -1,13 +1,17 @@
"use client"; "use client";
import { ButtonHTMLAttributes, ReactNode, ElementType } from "react"; import { ButtonHTMLAttributes, ReactNode, ElementType } from "react";
import Link from "next/link";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "success" | "danger" | "ghost"; variant?: "primary" | "secondary" | "success" | "danger" | "ghost";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
children: ReactNode; children: ReactNode;
as?: ElementType; as?: ElementType;
} } & (
| { as?: Exclude<ElementType, typeof Link> }
| { as: typeof Link; href: string }
);
const variantClasses = { const variantClasses = {
primary: "btn-primary border transition-colors", primary: "btn-primary border transition-colors",

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
@@ -37,7 +38,7 @@ export default function Modal({
if (!isOpen) return null; if (!isOpen) return null;
return ( const modalContent = (
<div <div
className="fixed inset-0 z-[200] flex items-center justify-center p-4 backdrop-blur-sm" className="fixed inset-0 z-[200] flex items-center justify-center p-4 backdrop-blur-sm"
style={{ style={{
@@ -59,4 +60,11 @@ export default function Modal({
</div> </div>
</div> </div>
); );
// Utiliser un portal pour rendre le modal directement dans le body
if (typeof window !== "undefined") {
return createPortal(modalContent, document.body);
}
return null;
} }

39
components/ui/Select.tsx Normal file
View File

@@ -0,0 +1,39 @@
"use client";
import { SelectHTMLAttributes, forwardRef } from "react";
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, className = "", children, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-bold text-pixel-gold mb-2">
{label}
</label>
)}
<select
ref={ref}
className={`w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300 focus:outline-none focus:ring-2 focus:ring-pixel-gold/50 focus:border-pixel-gold transition ${className} ${
error ? "border-red-500" : ""
}`}
{...props}
>
{children}
</select>
{error && (
<p className="mt-1 text-xs text-red-400">{error}</p>
)}
</div>
);
}
);
Select.displayName = "Select";
export default Select;

View File

@@ -2,6 +2,7 @@ export { default as Avatar } from "./Avatar";
export { default as Button } from "./Button"; export { default as Button } from "./Button";
export { default as Input } from "./Input"; export { default as Input } from "./Input";
export { default as Textarea } from "./Textarea"; export { default as Textarea } from "./Textarea";
export { default as Select } from "./Select";
export { default as Card } from "./Card"; export { default as Card } from "./Card";
export { default as Modal } from "./Modal"; export { default as Modal } from "./Modal";
export { default as Badge } from "./Badge"; export { default as Badge } from "./Badge";

View File

@@ -1,6 +1,31 @@
version: "3.8" version: "3.8"
services: services:
got-postgres:
image: postgres:15-alpine
container_name: got-mc-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-gotgaming}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-this-in-production}
POSTGRES_DB: ${POSTGRES_DB:-gotgaming}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
volumes:
- ${POSTGRES_DATA_PATH:-./data/postgres}:/var/lib/postgresql/data
ports:
- "5433:5432"
restart: unless-stopped
command: postgres -c max_connections=100 -c shared_buffers=256MB -c effective_cache_size=1GB
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-gotgaming} -d ${POSTGRES_DB:-gotgaming}",
]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
got-app: got-app:
build: build:
context: . context: .
@@ -10,15 +35,21 @@ services:
- "3040:3000" - "3040:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=file:/app/data/dev.db - POSTGRES_USER=${POSTGRES_USER:-gotgaming}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change-this-in-production}
- POSTGRES_DB=${POSTGRES_DB:-gotgaming}
- DATABASE_URL=postgresql://${POSTGRES_USER:-gotgaming}:${POSTGRES_PASSWORD:-change-this-in-production}@got-postgres:5432/${POSTGRES_DB:-gotgaming}?schema=public
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-change-this-secret-in-production} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-change-this-secret-in-production}
volumes: volumes:
# Persist database (override DATA_PATH env var to change location)
- ${PRISMA_DATA_PATH:-/Volumes/EXTERNAL_USB/sites/got-gaming/data}:/app/data
# Persist uploaded images (avatars and backgrounds) # Persist uploaded images (avatars and backgrounds)
- ${UPLOADS_PATH:-./public/uploads}:/app/public/uploads - ${UPLOADS_PATH:-./public/uploads}:/app/public/uploads
- ./prisma/migrations:/app/prisma/migrations # Migrations: décommenter uniquement en développement local pour modifier les migrations sans rebuild
# En production, les migrations sont incluses dans l'image Docker
# - ./prisma/migrations:/app/prisma/migrations
depends_on:
got-postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
labels: labels:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"

View File

@@ -5,6 +5,9 @@ interface Preferences {
homeBackground: string | null; homeBackground: string | null;
eventsBackground: string | null; eventsBackground: string | null;
leaderboardBackground: string | null; leaderboardBackground: string | null;
challengesBackground: string | null;
profileBackground: string | null;
houseBackground: string | null;
} }
export function usePreferences() { export function usePreferences() {
@@ -21,6 +24,9 @@ export function usePreferences() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
} }
); );
setLoading(false); setLoading(false);
@@ -30,6 +36,9 @@ export function usePreferences() {
homeBackground: null, homeBackground: null,
eventsBackground: null, eventsBackground: null,
leaderboardBackground: null, leaderboardBackground: null,
challengesBackground: null,
profileBackground: null,
houseBackground: null,
}); });
setLoading(false); setLoading(false);
}); });
@@ -39,7 +48,7 @@ export function usePreferences() {
} }
export function useBackgroundImage( export function useBackgroundImage(
page: "home" | "events" | "leaderboard", page: "home" | "events" | "leaderboard" | "challenges" | "profile" | "houses",
defaultImage: string defaultImage: string
) { ) {
const { preferences } = usePreferences(); const { preferences } = usePreferences();
@@ -48,7 +57,9 @@ export function useBackgroundImage(
useEffect(() => { useEffect(() => {
if (preferences) { if (preferences) {
const imageKey = `${page}Background` as keyof Preferences; // Mapping spécial pour "houses" -> "house" (car la colonne est houseBackground)
const dbPage = page === "houses" ? "house" : page;
const imageKey = `${dbPage}Background` as keyof Preferences;
const customImage = preferences[imageKey]; const customImage = preferences[imageKey];
const rawImage = customImage || defaultImage; const rawImage = customImage || defaultImage;
// Normaliser l'URL pour utiliser l'API si nécessaire // Normaliser l'URL pour utiliser l'API si nécessaire

View File

@@ -1,7 +1,7 @@
import { sitePreferencesService } from "@/services/preferences/site-preferences.service"; import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
export async function getBackgroundImage( export async function getBackgroundImage(
page: "home" | "events" | "leaderboard", page: "home" | "events" | "leaderboard" | "challenges" | "profile" | "houses",
defaultImage: string defaultImage: string
): Promise<string> { ): Promise<string> {
return sitePreferencesService.getBackgroundImage(page, defaultImage); return sitePreferencesService.getBackgroundImage(page, defaultImage);

View File

@@ -1,10 +1,32 @@
import { PrismaClient } from "@/prisma/generated/prisma/client"; import { PrismaClient } from "@/prisma/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
const adapter = new PrismaBetterSqlite3({ // Construire DATABASE_URL si elle n'est pas définie, en utilisant les variables individuelles
url: process.env.DATABASE_URL || "file:./data/dev.db", let databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
const user = process.env.POSTGRES_USER || "gotgaming";
const password = process.env.POSTGRES_PASSWORD || "change-this-in-production";
const host = process.env.POSTGRES_HOST || "got-postgres";
const port = process.env.POSTGRES_PORT || "5432";
const db = process.env.POSTGRES_DB || "gotgaming";
// Encoder le mot de passe pour l'URL
const encodedPassword = encodeURIComponent(password);
databaseUrl = `postgresql://${user}:${encodedPassword}@${host}:${port}/${db}?schema=public`;
}
if (typeof databaseUrl !== "string") {
throw new Error("DATABASE_URL must be a string");
}
const pool = new Pool({
connectionString: databaseUrl,
}); });
const adapter = new PrismaPg(pool);
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;
}; };
@@ -13,10 +35,7 @@ export const prisma =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient({
adapter, adapter,
log: log: ["error"],
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
}); });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -17,25 +17,24 @@
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"prisma", "prisma",
"@prisma/engines", "@prisma/engines"
"better-sqlite3"
] ]
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-better-sqlite3": "^7.1.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0", "@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.5.0",
"next": "15.5.9", "next": "15.5.9",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"pg": "^8.16.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pg": "^8.16.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/eslint-plugin": "^8.49.0",

225
pnpm-lock.yaml generated
View File

@@ -8,7 +8,7 @@ importers:
.: .:
dependencies: dependencies:
'@prisma/adapter-better-sqlite3': '@prisma/adapter-pg':
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@prisma/client': '@prisma/client':
@@ -17,15 +17,15 @@ importers:
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
better-sqlite3:
specifier: ^12.5.0
version: 12.5.0
next: next:
specifier: 15.5.9 specifier: 15.5.9
version: 15.5.9(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 15.5.9(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-auth: next-auth:
specifier: 5.0.0-beta.30 specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@15.5.9(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) version: 5.0.0-beta.30(next@15.5.9(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)
pg:
specifier: ^8.16.3
version: 8.16.3
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.1 version: 19.2.1
@@ -39,12 +39,12 @@ importers:
'@types/bcryptjs': '@types/bcryptjs':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.1 version: 22.19.1
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
'@types/react': '@types/react':
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7 version: 19.2.7
@@ -668,8 +668,8 @@ packages:
'@panva/hkdf@1.2.1': '@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@prisma/adapter-better-sqlite3@7.1.0': '@prisma/adapter-pg@7.1.0':
resolution: {integrity: sha512-Ex4CimAONWMoUrhU27lpGXb4MdX/59qj+4PBTIuPVJLXZfTxSWuU8KowlRtq1w5iE91WiwMgU1KgeBOKJ81nEA==} resolution: {integrity: sha512-DSAnUwkKfX4bUzhkrjGN4IBQzwg0nvFw2W17H0Oa532I5w9nLtTJ9mAEGDs1nUBEGRAsa0c7qsf8CSgfJ4DsBQ==}
'@prisma/client-runtime-utils@7.1.0': '@prisma/client-runtime-utils@7.1.0':
resolution: {integrity: sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==} resolution: {integrity: sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==}
@@ -742,9 +742,6 @@ packages:
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -757,6 +754,9 @@ packages:
'@types/node@22.19.1': '@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -2072,6 +2072,40 @@ packages:
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
pg-cloudflare@1.2.7:
resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==}
pg-connection-string@2.9.1:
resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.10.1:
resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.10.3:
resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.16.3:
resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2149,6 +2183,26 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-array@3.0.4:
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
engines: {node: '>=12'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres@3.4.7: postgres@3.4.7:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2372,6 +2426,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sqlstring@2.3.3: sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -2598,6 +2656,10 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3083,10 +3145,13 @@ snapshots:
'@panva/hkdf@1.2.1': {} '@panva/hkdf@1.2.1': {}
'@prisma/adapter-better-sqlite3@7.1.0': '@prisma/adapter-pg@7.1.0':
dependencies: dependencies:
'@prisma/driver-adapter-utils': 7.1.0 '@prisma/driver-adapter-utils': 7.1.0
better-sqlite3: 12.5.0 pg: 8.16.3
postgres-array: 3.0.4
transitivePeerDependencies:
- pg-native
'@prisma/client-runtime-utils@7.1.0': {} '@prisma/client-runtime-utils@7.1.0': {}
@@ -3184,10 +3249,6 @@ snapshots:
dependencies: dependencies:
bcryptjs: 3.0.3 bcryptjs: 3.0.3
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 22.19.1
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -3198,6 +3259,12 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/pg@8.16.0':
dependencies:
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/react-dom@19.2.3(@types/react@19.2.7)': '@types/react-dom@19.2.3(@types/react@19.2.7)':
dependencies: dependencies:
'@types/react': 19.2.7 '@types/react': 19.2.7
@@ -3479,7 +3546,8 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1:
optional: true
baseline-browser-mapping@2.9.5: {} baseline-browser-mapping@2.9.5: {}
@@ -3489,18 +3557,21 @@ snapshots:
dependencies: dependencies:
bindings: 1.5.0 bindings: 1.5.0
prebuild-install: 7.1.3 prebuild-install: 7.1.3
optional: true
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bindings@1.5.0: bindings@1.5.0:
dependencies: dependencies:
file-uri-to-path: 1.0.0 file-uri-to-path: 1.0.0
optional: true
bl@4.1.0: bl@4.1.0:
dependencies: dependencies:
buffer: 5.7.1 buffer: 5.7.1
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true
brace-expansion@1.1.12: brace-expansion@1.1.12:
dependencies: dependencies:
@@ -3527,6 +3598,7 @@ snapshots:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
optional: true
c12@3.1.0: c12@3.1.0:
dependencies: dependencies:
@@ -3598,7 +3670,8 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chownr@1.1.4: {} chownr@1.1.4:
optional: true
citty@0.1.6: citty@0.1.6:
dependencies: dependencies:
@@ -3663,8 +3736,10 @@ snapshots:
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
optional: true
deep-extend@0.6.0: {} deep-extend@0.6.0:
optional: true
deep-is@0.1.4: {} deep-is@0.1.4: {}
@@ -3688,7 +3763,8 @@ snapshots:
destr@2.0.5: {} destr@2.0.5: {}
detect-libc@2.1.2: {} detect-libc@2.1.2:
optional: true
didyoumean@1.2.2: {} didyoumean@1.2.2: {}
@@ -3722,6 +3798,7 @@ snapshots:
end-of-stream@1.4.5: end-of-stream@1.4.5:
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
optional: true
es-abstract@1.24.0: es-abstract@1.24.0:
dependencies: dependencies:
@@ -4060,7 +4137,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
expand-template@2.0.3: {} expand-template@2.0.3:
optional: true
exsolve@1.0.8: {} exsolve@1.0.8: {}
@@ -4102,7 +4180,8 @@ snapshots:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
file-uri-to-path@1.0.0: {} file-uri-to-path@1.0.0:
optional: true
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
@@ -4131,7 +4210,8 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fs-constants@1.0.0: {} fs-constants@1.0.0:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -4196,7 +4276,8 @@ snapshots:
nypm: 0.6.2 nypm: 0.6.2
pathe: 2.0.3 pathe: 2.0.3
github-from-package@0.0.0: {} github-from-package@0.0.0:
optional: true
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
@@ -4259,7 +4340,8 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
ieee754@1.2.1: {} ieee754@1.2.1:
optional: true
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -4272,9 +4354,11 @@ snapshots:
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
inherits@2.0.4: {} inherits@2.0.4:
optional: true
ini@1.3.8: {} ini@1.3.8:
optional: true
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
@@ -4496,7 +4580,8 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
mimic-response@3.1.0: {} mimic-response@3.1.0:
optional: true
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
@@ -4508,7 +4593,8 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
mkdirp-classic@0.5.3: {} mkdirp-classic@0.5.3:
optional: true
ms@2.1.3: {} ms@2.1.3: {}
@@ -4536,7 +4622,8 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
napi-build-utils@2.0.0: {} napi-build-utils@2.0.0:
optional: true
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
@@ -4574,6 +4661,7 @@ snapshots:
node-abi@3.85.0: node-abi@3.85.0:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
optional: true
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
@@ -4642,6 +4730,7 @@ snapshots:
once@1.4.0: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
optional: true
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
@@ -4680,6 +4769,41 @@ snapshots:
perfect-debounce@1.0.0: {} perfect-debounce@1.0.0: {}
pg-cloudflare@1.2.7:
optional: true
pg-connection-string@2.9.1: {}
pg-int8@1.0.1: {}
pg-pool@3.10.1(pg@8.16.3):
dependencies:
pg: 8.16.3
pg-protocol@1.10.3: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.16.3:
dependencies:
pg-connection-string: 2.9.1
pg-pool: 3.10.1(pg@8.16.3)
pg-protocol: 1.10.3
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.2.7
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
@@ -4742,6 +4866,18 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-array@3.0.4: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres@3.4.7: {} postgres@3.4.7: {}
preact-render-to-string@6.5.11(preact@10.24.3): preact-render-to-string@6.5.11(preact@10.24.3):
@@ -4764,6 +4900,7 @@ snapshots:
simple-get: 4.0.1 simple-get: 4.0.1
tar-fs: 2.1.4 tar-fs: 2.1.4
tunnel-agent: 0.6.0 tunnel-agent: 0.6.0
optional: true
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
@@ -4802,6 +4939,7 @@ snapshots:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
once: 1.4.0 once: 1.4.0
optional: true
punycode@2.3.1: {} punycode@2.3.1: {}
@@ -4820,6 +4958,7 @@ snapshots:
ini: 1.3.8 ini: 1.3.8
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
optional: true
react-dom@19.2.1(react@19.2.1): react-dom@19.2.1(react@19.2.1):
dependencies: dependencies:
@@ -4839,6 +4978,7 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
optional: true
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
@@ -4904,7 +5044,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
isarray: 2.0.5 isarray: 2.0.5
safe-buffer@5.2.1: {} safe-buffer@5.2.1:
optional: true
safe-push-apply@1.0.0: safe-push-apply@1.0.0:
dependencies: dependencies:
@@ -5019,16 +5160,20 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-concat@1.0.1: {} simple-concat@1.0.1:
optional: true
simple-get@4.0.1: simple-get@4.0.1:
dependencies: dependencies:
decompress-response: 6.0.0 decompress-response: 6.0.0
once: 1.4.0 once: 1.4.0
simple-concat: 1.0.1 simple-concat: 1.0.1
optional: true
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
split2@4.2.0: {}
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
@@ -5093,10 +5238,12 @@ snapshots:
string_decoder@1.3.0: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
optional: true
strip-bom@3.0.0: {} strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1:
optional: true
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
@@ -5157,6 +5304,7 @@ snapshots:
mkdirp-classic: 0.5.3 mkdirp-classic: 0.5.3
pump: 3.0.3 pump: 3.0.3
tar-stream: 2.2.0 tar-stream: 2.2.0
optional: true
tar-stream@2.2.0: tar-stream@2.2.0:
dependencies: dependencies:
@@ -5165,6 +5313,7 @@ snapshots:
fs-constants: 1.0.0 fs-constants: 1.0.0
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
@@ -5210,6 +5359,7 @@ snapshots:
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
optional: true
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
@@ -5359,7 +5509,10 @@ snapshots:
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrappy@1.0.2: {} wrappy@1.0.2:
optional: true
xtend@4.0.2: {}
yallist@3.1.1: {} yallist@3.1.1: {}

View File

@@ -1,53 +1,74 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
// @ts-nocheck // @ts-nocheck
/* /*
* This file should be your main import to use Prisma-related types and utilities in a browser. * This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types. * Use it to get access to models, enums, and input types.
* *
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only. * This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point. * See `client.ts` for the standard, server-side entry point.
* *
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
import * as Prisma from "./internal/prismaNamespaceBrowser"; import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }; export { Prisma }
export * as $Enums from "./enums"; export * as $Enums from './enums'
export * from "./enums"; export * from './enums';
/** /**
* Model User * Model User
* *
*/ */
export type User = Prisma.UserModel; export type User = Prisma.UserModel
/** /**
* Model UserPreferences * Model UserPreferences
* *
*/ */
export type UserPreferences = Prisma.UserPreferencesModel; export type UserPreferences = Prisma.UserPreferencesModel
/** /**
* Model Event * Model Event
* *
*/ */
export type Event = Prisma.EventModel; export type Event = Prisma.EventModel
/** /**
* Model EventRegistration * Model EventRegistration
* *
*/ */
export type EventRegistration = Prisma.EventRegistrationModel; export type EventRegistration = Prisma.EventRegistrationModel
/** /**
* Model EventFeedback * Model EventFeedback
* *
*/ */
export type EventFeedback = Prisma.EventFeedbackModel; export type EventFeedback = Prisma.EventFeedbackModel
/** /**
* Model SitePreferences * Model SitePreferences
* *
*/ */
export type SitePreferences = Prisma.SitePreferencesModel; export type SitePreferences = Prisma.SitePreferencesModel
/** /**
* Model Challenge * Model Challenge
* *
*/ */
export type Challenge = Prisma.ChallengeModel; export type Challenge = Prisma.ChallengeModel
/**
* Model House
*
*/
export type House = Prisma.HouseModel
/**
* Model HouseMembership
*
*/
export type HouseMembership = Prisma.HouseMembershipModel
/**
* Model HouseInvitation
*
*/
export type HouseInvitation = Prisma.HouseInvitationModel
/**
* Model HouseRequest
*
*/
export type HouseRequest = Prisma.HouseRequestModel

View File

@@ -1,7 +1,8 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
// @ts-nocheck // @ts-nocheck
/* /*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types. * This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead. * If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
@@ -9,21 +10,21 @@
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
import * as process from "node:process"; import * as process from 'node:process'
import * as path from "node:path"; import * as path from 'node:path'
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url'
globalThis["__dirname"] = path.dirname(fileURLToPath(import.meta.url)); globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"; import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"; import * as $Enums from "./enums"
import * as $Class from "./internal/class"; import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"; import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from "./enums"; export * as $Enums from './enums'
export * from "./enums"; export * from "./enums"
/** /**
* ## Prisma Client * ## Prisma Client
* *
* Type-safe database client for TypeScript * Type-safe database client for TypeScript
* @example * @example
* ``` * ```
@@ -31,51 +32,65 @@ export * from "./enums";
* // Fetch zero or more Users * // Fetch zero or more Users
* const users = await prisma.user.findMany() * const users = await prisma.user.findMany()
* ``` * ```
* *
* Read more in our [docs](https://pris.ly/d/client). * Read more in our [docs](https://pris.ly/d/client).
*/ */
export const PrismaClient = $Class.getPrismaClientClass(); export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient< export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
LogOpts extends Prisma.LogLevel = never, export { Prisma }
OmitOpts extends Prisma.PrismaClientOptions["omit"] =
Prisma.PrismaClientOptions["omit"],
ExtArgs extends runtime.Types.Extensions.InternalArgs =
runtime.Types.Extensions.DefaultArgs,
> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
export { Prisma };
/** /**
* Model User * Model User
* *
*/ */
export type User = Prisma.UserModel; export type User = Prisma.UserModel
/** /**
* Model UserPreferences * Model UserPreferences
* *
*/ */
export type UserPreferences = Prisma.UserPreferencesModel; export type UserPreferences = Prisma.UserPreferencesModel
/** /**
* Model Event * Model Event
* *
*/ */
export type Event = Prisma.EventModel; export type Event = Prisma.EventModel
/** /**
* Model EventRegistration * Model EventRegistration
* *
*/ */
export type EventRegistration = Prisma.EventRegistrationModel; export type EventRegistration = Prisma.EventRegistrationModel
/** /**
* Model EventFeedback * Model EventFeedback
* *
*/ */
export type EventFeedback = Prisma.EventFeedbackModel; export type EventFeedback = Prisma.EventFeedbackModel
/** /**
* Model SitePreferences * Model SitePreferences
* *
*/ */
export type SitePreferences = Prisma.SitePreferencesModel; export type SitePreferences = Prisma.SitePreferencesModel
/** /**
* Model Challenge * Model Challenge
* *
*/ */
export type Challenge = Prisma.ChallengeModel; export type Challenge = Prisma.ChallengeModel
/**
* Model House
*
*/
export type House = Prisma.HouseModel
/**
* Model HouseMembership
*
*/
export type HouseMembership = Prisma.HouseMembershipModel
/**
* Model HouseInvitation
*
*/
export type HouseInvitation = Prisma.HouseInvitationModel
/**
* Model HouseRequest
*
*/
export type HouseRequest = Prisma.HouseRequestModel

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,83 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
// @ts-nocheck // @ts-nocheck
/* /*
* This file exports all enum related types from the schema. * This file exports all enum related types from the schema.
* *
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
export const Role = { export const Role = {
USER: "USER", USER: 'USER',
ADMIN: "ADMIN", ADMIN: 'ADMIN'
} as const; } as const
export type Role = (typeof Role)[keyof typeof Role]
export type Role = (typeof Role)[keyof typeof Role];
export const EventType = { export const EventType = {
ATELIER: "ATELIER", ATELIER: 'ATELIER',
KATA: "KATA", KATA: 'KATA',
PRESENTATION: "PRESENTATION", PRESENTATION: 'PRESENTATION',
LEARNING_HOUR: "LEARNING_HOUR", LEARNING_HOUR: 'LEARNING_HOUR'
} as const; } as const
export type EventType = (typeof EventType)[keyof typeof EventType]
export type EventType = (typeof EventType)[keyof typeof EventType];
export const CharacterClass = { export const CharacterClass = {
WARRIOR: "WARRIOR", WARRIOR: 'WARRIOR',
MAGE: "MAGE", MAGE: 'MAGE',
ROGUE: "ROGUE", ROGUE: 'ROGUE',
RANGER: "RANGER", RANGER: 'RANGER',
PALADIN: "PALADIN", PALADIN: 'PALADIN',
ENGINEER: "ENGINEER", ENGINEER: 'ENGINEER',
MERCHANT: "MERCHANT", MERCHANT: 'MERCHANT',
SCHOLAR: "SCHOLAR", SCHOLAR: 'SCHOLAR',
BERSERKER: "BERSERKER", BERSERKER: 'BERSERKER',
NECROMANCER: "NECROMANCER", NECROMANCER: 'NECROMANCER'
} as const; } as const
export type CharacterClass = (typeof CharacterClass)[keyof typeof CharacterClass]
export type CharacterClass =
(typeof CharacterClass)[keyof typeof CharacterClass];
export const ChallengeStatus = { export const ChallengeStatus = {
PENDING: "PENDING", PENDING: 'PENDING',
ACCEPTED: "ACCEPTED", ACCEPTED: 'ACCEPTED',
COMPLETED: "COMPLETED", COMPLETED: 'COMPLETED',
REJECTED: "REJECTED", REJECTED: 'REJECTED',
CANCELLED: "CANCELLED", CANCELLED: 'CANCELLED'
} as const; } as const
export type ChallengeStatus = export type ChallengeStatus = (typeof ChallengeStatus)[keyof typeof ChallengeStatus]
(typeof ChallengeStatus)[keyof typeof ChallengeStatus];
export const HouseRole = {
OWNER: 'OWNER',
ADMIN: 'ADMIN',
MEMBER: 'MEMBER'
} as const
export type HouseRole = (typeof HouseRole)[keyof typeof HouseRole]
export const InvitationStatus = {
PENDING: 'PENDING',
ACCEPTED: 'ACCEPTED',
REJECTED: 'REJECTED',
CANCELLED: 'CANCELLED'
} as const
export type InvitationStatus = (typeof InvitationStatus)[keyof typeof InvitationStatus]
export const RequestStatus = {
PENDING: 'PENDING',
ACCEPTED: 'ACCEPTED',
REJECTED: 'REJECTED',
CANCELLED: 'CANCELLED'
} as const
export type RequestStatus = (typeof RequestStatus)[keyof typeof RequestStatus]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
// @ts-nocheck // @ts-nocheck
/* /*
* WARNING: This is an internal file that is subject to change! * WARNING: This is an internal file that is subject to change!
* *
@@ -14,185 +15,255 @@
* model files in the `model` directory! * model files in the `model` directory!
*/ */
import * as runtime from "@prisma/client/runtime/index-browser"; import * as runtime from "@prisma/client/runtime/index-browser"
export type * from "../models"; export type * from '../models'
export type * from "./prismaNamespace"; export type * from './prismaNamespace'
export const Decimal = runtime.Decimal
export const Decimal = runtime.Decimal;
export const NullTypes = { export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as new ( DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
secret: never JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
) => typeof runtime.DbNull, AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
JsonNull: runtime.NullTypes.JsonNull as new ( }
secret: never
) => typeof runtime.JsonNull,
AnyNull: runtime.NullTypes.AnyNull as new (
secret: never
) => typeof runtime.AnyNull,
};
/** /**
* Helper for filtering JSON entries that have `null` on the database (empty on the db) * Helper for filtering JSON entries that have `null` on the database (empty on the db)
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const DbNull = runtime.DbNull; export const DbNull = runtime.DbNull
/** /**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db) * Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const JsonNull = runtime.JsonNull; export const JsonNull = runtime.JsonNull
/** /**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull` * Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
* *
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field * @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/ */
export const AnyNull = runtime.AnyNull; export const AnyNull = runtime.AnyNull
export const ModelName = { export const ModelName = {
User: "User", User: 'User',
UserPreferences: "UserPreferences", UserPreferences: 'UserPreferences',
Event: "Event", Event: 'Event',
EventRegistration: "EventRegistration", EventRegistration: 'EventRegistration',
EventFeedback: "EventFeedback", EventFeedback: 'EventFeedback',
SitePreferences: "SitePreferences", SitePreferences: 'SitePreferences',
Challenge: "Challenge", Challenge: 'Challenge',
} as const; House: 'House',
HouseMembership: 'HouseMembership',
HouseInvitation: 'HouseInvitation',
HouseRequest: 'HouseRequest'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]; export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/* /*
* Enums * Enums
*/ */
export const TransactionIsolationLevel = { export const TransactionIsolationLevel = {
Serializable: "Serializable", ReadUncommitted: 'ReadUncommitted',
} as const; ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export type TransactionIsolationLevel =
(typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel];
export const UserScalarFieldEnum = { export const UserScalarFieldEnum = {
id: "id", id: 'id',
email: "email", email: 'email',
password: "password", password: 'password',
username: "username", username: 'username',
role: "role", role: 'role',
score: "score", score: 'score',
level: "level", level: 'level',
hp: "hp", hp: 'hp',
maxHp: "maxHp", maxHp: 'maxHp',
xp: "xp", xp: 'xp',
maxXp: "maxXp", maxXp: 'maxXp',
avatar: "avatar", avatar: 'avatar',
createdAt: "createdAt", createdAt: 'createdAt',
updatedAt: "updatedAt", updatedAt: 'updatedAt',
bio: "bio", bio: 'bio',
characterClass: "characterClass", characterClass: 'characterClass'
} as const; } as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export type UserScalarFieldEnum =
(typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum];
export const UserPreferencesScalarFieldEnum = { export const UserPreferencesScalarFieldEnum = {
id: "id", id: 'id',
userId: "userId", userId: 'userId',
homeBackground: "homeBackground", homeBackground: 'homeBackground',
eventsBackground: "eventsBackground", eventsBackground: 'eventsBackground',
leaderboardBackground: "leaderboardBackground", leaderboardBackground: 'leaderboardBackground',
theme: "theme", theme: 'theme',
createdAt: "createdAt", createdAt: 'createdAt',
updatedAt: "updatedAt", updatedAt: 'updatedAt'
} as const; } as const
export type UserPreferencesScalarFieldEnum = (typeof UserPreferencesScalarFieldEnum)[keyof typeof UserPreferencesScalarFieldEnum]
export type UserPreferencesScalarFieldEnum =
(typeof UserPreferencesScalarFieldEnum)[keyof typeof UserPreferencesScalarFieldEnum];
export const EventScalarFieldEnum = { export const EventScalarFieldEnum = {
id: "id", id: 'id',
date: "date", date: 'date',
name: "name", name: 'name',
description: "description", description: 'description',
type: "type", type: 'type',
room: "room", room: 'room',
time: "time", time: 'time',
maxPlaces: "maxPlaces", maxPlaces: 'maxPlaces',
createdAt: "createdAt", createdAt: 'createdAt',
updatedAt: "updatedAt", updatedAt: 'updatedAt'
} as const; } as const
export type EventScalarFieldEnum = (typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum]
export type EventScalarFieldEnum =
(typeof EventScalarFieldEnum)[keyof typeof EventScalarFieldEnum];
export const EventRegistrationScalarFieldEnum = { export const EventRegistrationScalarFieldEnum = {
id: "id", id: 'id',
userId: "userId", userId: 'userId',
eventId: "eventId", eventId: 'eventId',
createdAt: "createdAt", createdAt: 'createdAt'
} as const; } as const
export type EventRegistrationScalarFieldEnum = (typeof EventRegistrationScalarFieldEnum)[keyof typeof EventRegistrationScalarFieldEnum]
export type EventRegistrationScalarFieldEnum =
(typeof EventRegistrationScalarFieldEnum)[keyof typeof EventRegistrationScalarFieldEnum];
export const EventFeedbackScalarFieldEnum = { export const EventFeedbackScalarFieldEnum = {
id: "id", id: 'id',
userId: "userId", userId: 'userId',
eventId: "eventId", eventId: 'eventId',
rating: "rating", rating: 'rating',
comment: "comment", comment: 'comment',
createdAt: "createdAt", isRead: 'isRead',
updatedAt: "updatedAt", createdAt: 'createdAt',
} as const; updatedAt: 'updatedAt'
} as const
export type EventFeedbackScalarFieldEnum = (typeof EventFeedbackScalarFieldEnum)[keyof typeof EventFeedbackScalarFieldEnum]
export type EventFeedbackScalarFieldEnum =
(typeof EventFeedbackScalarFieldEnum)[keyof typeof EventFeedbackScalarFieldEnum];
export const SitePreferencesScalarFieldEnum = { export const SitePreferencesScalarFieldEnum = {
id: "id", id: 'id',
homeBackground: "homeBackground", homeBackground: 'homeBackground',
eventsBackground: "eventsBackground", eventsBackground: 'eventsBackground',
leaderboardBackground: "leaderboardBackground", leaderboardBackground: 'leaderboardBackground',
createdAt: "createdAt", challengesBackground: 'challengesBackground',
updatedAt: "updatedAt", profileBackground: 'profileBackground',
} as const; houseBackground: 'houseBackground',
eventRegistrationPoints: 'eventRegistrationPoints',
eventFeedbackPoints: 'eventFeedbackPoints',
houseJoinPoints: 'houseJoinPoints',
houseLeavePoints: 'houseLeavePoints',
houseCreatePoints: 'houseCreatePoints',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SitePreferencesScalarFieldEnum = (typeof SitePreferencesScalarFieldEnum)[keyof typeof SitePreferencesScalarFieldEnum]
export type SitePreferencesScalarFieldEnum =
(typeof SitePreferencesScalarFieldEnum)[keyof typeof SitePreferencesScalarFieldEnum];
export const ChallengeScalarFieldEnum = { export const ChallengeScalarFieldEnum = {
id: "id", id: 'id',
challengerId: "challengerId", challengerId: 'challengerId',
challengedId: "challengedId", challengedId: 'challengedId',
title: "title", title: 'title',
description: "description", description: 'description',
pointsReward: "pointsReward", pointsReward: 'pointsReward',
status: "status", status: 'status',
adminId: "adminId", adminId: 'adminId',
adminComment: "adminComment", adminComment: 'adminComment',
winnerId: "winnerId", winnerId: 'winnerId',
createdAt: "createdAt", createdAt: 'createdAt',
acceptedAt: "acceptedAt", acceptedAt: 'acceptedAt',
completedAt: "completedAt", completedAt: 'completedAt',
updatedAt: "updatedAt", updatedAt: 'updatedAt'
} as const; } as const
export type ChallengeScalarFieldEnum = (typeof ChallengeScalarFieldEnum)[keyof typeof ChallengeScalarFieldEnum]
export const HouseScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description',
creatorId: 'creatorId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HouseScalarFieldEnum = (typeof HouseScalarFieldEnum)[keyof typeof HouseScalarFieldEnum]
export const HouseMembershipScalarFieldEnum = {
id: 'id',
houseId: 'houseId',
userId: 'userId',
role: 'role',
joinedAt: 'joinedAt'
} as const
export type HouseMembershipScalarFieldEnum = (typeof HouseMembershipScalarFieldEnum)[keyof typeof HouseMembershipScalarFieldEnum]
export const HouseInvitationScalarFieldEnum = {
id: 'id',
houseId: 'houseId',
inviterId: 'inviterId',
inviteeId: 'inviteeId',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HouseInvitationScalarFieldEnum = (typeof HouseInvitationScalarFieldEnum)[keyof typeof HouseInvitationScalarFieldEnum]
export const HouseRequestScalarFieldEnum = {
id: 'id',
houseId: 'houseId',
requesterId: 'requesterId',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HouseRequestScalarFieldEnum = (typeof HouseRequestScalarFieldEnum)[keyof typeof HouseRequestScalarFieldEnum]
export type ChallengeScalarFieldEnum =
(typeof ChallengeScalarFieldEnum)[keyof typeof ChallengeScalarFieldEnum];
export const SortOrder = { export const SortOrder = {
asc: "asc", asc: 'asc',
desc: "desc", desc: 'desc'
} as const; } as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
export const NullsOrder = { export const NullsOrder = {
first: "first", first: 'first',
last: "last", last: 'last'
} as const; } as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder];

View File

@@ -1,17 +1,22 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */ /* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */ /* eslint-disable */
// biome-ignore-all lint: generated file // biome-ignore-all lint: generated file
// @ts-nocheck // @ts-nocheck
/* /*
* This is a barrel export file for all models and their related types. * This is a barrel export file for all models and their related types.
* *
* 🟢 You can import this file directly. * 🟢 You can import this file directly.
*/ */
export type * from "./models/User"; export type * from './models/User'
export type * from "./models/UserPreferences"; export type * from './models/UserPreferences'
export type * from "./models/Event"; export type * from './models/Event'
export type * from "./models/EventRegistration"; export type * from './models/EventRegistration'
export type * from "./models/EventFeedback"; export type * from './models/EventFeedback'
export type * from "./models/SitePreferences"; export type * from './models/SitePreferences'
export type * from "./models/Challenge"; export type * from './models/Challenge'
export type * from "./commonInputTypes"; export type * from './models/House'
export type * from './models/HouseMembership'
export type * from './models/HouseInvitation'
export type * from './models/HouseRequest'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load Diff

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