Compare commits

...

11 Commits

Author SHA1 Message Date
9764402ef2 feat: adapting dor server 2025-12-06 13:09:12 +01:00
Julien Froidefond
291dace07a chore: update package dependencies to latest versions for improved stability and performance 2025-12-05 11:09:20 +01:00
Julien Froidefond
71d850c985 chore: clean up code formatting and remove unnecessary whitespace across multiple files for improved readability 2025-12-05 11:05:14 +01:00
Julien Froidefond
b3157fffbd feat: update Next.js configuration to enable standalone output for improved deployment 2025-11-29 12:15:19 +01:00
Julien Froidefond
1f666713e8 feat: update session links to include tab parameters for improved navigation and add loading skeletons for better user experience 2025-11-29 11:22:28 +01:00
Julien Froidefond
cee3cd7b47 chore: remove Motivators page and update links to Sessions across the application 2025-11-28 15:09:01 +01:00
Julien Froidefond
eaeb1335fa feat: enhance session management by resolving collaborators to users and integrating CollaboratorDisplay component across motivators and sessions pages 2025-11-28 11:04:58 +01:00
Julien Froidefond
941151553f feat: add 'Utilisateurs' link to Header component and implement user statistics retrieval in auth service 2025-11-28 10:55:49 +01:00
Julien Froidefond
ff7c846ed1 feat: integrate Avatar component across various modules for improved user representation and enhance profile page with Gravatar support 2025-11-28 10:52:48 +01:00
Julien Froidefond
cb4873cd40 feat: add session editing functionality with modal in WorkshopTabs component and enhance session revalidation 2025-11-28 10:48:36 +01:00
Julien Froidefond
ac079ed8b2 feat: implement session deletion functionality with confirmation modal in WorkshopTabs component 2025-11-28 10:44:01 +01:00
74 changed files with 1576 additions and 782 deletions

47
.dockerignore Normal file
View File

@@ -0,0 +1,47 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
.next
out
dist
build
# Database
*.db
*.db-journal
data/
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Misc
*.log
npm-debug.log*
.DS_Store
Thumbs.db
# Environment
.env
.env.*
!.env.example
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Documentation
README.md
devbook.md
TODO.md

66
Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1
# ---- Base ----
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---- Build ----
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client (dummy URL for build, actual URL at runtime)
ENV DATABASE_URL="file:/tmp/build.db"
RUN pnpm prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ---- Production ----
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install build tools for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy built assets (standalone includes node_modules needed for runtime)
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma schema, migrations, and config
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
# Install prisma CLI for migrations + better-sqlite3 (compile native module)
ENV DATABASE_URL="file:/app/data/prod.db"
RUN pnpm add prisma @prisma/client @prisma/adapter-better-sqlite3 better-sqlite3 dotenv && \
pnpm prisma generate
# Copy entrypoint script
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
# Create data directory for SQLite
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV DATABASE_URL="file:/app/data/prod.db"
ENTRYPOINT ["./docker-entrypoint.sh"]

BIN
data/dev.db Normal file

Binary file not shown.

BIN
data/prod.db Normal file

Binary file not shown.

BIN
dev.db

Binary file not shown.

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3009:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=file:/app/data/dev.db
- AUTH_SECRET=${AUTH_SECRET:-your-secret-key-change-in-production}
- AUTH_TRUST_HOST=true
- AUTH_URL=${AUTH_URL:-https://workshop-manager.julienfroidefond.com}
volumes:
- ./data:/app/data
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=false"

9
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -e
echo "🔄 Running database migrations..."
pnpm prisma migrate deploy
echo "🚀 Starting application..."
exec node server.js

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -21,12 +21,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1",
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
"next": "16.0.5",
"next": "16.0.7",
"next-auth": "5.0.0-beta.30",
"prisma": "^7.0.1",
"prisma": "^7.1.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},

215
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.0.1
version: 7.0.1
'@prisma/client':
specifier: ^7.0.1
version: 7.0.1(prisma@7.0.1(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)
specifier: ^7.1.0
version: 7.1.0(prisma@7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
@@ -33,14 +33,14 @@ importers:
specifier: ^12.4.6
version: 12.4.6
next:
specifier: 16.0.5
version: 16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
specifier: 16.0.7
version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-auth:
specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
version: 5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
prisma:
specifier: ^7.0.1
version: 7.0.1(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
specifier: ^7.1.0
version: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react:
specifier: 19.2.0
version: 19.2.0
@@ -277,8 +277,8 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@hono/node-server@1.14.2':
resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==}
'@hono/node-server@1.19.6':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -459,56 +459,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.5':
resolution: {integrity: sha512-jRLOw822AE6aaIm9oh0NrauZEM0Vtx5xhYPgqx89txUmv/UmcRwpcXmGeQOvYNT/1bakUwA+nG5CA74upYVVDw==}
'@next/env@16.0.7':
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
'@next/eslint-plugin-next@16.0.5':
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
'@next/swc-darwin-arm64@16.0.5':
resolution: {integrity: sha512-65Mfo1rD+mVbJuBTlXbNelNOJ5ef+5pskifpFHsUt3cnOWjDNKctHBwwSz9tJlPp7qADZtiN/sdcG7mnc0El8Q==}
'@next/swc-darwin-arm64@16.0.7':
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.0.5':
resolution: {integrity: sha512-2fDzXD/JpEjY500VUF0uuGq3YZcpC6XxmGabePPLyHCKbw/YXRugv3MRHH7MxE2hVHtryXeSYYnxcESb/3OUIQ==}
'@next/swc-darwin-x64@16.0.7':
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.5':
resolution: {integrity: sha512-meSLB52fw4tgDpPnyuhwA280EWLwwIntrxLYjzKU3e3730ur2WJAmmqoZ1LPIZ2l3eDfh9SBHnJGTczbgPeNeA==}
'@next/swc-linux-arm64-gnu@16.0.7':
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.0.5':
resolution: {integrity: sha512-aAJtQkvUzz5t0xVAmK931SIhWnSQAaEoTyG/sKPCYq2u835K/E4a14A+WRPd4dkhxIHNudE8dI+FpHekgdrA4g==}
'@next/swc-linux-arm64-musl@16.0.7':
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.0.5':
resolution: {integrity: sha512-bYwbjBwooMWRhy6vRxenaYdguTM2hlxFt1QBnUF235zTnU2DhGpETm5WU93UvtAy0uhC5Kgqsl8RyNXlprFJ6Q==}
'@next/swc-linux-x64-gnu@16.0.7':
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.0.5':
resolution: {integrity: sha512-iGv2K/4gW3mkzh+VcZTf2gEGX5o9xdb5oPqHjgZvHdVzCw0iSAJ7n9vKzl3SIEIIHZmqRsgNasgoLd0cxaD+tg==}
'@next/swc-linux-x64-musl@16.0.7':
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.0.5':
resolution: {integrity: sha512-6xf52Hp4SH9+4jbYmfUleqkuxvdB9JJRwwFlVG38UDuEGPqpIA+0KiJEU9lxvb0RGNo2i2ZUhc5LHajij9H9+A==}
'@next/swc-win32-arm64-msvc@16.0.7':
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.0.5':
resolution: {integrity: sha512-06kTaOh+Qy/kguN+MMK+/VtKmRkQJrPlGQMvCUbABk1UxI5SKTgJhbmMj9Hf0qWwrS6g9JM6/Zk+etqeMyvHAw==}
'@next/swc-win32-x64-msvc@16.0.7':
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -535,11 +535,11 @@ packages:
'@prisma/adapter-better-sqlite3@7.0.1':
resolution: {integrity: sha512-fgnn+lkUV/7pUvPnQSI/ZHONwGU+eisWqU6tvBFGK2ovgBUAJN8zG0fbt8v8Brphyo4h4AA3+jyG0TFFb+qVxQ==}
'@prisma/client-runtime-utils@7.0.1':
resolution: {integrity: sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==}
'@prisma/client-runtime-utils@7.1.0':
resolution: {integrity: sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==}
'@prisma/client@7.0.1':
resolution: {integrity: sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==}
'@prisma/client@7.1.0':
resolution: {integrity: sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==}
engines: {node: ^20.19 || ^22.12 || >=24.0}
peerDependencies:
prisma: '*'
@@ -550,8 +550,8 @@ packages:
typescript:
optional: true
'@prisma/config@7.0.1':
resolution: {integrity: sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==}
'@prisma/config@7.1.0':
resolution: {integrity: sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==}
'@prisma/debug@6.8.2':
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
@@ -559,26 +559,29 @@ packages:
'@prisma/debug@7.0.1':
resolution: {integrity: sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==}
'@prisma/dev@0.13.0':
resolution: {integrity: sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==}
'@prisma/debug@7.1.0':
resolution: {integrity: sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==}
'@prisma/dev@0.15.0':
resolution: {integrity: sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==}
'@prisma/driver-adapter-utils@7.0.1':
resolution: {integrity: sha512-sBbxm/yysHLLF2iMAB+qcX/nn3WFgsiC4DQNz0uM6BwGSIs8lIvgo0u8nR9nxe5gvFgKiIH8f4z2fgOEMeXc8w==}
'@prisma/engines-version@7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6':
resolution: {integrity: sha512-RA7pShKvijHib4USRB3YuLTQamHKJPkTRDc45AwxfahUQngiGVMlIj4ix4emUxkrum4o/jwn82WIwlG57EtgiQ==}
'@prisma/engines-version@7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba':
resolution: {integrity: sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==}
'@prisma/engines@7.0.1':
resolution: {integrity: sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==}
'@prisma/engines@7.1.0':
resolution: {integrity: sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==}
'@prisma/fetch-engine@7.0.1':
resolution: {integrity: sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==}
'@prisma/fetch-engine@7.1.0':
resolution: {integrity: sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==}
'@prisma/get-platform@6.8.2':
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
'@prisma/get-platform@7.0.1':
resolution: {integrity: sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==}
'@prisma/get-platform@7.1.0':
resolution: {integrity: sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==}
'@prisma/query-plan-executor@6.18.0':
resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==}
@@ -1478,8 +1481,8 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grammex@3.1.11:
resolution: {integrity: sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==}
grammex@3.1.12:
resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==}
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
@@ -1517,8 +1520,8 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
hono@4.7.10:
resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==}
hono@4.10.6:
resolution: {integrity: sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==}
engines: {node: '>=16.9.0'}
http-status-codes@2.3.0:
@@ -1905,8 +1908,8 @@ packages:
nodemailer:
optional: true
next@16.0.5:
resolution: {integrity: sha512-XUPsFqSqu/NDdPfn/cju9yfIedkDI7ytDoALD9todaSMxk1Z5e3WcbUjfI9xsanFTys7xz62lnRWNFqJordzkQ==}
next@16.0.7:
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2071,8 +2074,8 @@ packages:
engines: {node: '>=14'}
hasBin: true
prisma@7.0.1:
resolution: {integrity: sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==}
prisma@7.1.0:
resolution: {integrity: sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==}
engines: {node: ^20.19 || ^22.12 || >=24.0}
hasBin: true
peerDependencies:
@@ -2459,8 +2462,8 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
valibot@1.1.0:
resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
@@ -2752,9 +2755,9 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
'@hono/node-server@1.14.2(hono@4.7.10)':
'@hono/node-server@1.19.6(hono@4.10.6)':
dependencies:
hono: 4.7.10
hono: 4.10.6
'@humanfs/core@0.19.1': {}
@@ -2895,34 +2898,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.0.5': {}
'@next/env@16.0.7': {}
'@next/eslint-plugin-next@16.0.5':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.5':
'@next/swc-darwin-arm64@16.0.7':
optional: true
'@next/swc-darwin-x64@16.0.5':
'@next/swc-darwin-x64@16.0.7':
optional: true
'@next/swc-linux-arm64-gnu@16.0.5':
'@next/swc-linux-arm64-gnu@16.0.7':
optional: true
'@next/swc-linux-arm64-musl@16.0.5':
'@next/swc-linux-arm64-musl@16.0.7':
optional: true
'@next/swc-linux-x64-gnu@16.0.5':
'@next/swc-linux-x64-gnu@16.0.7':
optional: true
'@next/swc-linux-x64-musl@16.0.5':
'@next/swc-linux-x64-musl@16.0.7':
optional: true
'@next/swc-win32-arm64-msvc@16.0.5':
'@next/swc-win32-arm64-msvc@16.0.7':
optional: true
'@next/swc-win32-x64-msvc@16.0.5':
'@next/swc-win32-x64-msvc@16.0.7':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -2946,16 +2949,16 @@ snapshots:
'@prisma/driver-adapter-utils': 7.0.1
better-sqlite3: 12.4.6
'@prisma/client-runtime-utils@7.0.1': {}
'@prisma/client-runtime-utils@7.1.0': {}
'@prisma/client@7.0.1(prisma@7.0.1(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)':
'@prisma/client@7.1.0(prisma@7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@prisma/client-runtime-utils': 7.0.1
'@prisma/client-runtime-utils': 7.1.0
optionalDependencies:
prisma: 7.0.1(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
prisma: 7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
typescript: 5.9.3
'@prisma/config@7.0.1':
'@prisma/config@7.1.0':
dependencies:
c12: 3.1.0
deepmerge-ts: 7.1.5
@@ -2968,24 +2971,26 @@ snapshots:
'@prisma/debug@7.0.1': {}
'@prisma/dev@0.13.0(typescript@5.9.3)':
'@prisma/debug@7.1.0': {}
'@prisma/dev@0.15.0(typescript@5.9.3)':
dependencies:
'@electric-sql/pglite': 0.3.2
'@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2)
'@electric-sql/pglite-tools': 0.2.7(@electric-sql/pglite@0.3.2)
'@hono/node-server': 1.14.2(hono@4.7.10)
'@hono/node-server': 1.19.6(hono@4.10.6)
'@mrleebo/prisma-ast': 0.12.1
'@prisma/get-platform': 6.8.2
'@prisma/query-plan-executor': 6.18.0
foreground-child: 3.3.1
get-port-please: 3.1.2
hono: 4.7.10
hono: 4.10.6
http-status-codes: 2.3.0
pathe: 2.0.3
proper-lockfile: 4.1.2
remeda: 2.21.3
std-env: 3.9.0
valibot: 1.1.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
zeptomatch: 2.0.2
transitivePeerDependencies:
- typescript
@@ -2994,28 +2999,28 @@ snapshots:
dependencies:
'@prisma/debug': 7.0.1
'@prisma/engines-version@7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6': {}
'@prisma/engines-version@7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba': {}
'@prisma/engines@7.0.1':
'@prisma/engines@7.1.0':
dependencies:
'@prisma/debug': 7.0.1
'@prisma/engines-version': 7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6
'@prisma/fetch-engine': 7.0.1
'@prisma/get-platform': 7.0.1
'@prisma/debug': 7.1.0
'@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
'@prisma/fetch-engine': 7.1.0
'@prisma/get-platform': 7.1.0
'@prisma/fetch-engine@7.0.1':
'@prisma/fetch-engine@7.1.0':
dependencies:
'@prisma/debug': 7.0.1
'@prisma/engines-version': 7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6
'@prisma/get-platform': 7.0.1
'@prisma/debug': 7.1.0
'@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
'@prisma/get-platform': 7.1.0
'@prisma/get-platform@6.8.2':
dependencies:
'@prisma/debug': 6.8.2
'@prisma/get-platform@7.0.1':
'@prisma/get-platform@7.1.0':
dependencies:
'@prisma/debug': 7.0.1
'@prisma/debug': 7.1.0
'@prisma/query-plan-executor@6.18.0': {}
@@ -4071,7 +4076,7 @@ snapshots:
graceful-fs@4.2.11: {}
grammex@3.1.11: {}
grammex@3.1.12: {}
graphemer@1.4.0: {}
@@ -4103,7 +4108,7 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
hono@4.7.10: {}
hono@4.10.6: {}
http-status-codes@2.3.0: {}
@@ -4433,15 +4438,15 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.30(next@16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
next-auth@5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0):
dependencies:
'@auth/core': 0.41.0
next: 16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
next@16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.5
'@next/env': 16.0.7
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001757
postcss: 8.4.31
@@ -4449,14 +4454,14 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.5
'@next/swc-darwin-x64': 16.0.5
'@next/swc-linux-arm64-gnu': 16.0.5
'@next/swc-linux-arm64-musl': 16.0.5
'@next/swc-linux-x64-gnu': 16.0.5
'@next/swc-linux-x64-musl': 16.0.5
'@next/swc-win32-arm64-msvc': 16.0.5
'@next/swc-win32-x64-msvc': 16.0.5
'@next/swc-darwin-arm64': 16.0.7
'@next/swc-darwin-x64': 16.0.7
'@next/swc-linux-arm64-gnu': 16.0.7
'@next/swc-linux-arm64-musl': 16.0.7
'@next/swc-linux-x64-gnu': 16.0.7
'@next/swc-linux-x64-musl': 16.0.7
'@next/swc-win32-arm64-msvc': 16.0.7
'@next/swc-win32-x64-msvc': 16.0.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -4618,11 +4623,11 @@ snapshots:
prettier@3.7.1: {}
prisma@7.0.1(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
prisma@7.1.0(@types/react@19.2.7)(better-sqlite3@12.4.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@prisma/config': 7.0.1
'@prisma/dev': 0.13.0(typescript@5.9.3)
'@prisma/engines': 7.0.1
'@prisma/config': 7.1.0
'@prisma/dev': 0.15.0(typescript@5.9.3)
'@prisma/engines': 7.1.0
'@prisma/studio-core': 0.8.2(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
mysql2: 3.15.3
postgres: 3.4.7
@@ -5117,7 +5122,7 @@ snapshots:
util-deprecate@1.0.2: {}
valibot@1.1.0(typescript@5.9.3):
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -5176,7 +5181,7 @@ snapshots:
zeptomatch@2.0.2:
dependencies:
grammex: 3.1.11
grammex: 3.1.12
zod-validation-error@4.0.2(zod@4.1.13):
dependencies:

View File

@@ -15,10 +15,7 @@ export async function createMotivatorSession(data: { title: string; participant:
}
try {
const motivatorSession = await motivatorsService.createMotivatorSession(
session.user.id,
data
);
const motivatorSession = await motivatorsService.createMotivatorSession(session.user.id, data);
revalidatePath('/motivators');
return { success: true, data: motivatorSession };
} catch (error) {
@@ -49,6 +46,7 @@ export async function updateMotivatorSession(
revalidatePath(`/motivators/${sessionId}`);
revalidatePath('/motivators');
revalidatePath('/sessions'); // Also revalidate unified workshops page
return { success: true };
} catch (error) {
console.error('Error updating motivator session:', error);
@@ -65,6 +63,7 @@ export async function deleteMotivatorSession(sessionId: string) {
try {
await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id);
revalidatePath('/motivators');
revalidatePath('/sessions'); // Also revalidate unified workshops page
return { success: true };
} catch (error) {
console.error('Error deleting motivator session:', error);
@@ -87,10 +86,7 @@ export async function updateMotivatorCard(
}
// Check edit permission
const canEdit = await motivatorsService.canEditMotivatorSession(
sessionId,
authSession.user.id
);
const canEdit = await motivatorsService.canEditMotivatorSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -130,10 +126,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
}
// Check edit permission
const canEdit = await motivatorsService.canEditMotivatorSession(
sessionId,
authSession.user.id
);
const canEdit = await motivatorsService.canEditMotivatorSession(sessionId, authSession.user.id);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
@@ -157,11 +150,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
}
}
export async function updateCardInfluence(
cardId: string,
sessionId: string,
influence: number
) {
export async function updateCardInfluence(cardId: string, sessionId: string, influence: number) {
return updateMotivatorCard(cardId, sessionId, { influence });
}
@@ -190,8 +179,7 @@ export async function shareMotivatorSession(
return { success: true, data: share };
} catch (error) {
console.error('Error sharing motivator session:', error);
const message =
error instanceof Error ? error.message : 'Erreur lors du partage';
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
@@ -203,11 +191,7 @@ export async function removeMotivatorShare(sessionId: string, shareUserId: strin
}
try {
await motivatorsService.removeMotivatorShare(
sessionId,
authSession.user.id,
shareUserId
);
await motivatorsService.removeMotivatorShare(sessionId, authSession.user.id, shareUserId);
revalidatePath(`/motivators/${sessionId}`);
return { success: true };
} catch (error) {
@@ -215,4 +199,3 @@ export async function removeMotivatorShare(sessionId: string, shareUserId: strin
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -35,10 +35,7 @@ export async function updateProfileAction(data: { name?: string; email?: string
return result;
}
export async function updatePasswordAction(data: {
currentPassword: string;
newPassword: string;
}) {
export async function updatePasswordAction(data: { currentPassword: string; newPassword: string }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' };
@@ -48,11 +45,6 @@ export async function updatePasswordAction(data: {
return { success: false, error: 'Le nouveau mot de passe doit faire au moins 6 caractères' };
}
const result = await updateUserPassword(
session.user.id,
data.currentPassword,
data.newPassword
);
const result = await updateUserPassword(session.user.id, data.currentPassword, data.newPassword);
return result;
}

View File

@@ -70,3 +70,68 @@ export async function updateSessionCollaborator(sessionId: string, collaborator:
}
}
export async function updateSwotSession(
sessionId: string,
data: { title?: string; collaborator?: string }
) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
if (data.title !== undefined && !data.title.trim()) {
return { success: false, error: 'Le titre ne peut pas être vide' };
}
if (data.collaborator !== undefined && !data.collaborator.trim()) {
return { success: false, error: 'Le nom du collaborateur ne peut pas être vide' };
}
try {
const updateData: { title?: string; collaborator?: string } = {};
if (data.title) updateData.title = data.title.trim();
if (data.collaborator) updateData.collaborator = data.collaborator.trim();
const result = await sessionsService.updateSession(sessionId, session.user.id, updateData);
if (result.count === 0) {
return { success: false, error: 'Session non trouvée ou non autorisé' };
}
// Emit event for real-time sync
await sessionsService.createSessionEvent(
sessionId,
session.user.id,
'SESSION_UPDATED',
updateData
);
revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error updating session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteSwotSession(sessionId: string) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const result = await sessionsService.deleteSession(sessionId, session.user.id);
if (result.count === 0) {
return { success: false, error: 'Session non trouvée ou non autorisé' };
}
revalidatePath('/sessions');
return { success: true };
} catch (error) {
console.error('Error deleting session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}

View File

@@ -2,11 +2,7 @@
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import {
shareSession,
removeShare,
getSessionShares,
} from '@/services/sessions';
import { shareSession, removeShare, getSessionShares } from '@/services/sessions';
import type { ShareRole } from '@prisma/client';
export async function shareSessionAction(
@@ -26,10 +22,10 @@ export async function shareSessionAction(
} catch (error) {
const message = error instanceof Error ? error.message : 'Erreur inconnue';
if (message === 'User not found') {
return { success: false, error: "Aucun utilisateur trouvé avec cet email" };
return { success: false, error: 'Aucun utilisateur trouvé avec cet email' };
}
if (message === 'Cannot share session with yourself') {
return { success: false, error: "Vous ne pouvez pas partager avec vous-même" };
return { success: false, error: 'Vous ne pouvez pas partager avec vous-même' };
}
return { success: false, error: message };
}
@@ -65,5 +61,3 @@ export async function getSharesAction(sessionId: string) {
return { success: false, error: message, data: [] };
}
}

View File

@@ -20,14 +20,14 @@ export async function createSwotItem(
try {
const item = await sessionsService.createSwotItem(sessionId, data);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
itemId: item.id,
content: item.content,
category: item.category,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
@@ -48,13 +48,13 @@ export async function updateSwotItem(
try {
const item = await sessionsService.updateSwotItem(itemId, data);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_UPDATED', {
itemId: item.id,
...data,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
@@ -71,12 +71,12 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
try {
await sessionsService.deleteSwotItem(itemId);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_DELETED', {
itemId,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true };
} catch (error) {
@@ -93,7 +93,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
try {
const item = await sessionsService.duplicateSwotItem(itemId);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
itemId: item.id,
@@ -101,7 +101,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
category: item.category,
duplicatedFrom: itemId,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
@@ -123,14 +123,14 @@ export async function moveSwotItem(
try {
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_MOVED', {
itemId: item.id,
newCategory,
newOrder,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: item };
} catch (error) {
@@ -159,14 +159,14 @@ export async function createAction(
try {
const action = await sessionsService.createAction(sessionId, data);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_CREATED', {
actionId: action.id,
title: action.title,
linkedItemIds: data.linkedItemIds,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action };
} catch (error) {
@@ -192,13 +192,13 @@ export async function updateAction(
try {
const action = await sessionsService.updateAction(actionId, data);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_UPDATED', {
actionId: action.id,
...data,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true, data: action };
} catch (error) {
@@ -215,12 +215,12 @@ export async function deleteAction(actionId: string, sessionId: string) {
try {
await sessionsService.deleteAction(actionId);
// Emit event for real-time sync
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_DELETED', {
actionId,
});
revalidatePath(`/sessions/${sessionId}`);
return { success: true };
} catch (error) {
@@ -228,4 +228,3 @@ export async function deleteAction(actionId: string, sessionId: string) {
return { success: false, error: 'Erreur lors de la suppression' };
}
}

View File

@@ -109,4 +109,3 @@ export default function LoginPage() {
</div>
);
}

View File

@@ -170,4 +170,3 @@ export default function RegisterPage() {
</div>
);
}

View File

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

View File

@@ -22,4 +22,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Erreur lors de la création du compte' }, { status: 500 });
}
}

View File

@@ -1,18 +1,12 @@
import { auth } from '@/lib/auth';
import {
canAccessMotivatorSession,
getMotivatorSessionEvents,
} from '@/services/moving-motivators';
import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
@@ -115,4 +109,3 @@ export function broadcastToMotivatorSession(sessionId: string, event: object) {
}
}
}

View File

@@ -6,10 +6,7 @@ export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id: sessionId } = await params;
const session = await auth();
@@ -112,4 +109,3 @@ export function broadcastToSession(sessionId: string, event: object) {
}
}
}

View File

@@ -45,10 +45,7 @@ export async function POST(request: Request) {
const { title, collaborator } = body;
if (!title || !collaborator) {
return NextResponse.json(
{ error: 'Titre et collaborateur requis' },
{ status: 400 }
);
return NextResponse.json({ error: 'Titre et collaborateur requis' }, { status: 400 });
}
const newSession = await prisma.session.create({
@@ -68,4 +65,3 @@ export async function POST(request: Request) {
);
}
}

View File

@@ -107,4 +107,3 @@ export function EditableMotivatorTitle({
</button>
);
}

View File

@@ -3,7 +3,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge } from '@/components/ui';
import { Badge, CollaboratorDisplay } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle';
interface MotivatorSessionPageProps {
@@ -29,7 +29,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/motivators" className="hover:text-foreground">
<Link href="/sessions?tab=motivators" className="hover:text-foreground">
Moving Motivators
</Link>
<span>/</span>
@@ -48,9 +48,9 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
initialTitle={session.title}
isOwner={session.isOwner}
/>
<p className="mt-1 text-lg text-muted">
👤 {session.participant}
</p>
<div className="mt-2">
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">
@@ -76,13 +76,8 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<MotivatorBoard
sessionId={session.id}
cards={session.cards}
canEdit={session.canEdit}
/>
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
</MotivatorLiveWrapper>
</main>
);
}

View File

@@ -2,7 +2,15 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from '@/components/ui';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
import { createMotivatorSession } from '@/actions/moving-motivators';
export default function NewMotivatorSessionPage() {
@@ -99,4 +107,3 @@ export default function NewMotivatorSessionPage() {
</main>
);
}

View File

@@ -1,135 +0,0 @@
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui';
export default async function MotivatorsPage() {
const session = await auth();
if (!session?.user?.id) {
return null;
}
const sessions = await getMotivatorSessionsByUserId(session.user.id);
// Separate owned vs shared sessions
const ownedSessions = sessions.filter((s) => s.isOwner);
const sharedSessions = sessions.filter((s) => !s.isOwner);
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Moving Motivators</h1>
<p className="mt-1 text-muted">
Découvrez ce qui motive vraiment vos collaborateurs
</p>
</div>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouvelle Session
</Button>
</Link>
</div>
{/* Sessions Grid */}
{sessions.length === 0 ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
Aucune session pour le moment
</h2>
<p className="text-muted mb-6">
Créez votre première session Moving Motivators pour explorer les motivations
intrinsèques de vos collaborateurs.
</p>
<Link href="/motivators/new">
<Button>Créer ma première session</Button>
</Link>
</Card>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</main>
);
}
type SessionWithMeta = Awaited<ReturnType<typeof getMotivatorSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/motivators/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.participant}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">🎯</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.cards} motivations
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -10,8 +10,8 @@ export default function Home() {
Vos ateliers, <span className="text-primary">réinventés</span>
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted">
Des outils interactifs et collaboratifs pour accompagner vos équipes.
Analysez, comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
Des outils interactifs et collaboratifs pour accompagner vos équipes. Analysez,
comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
</p>
</section>
@@ -23,7 +23,7 @@ export default function Home() {
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
{/* SWOT Workshop Card */}
<WorkshopCard
href="/sessions"
href="/sessions?tab=swot"
icon="📊"
title="Analyse SWOT"
tagline="Analysez. Planifiez. Progressez."
@@ -39,14 +39,14 @@ export default function Home() {
{/* Moving Motivators Workshop Card */}
<WorkshopCard
href="/motivators"
href="/sessions?tab=motivators"
icon="🎯"
title="Moving Motivators"
tagline="Révélez ce qui motive vraiment"
description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
features={[
'10 cartes de motivation à classer',
'Évaluation de l\'influence positive/négative',
"Évaluation de l'influence positive/négative",
'Récapitulatif personnalisé des motivations',
]}
accentColor="#8b5cf6"
@@ -73,8 +73,9 @@ export default function Home() {
Pourquoi faire un SWOT ?
</h3>
<p className="text-muted mb-4">
L&apos;analyse SWOT est un outil puissant pour prendre du recul sur une situation professionnelle.
Elle permet de dresser un portrait objectif et structuré, base indispensable pour définir des actions pertinentes.
L&apos;analyse SWOT est un outil puissant pour prendre du recul sur une situation
professionnelle. Elle permet de dresser un portrait objectif et structuré, base
indispensable pour définir des actions pertinentes.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
@@ -105,15 +106,21 @@ export default function Home() {
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-green-500/10 p-3 border border-green-500/20">
<p className="font-semibold text-green-600 text-sm mb-1">💪 Forces</p>
<p className="text-xs text-muted">Compétences, talents, réussites, qualités distinctives</p>
<p className="text-xs text-muted">
Compétences, talents, réussites, qualités distinctives
</p>
</div>
<div className="rounded-lg bg-orange-500/10 p-3 border border-orange-500/20">
<p className="font-semibold text-orange-600 text-sm mb-1"> Faiblesses</p>
<p className="text-xs text-muted">Lacunes, difficultés récurrentes, axes de progression</p>
<p className="text-xs text-muted">
Lacunes, difficultés récurrentes, axes de progression
</p>
</div>
<div className="rounded-lg bg-blue-500/10 p-3 border border-blue-500/20">
<p className="font-semibold text-blue-600 text-sm mb-1">🚀 Opportunités</p>
<p className="text-xs text-muted">Projets, formations, évolutions, nouveaux défis</p>
<p className="text-xs text-muted">
Projets, formations, évolutions, nouveaux défis
</p>
</div>
<div className="rounded-lg bg-red-500/10 p-3 border border-red-500/20">
<p className="font-semibold text-red-600 text-sm mb-1">🛡 Menaces</p>
@@ -129,10 +136,26 @@ export default function Home() {
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard number={1} title="Remplir la matrice" description="Identifiez ensemble les éléments de chaque quadrant lors d'un échange constructif" />
<StepCard number={2} title="Prioriser" description="Classez les éléments par importance et impact pour concentrer les efforts" />
<StepCard number={3} title="Croiser" description="Reliez les forces aux opportunités, anticipez les menaces avec les atouts" />
<StepCard number={4} title="Agir" description="Définissez des actions concrètes avec des échéances et des responsables" />
<StepCard
number={1}
title="Remplir la matrice"
description="Identifiez ensemble les éléments de chaque quadrant lors d'un échange constructif"
/>
<StepCard
number={2}
title="Prioriser"
description="Classez les éléments par importance et impact pour concentrer les efforts"
/>
<StepCard
number={3}
title="Croiser"
description="Reliez les forces aux opportunités, anticipez les menaces avec les atouts"
/>
<StepCard
number={4}
title="Agir"
description="Définissez des actions concrètes avec des échéances et des responsables"
/>
</div>
</div>
</div>
@@ -156,8 +179,9 @@ export default function Home() {
Pourquoi explorer ses motivations ?
</h3>
<p className="text-muted mb-4">
Créé par Jurgen Appelo (Management 3.0), cet exercice révèle les motivations intrinsèques qui nous animent.
Comprendre ce qui nous motive permet de mieux s&apos;épanouir et d&apos;aligner nos missions avec nos aspirations profondes.
Créé par Jurgen Appelo (Management 3.0), cet exercice révèle les motivations
intrinsèques qui nous animent. Comprendre ce qui nous motive permet de mieux
s&apos;épanouir et d&apos;aligner nos missions avec nos aspirations profondes.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
@@ -206,20 +230,20 @@ export default function Home() {
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-3 gap-4">
<StepCard
number={1}
title="Classer par importance"
description="Ordonnez les 10 cartes de la moins importante (gauche) à la plus importante (droite) pour vous"
<StepCard
number={1}
title="Classer par importance"
description="Ordonnez les 10 cartes de la moins importante (gauche) à la plus importante (droite) pour vous"
/>
<StepCard
number={2}
title="Évaluer l'influence"
description="Pour chaque motivation, indiquez si votre situation actuelle l'impacte positivement ou négativement"
<StepCard
number={2}
title="Évaluer l'influence"
description="Pour chaque motivation, indiquez si votre situation actuelle l'impacte positivement ou négativement"
/>
<StepCard
number={3}
title="Analyser et discuter"
description="Le récapitulatif révèle les motivations clés et les points de vigilance pour un échange constructif"
<StepCard
number={3}
title="Analyser et discuter"
description="Le récapitulatif révèle les motivations clés et les points de vigilance pour un échange constructif"
/>
</div>
</div>
@@ -279,9 +303,7 @@ function WorkshopCard({
newHref: string;
}) {
return (
<div
className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl"
>
<div className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl">
{/* Accent gradient */}
<div
className="absolute inset-x-0 top-0 h-1 opacity-80"
@@ -313,7 +335,12 @@ function WorkshopCard({
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
{feature}
</li>
@@ -380,22 +407,16 @@ function StepCard({
);
}
function MotivatorPill({
icon,
name,
color,
}: {
icon: string;
name: string;
color: string;
}) {
function MotivatorPill({ icon, name, color }: { icon: string; name: string; color: string }) {
return (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-full"
style={{ backgroundColor: `${color}15`, border: `1px solid ${color}30` }}
>
<span>{icon}</span>
<span className="font-medium" style={{ color }}>{name}</span>
<span className="font-medium" style={{ color }}>
{name}
</span>
</div>
);
}

View File

@@ -12,9 +12,7 @@ export function PasswordForm() {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const canSubmit =
currentPassword.length > 0 &&
newPassword.length >= 6 &&
newPassword === confirmPassword;
currentPassword.length > 0 && newPassword.length >= 6 && newPassword === confirmPassword;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -58,10 +56,7 @@ export function PasswordForm() {
</div>
<div>
<label
htmlFor="newPassword"
className="mb-1.5 block text-sm font-medium text-foreground"
>
<label htmlFor="newPassword" className="mb-1.5 block text-sm font-medium text-foreground">
Nouveau mot de passe
</label>
<Input
@@ -90,17 +85,13 @@ export function PasswordForm() {
required
/>
{confirmPassword && newPassword !== confirmPassword && (
<p className="mt-1 text-xs text-destructive">
Les mots de passe ne correspondent pas
</p>
<p className="mt-1 text-xs text-destructive">Les mots de passe ne correspondent pas</p>
)}
</div>
{message && (
<p
className={`text-sm ${
message.type === 'success' ? 'text-success' : 'text-destructive'
}`}
className={`text-sm ${message.type === 'success' ? 'text-success' : 'text-destructive'}`}
>
{message.text}
</p>
@@ -112,4 +103,3 @@ export function PasswordForm() {
</form>
);
}

View File

@@ -67,9 +67,7 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
{message && (
<p
className={`text-sm ${
message.type === 'success' ? 'text-success' : 'text-destructive'
}`}
className={`text-sm ${message.type === 'success' ? 'text-success' : 'text-destructive'}`}
>
{message.text}
</p>
@@ -81,4 +79,3 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
</form>
);
}

View File

@@ -3,6 +3,7 @@ import { redirect } from 'next/navigation';
import { getUserById } from '@/services/auth';
import { ProfileForm } from './ProfileForm';
import { PasswordForm } from './PasswordForm';
import { getGravatarUrl } from '@/lib/gravatar';
export default async function ProfilePage() {
const session = await auth();
@@ -19,17 +20,34 @@ export default async function ProfilePage() {
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
<div className="mb-8 flex items-center gap-6">
<img
src={getGravatarUrl(user.email, 160)}
alt={user.name || user.email}
width={80}
height={80}
className="rounded-full border-2 border-border"
/>
<div>
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
</div>
</div>
<div className="space-y-8">
{/* Profile Info */}
<section className="rounded-xl border border-border bg-card p-6">
<h2 className="mb-6 text-xl font-semibold text-foreground">
Informations personnelles
</h2>
<div className="mb-6 flex items-start justify-between">
<h2 className="text-xl font-semibold text-foreground">Informations personnelles</h2>
<a
href="https://gravatar.com"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted hover:text-primary transition-colors"
>
Changer mon avatar sur Gravatar
</a>
</div>
<ProfileForm
initialData={{
name: user.name || '',
@@ -40,17 +58,13 @@ export default async function ProfilePage() {
{/* Password */}
<section className="rounded-xl border border-border bg-card p-6">
<h2 className="mb-6 text-xl font-semibold text-foreground">
Changer le mot de passe
</h2>
<h2 className="mb-6 text-xl font-semibold text-foreground">Changer le mot de passe</h2>
<PasswordForm />
</section>
{/* Account Info */}
<section className="rounded-xl border border-border bg-card p-6">
<h2 className="mb-4 text-xl font-semibold text-foreground">
Informations du compte
</h2>
<h2 className="mb-4 text-xl font-semibold text-foreground">Informations du compte</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted">ID du compte</span>
@@ -72,4 +86,3 @@ export default async function ProfilePage() {
</main>
);
}

View File

@@ -1,10 +1,23 @@
'use client';
import { useState } from 'react';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { Card, Badge } from '@/components/ui';
import { useSearchParams, useRouter } from 'next/navigation';
import {
Card,
Badge,
Button,
Modal,
ModalFooter,
Input,
CollaboratorDisplay,
} from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
type WorkshopType = 'all' | 'swot' | 'motivators';
type WorkshopType = 'all' | 'swot' | 'motivators' | 'byPerson';
const VALID_TABS: WorkshopType[] = ['all', 'swot', 'motivators', 'byPerson'];
interface ShareUser {
id: string;
@@ -18,10 +31,20 @@ interface Share {
user: ShareUser;
}
interface ResolvedCollaborator {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
interface SwotSession {
id: string;
title: string;
collaborator: string;
resolvedCollaborator: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -35,6 +58,7 @@ interface MotivatorSession {
id: string;
title: string;
participant: string;
resolvedParticipant: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -51,17 +75,90 @@ interface WorkshopTabsProps {
motivatorSessions: MotivatorSession[];
}
// Helper to get participant name from any session
function getParticipant(session: AnySession): string {
return session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
}
// Helper to get resolved collaborator from any session
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
return session.workshopType === 'swot'
? (session as SwotSession).resolvedCollaborator
: (session as MotivatorSession).resolvedParticipant;
}
// Get display name for grouping - prefer matched user name
function getDisplayName(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
if (resolved.matchedUser?.name) {
return resolved.matchedUser.name;
}
return resolved.raw;
}
// Get grouping key - use matched user ID if available, otherwise normalized raw string
function getGroupKey(session: AnySession): string {
const resolved = getResolvedCollaborator(session);
// If we have a matched user, use their ID as key (ensures same person = same group)
if (resolved.matchedUser) {
return `user:${resolved.matchedUser.id}`;
}
// Otherwise, normalize the raw string
return `raw:${resolved.raw.trim().toLowerCase()}`;
}
// Group sessions by participant (using matched user ID when available)
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
const grouped = new Map<string, AnySession[]>();
sessions.forEach((session) => {
const key = getGroupKey(session);
const existing = grouped.get(key);
if (existing) {
existing.push(session);
} else {
grouped.set(key, [session]);
}
});
// Sort sessions within each group by date
grouped.forEach((sessions) => {
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
return grouped;
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
const [activeTab, setActiveTab] = useState<WorkshopType>('all');
const searchParams = useSearchParams();
const router = useRouter();
// Get tab from URL or default to 'all'
const tabParam = searchParams.get('tab');
const activeTab: WorkshopType =
tabParam && VALID_TABS.includes(tabParam as WorkshopType) ? (tabParam as WorkshopType) : 'all';
const setActiveTab = (tab: WorkshopType) => {
const params = new URLSearchParams(searchParams.toString());
if (tab === 'all') {
params.delete('tab');
} else {
params.set('tab', tab);
}
router.push(`/sessions${params.toString() ? `?${params.toString()}` : ''}`);
};
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Filter based on active tab
// Filter based on active tab (for non-byPerson tabs)
const filteredSessions =
activeTab === 'all'
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'swot'
? swotSessions
@@ -71,10 +168,16 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
// Group by person (all sessions - owned and shared)
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
);
return (
<div className="space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b border-border pb-4">
<div className="flex gap-2 border-b border-border pb-4 flex-wrap">
<TabButton
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
@@ -82,6 +185,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
label="Tous"
count={allSessions.length}
/>
<TabButton
active={activeTab === 'byPerson'}
onClick={() => setActiveTab('byPerson')}
icon="👥"
label="Par personne"
count={sessionsByPerson.size}
/>
<TabButton
active={activeTab === 'swot'}
onClick={() => setActiveTab('swot')}
@@ -99,10 +209,34 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
</div>
{/* Sessions */}
{filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de ce type pour le moment
</div>
{activeTab === 'byPerson' ? (
// By Person View
sortedPersons.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier pour le moment</div>
) : (
<div className="space-y-8">
{sortedPersons.map(([personKey, sessions]) => {
const resolved = getResolvedCollaborator(sessions[0]);
return (
<section key={personKey}>
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
<CollaboratorDisplay collaborator={resolved} size="md" />
<Badge variant="primary">
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</Badge>
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
);
})}
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
<div className="space-y-8">
{/* My Sessions */}
@@ -156,9 +290,10 @@ function TabButton({
onClick={onClick}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
${active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
${
active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
@@ -172,6 +307,18 @@ function TabButton({
}
function SessionCard({ session }: { session: AnySession }) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [isPending, startTransition] = useTransition();
// Edit form state
const [editTitle, setEditTitle] = useState(session.title);
const [editParticipant, setEditParticipant] = useState(
session.workshopType === 'swot'
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant
);
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
@@ -180,93 +327,261 @@ function SessionCard({ session }: { session: AnySession }) {
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
const handleDelete = () => {
startTransition(async () => {
const result = isSwot
? await deleteSwotSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
setShowDeleteModal(false);
} else {
console.error('Error deleting session:', result.error);
}
});
};
const handleEdit = () => {
startTransition(async () => {
const result = isSwot
? await updateSwotSession(session.id, { title: editTitle, collaborator: editParticipant })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
});
if (result.success) {
setShowEditModal(false);
} else {
console.error('Error updating session:', result.error);
}
});
};
const openEditModal = () => {
// Reset form values when opening
setEditTitle(session.title);
setEditParticipant(participant);
setShowEditModal(true);
};
return (
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
<>
<div className="relative group">
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">
{session.title}
</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<p className="text-sm text-muted mb-3 line-clamp-1">
👤 {participant}
{!session.isOwner && (
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
)}
</p>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor:
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">
+{session.shares.length - 3}
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<div className="mb-3 flex items-center gap-2">
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
{!session.isOwner && (
<span className="text-xs text-muted">
· par {session.user.name || session.user.email}
</span>
)}
</div>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
)}
</div>
</div>
)}
</Card>
</Link>
{/* Action buttons - only for owner */}
{session.isOwner && (
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openEditModal();
}}
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20"
title="Modifier"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDeleteModal(true);
}}
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
title="Supprimer"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</Card>
</Link>
</div>
{/* Edit modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="Modifier l'atelier"
size="sm"
>
<form
onSubmit={(e) => {
e.preventDefault();
handleEdit();
}}
className="space-y-4"
>
<div>
<label htmlFor="edit-title" className="block text-sm font-medium text-foreground mb-1">
Titre
</label>
<Input
id="edit-title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Titre de l'atelier"
required
/>
</div>
<div>
<label
htmlFor="edit-participant"
className="block text-sm font-medium text-foreground mb-1"
>
{isSwot ? 'Collaborateur' : 'Participant'}
</label>
<Input
id="edit-participant"
value={editParticipant}
onChange={(e) => setEditParticipant(e.target.value)}
placeholder={isSwot ? 'Nom du collaborateur' : 'Nom du participant'}
required
/>
</div>
<ModalFooter>
<Button
type="button"
variant="ghost"
onClick={() => setShowEditModal(false)}
disabled={isPending}
>
Annuler
</Button>
<Button
type="submit"
disabled={isPending || !editTitle.trim() || !editParticipant.trim()}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</ModalFooter>
</form>
</Modal>
{/* Delete confirmation modal */}
<Modal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
title="Supprimer l'atelier"
size="sm"
>
<div className="space-y-4">
<p className="text-muted">
Êtes-vous sûr de vouloir supprimer l&apos;atelier{' '}
<strong className="text-foreground">&quot;{session.title}&quot;</strong> ?
</p>
<p className="text-sm text-destructive">
Cette action est irréversible. Toutes les données seront perdues.
</p>
<ModalFooter>
<Button variant="ghost" onClick={() => setShowDeleteModal(false)} disabled={isPending}>
Annuler
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
{isPending ? 'Suppression...' : 'Supprimer'}
</Button>
</ModalFooter>
</div>
</Modal>
</>
);
}

View File

@@ -5,7 +5,7 @@ import { getSessionById } from '@/services/sessions';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session';
import { Badge } from '@/components/ui';
import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps {
params: Promise<{ id: string }>;
@@ -30,8 +30,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/sessions" className="hover:text-foreground">
Mes Sessions
<Link href="/sessions?tab=swot" className="hover:text-foreground">
SWOT
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
@@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) {
initialTitle={session.title}
isOwner={session.isOwner}
/>
<p className="mt-1 text-lg text-muted">
👤 {session.collaborator}
</p>
<div className="mt-2">
<CollaboratorDisplay
collaborator={session.resolvedCollaborator}
size="lg"
showEmail
/>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} items</Badge>
@@ -76,13 +80,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<SwotBoard
sessionId={session.id}
items={session.items}
actions={session.actions}
/>
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
</SessionLiveWrapper>
</main>
);
}

View File

@@ -2,7 +2,15 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from '@/components/ui';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
} from '@/components/ui';
export default function NewSessionPage() {
const router = useRouter();
@@ -100,4 +108,3 @@ export default function NewSessionPage() {
</main>
);
}

View File

@@ -1,10 +1,30 @@
import { Suspense } from 'react';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui';
import { Card, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
function WorkshopTabsSkeleton() {
return (
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="flex gap-2 border-b border-border pb-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-10 w-32 bg-card animate-pulse rounded-lg" />
))}
</div>
{/* Cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-40 bg-card animate-pulse rounded-xl" />
))}
</div>
</div>
);
}
export default async function SessionsPage() {
const session = await auth();
@@ -42,9 +62,7 @@ export default async function SessionsPage() {
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
<p className="mt-1 text-muted">
Tous vos ateliers en un seul endroit
</p>
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
</div>
<div className="flex gap-2">
<Link href="/sessions/new">
@@ -70,7 +88,8 @@ export default async function SessionsPage() {
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators pour découvrir les motivations de vos collaborateurs.
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators
pour découvrir les motivations de vos collaborateurs.
</p>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
@@ -88,10 +107,9 @@ export default async function SessionsPage() {
</div>
</Card>
) : (
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
/>
<Suspense fallback={<WorkshopTabsSkeleton />}>
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
</Suspense>
)}
</main>
);

219
src/app/users/page.tsx Normal file
View File

@@ -0,0 +1,219 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { getAllUsersWithStats } from '@/services/auth';
import { getGravatarUrl } from '@/lib/gravatar';
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Aujourd'hui";
if (diffDays === 1) return 'Hier';
if (diffDays < 7) return `Il y a ${diffDays} jours`;
if (diffDays < 30) return `Il y a ${Math.floor(diffDays / 7)} sem.`;
if (diffDays < 365) return `Il y a ${Math.floor(diffDays / 30)} mois`;
return `Il y a ${Math.floor(diffDays / 365)} an(s)`;
}
export default async function UsersPage() {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const users = await getAllUsersWithStats();
// Calculate some global stats
const totalSessions = users.reduce(
(acc, u) => acc + u._count.sessions + u._count.motivatorSessions,
0
);
const avgSessionsPerUser = users.length > 0 ? totalSessions / users.length : 0;
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Utilisateurs</h1>
<p className="mt-1 text-muted">
{users.length} utilisateur{users.length > 1 ? 's' : ''} inscrit
{users.length > 1 ? 's' : ''}
</p>
</div>
{/* Global Stats */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-primary">{users.length}</div>
<div className="text-sm text-muted">Utilisateurs</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-strength">{totalSessions}</div>
<div className="text-sm text-muted">Sessions totales</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-opportunity">{avgSessionsPerUser.toFixed(1)}</div>
<div className="text-sm text-muted">Moy. par user</div>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<div className="text-2xl font-bold text-accent">
{users.reduce(
(acc, u) => acc + u._count.sharedSessions + u._count.sharedMotivatorSessions,
0
)}
</div>
<div className="text-sm text-muted">Partages actifs</div>
</div>
</div>
{/* Users List */}
<div className="space-y-3">
{users.map((user) => {
const totalUserSessions = user._count.sessions + user._count.motivatorSessions;
const totalShares = user._count.sharedSessions + user._count.sharedMotivatorSessions;
const isCurrentUser = user.id === session.user?.id;
return (
<div
key={user.id}
className={`flex items-center gap-4 rounded-xl border p-4 transition-colors ${
isCurrentUser
? 'border-primary/30 bg-primary/5'
: 'border-border bg-card hover:bg-card-hover'
}`}
>
{/* Avatar */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(user.email, 96)}
alt={user.name || user.email}
width={48}
height={48}
className="rounded-full border-2 border-border"
/>
{/* User Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground truncate">
{user.name || 'Sans nom'}
</span>
{isCurrentUser && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs text-primary">
Vous
</span>
)}
</div>
<div className="text-sm text-muted truncate">{user.email}</div>
</div>
{/* Stats Pills */}
<div className="hidden gap-2 sm:flex">
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--strength) 15%, transparent)',
color: 'var(--strength)',
}}
title="Sessions SWOT"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
/>
</svg>
{user._count.sessions}
</div>
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--opportunity) 15%, transparent)',
color: 'var(--opportunity)',
}}
title="Sessions Moving Motivators"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{user._count.motivatorSessions}
</div>
{totalShares > 0 && (
<div
className="flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium"
style={{
backgroundColor: 'color-mix(in srgb, var(--accent) 15%, transparent)',
color: 'var(--accent)',
}}
title="Sessions partagées avec cet utilisateur"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{totalShares}
</div>
)}
</div>
{/* Mobile Stats */}
<div className="flex flex-col items-end gap-1 sm:hidden">
<div className="text-sm font-medium text-foreground">
{totalUserSessions} session{totalUserSessions !== 1 ? 's' : ''}
</div>
<div className="text-xs text-muted">{formatRelativeTime(user.createdAt)}</div>
</div>
{/* Date Info */}
<div className="hidden flex-col items-end sm:flex">
<div className="text-sm text-foreground">{formatRelativeTime(user.createdAt)}</div>
<div className="text-xs text-muted">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
</div>
</div>
</div>
);
})}
</div>
{/* Empty state */}
{users.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16">
<div className="text-4xl">👥</div>
<div className="mt-4 text-lg font-medium text-foreground">Aucun utilisateur</div>
<div className="mt-1 text-sm text-muted">
Les utilisateurs apparaîtront ici une fois inscrits
</div>
</div>
)}
</main>
);
}

View File

@@ -18,19 +18,13 @@ export function LiveIndicator({ isConnected, error }: LiveIndicatorProps) {
return (
<div
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors ${
isConnected
? 'bg-success/10 text-success'
: 'bg-yellow/10 text-yellow'
isConnected ? 'bg-success/10 text-success' : 'bg-yellow/10 text-yellow'
}`}
>
<span
className={`h-2 w-2 rounded-full ${
isConnected ? 'bg-success animate-pulse' : 'bg-yellow'
}`}
className={`h-2 w-2 rounded-full ${isConnected ? 'bg-success animate-pulse' : 'bg-yellow'}`}
/>
<span>{isConnected ? 'Live' : 'Connexion...'}</span>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
import { LiveIndicator } from './LiveIndicator';
import { ShareModal } from './ShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
@@ -63,7 +64,7 @@ export function SessionLiveWrapper({
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
@@ -84,13 +85,13 @@ export function SessionLiveWrapper({
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<div
<Avatar
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
@@ -100,11 +101,7 @@ export function SessionLiveWrapper({
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShareModalOpen(true)}
>
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -119,9 +116,7 @@ export function SessionLiveWrapper({
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
{children}
</div>
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
@@ -135,5 +130,3 @@ export function SessionLiveWrapper({
</>
);
}

View File

@@ -5,6 +5,7 @@ import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import type { ShareRole } from '@prisma/client';
@@ -104,14 +105,10 @@ export function ShareModal({
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Collaborateurs ({shares.length})
</p>
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">
Aucun collaborateur pour le moment
</p>
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
@@ -120,19 +117,15 @@ export function ShareModal({
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && (
<p className="text-xs text-muted">{share.user.email}</p>
)}
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
@@ -177,5 +170,3 @@ export function ShareModal({
</Modal>
);
}

View File

@@ -1,4 +1,3 @@
export { LiveIndicator } from './LiveIndicator';
export { ShareModal } from './ShareModal';
export { SessionLiveWrapper } from './SessionLiveWrapper';

View File

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react';
import { Avatar } from '@/components/ui';
export function Header() {
const { theme, toggleTheme } = useTheme();
@@ -109,8 +110,9 @@ export function Header() {
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card px-3 transition-colors hover:bg-card-hover"
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card pl-1.5 pr-3 transition-colors hover:bg-card-hover"
>
<Avatar email={session.user.email!} name={session.user.name} size={24} />
<span className="text-sm font-medium text-foreground">
{session.user.name || session.user.email?.split('@')[0]}
</span>
@@ -131,10 +133,7 @@ export function Header() {
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p>
@@ -149,6 +148,13 @@ export function Header() {
>
👤 Mon Profil
</Link>
<Link
href="/users"
onClick={() => setMenuOpen(false)}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover"
>
👥 Utilisateurs
</Link>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"

View File

@@ -140,4 +140,3 @@ function InfluenceSlider({
</div>
);
}

View File

@@ -66,14 +66,15 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
// Persist to server
startTransition(async () => {
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
await reorderMotivatorCards(
sessionId,
newCards.map((c) => c.id)
);
});
}
function handleInfluenceChange(cardId: string, influence: number) {
setCards((prev) =>
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
);
setCards((prev) => prev.map((c) => (c.id === cardId ? { ...c, influence } : c)));
startTransition(async () => {
await updateCardInfluence(cardId, sessionId, influence);
@@ -151,11 +152,7 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
>
<div className="flex gap-2 min-w-max px-2">
{sortedCards.map((card) => (
<MotivatorCard
key={card.id}
card={card}
disabled={!canEdit}
/>
<MotivatorCard key={card.id} card={card} disabled={!canEdit} />
))}
</div>
</SortableContext>
@@ -182,7 +179,8 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
Évaluez l&apos;influence de chaque motivation
</h2>
<p className="text-muted">
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative
sur votre situation actuelle
</p>
</div>
@@ -216,9 +214,7 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
<h2 className="text-xl font-semibold text-foreground mb-2">
Récapitulatif de vos Moving Motivators
</h2>
<p className="text-muted">
Voici l&apos;analyse de vos motivations et leur impact
</p>
<p className="text-muted">Voici l&apos;analyse de vos motivations et leur impact</p>
</div>
<MotivatorSummary cards={sortedCards} />
@@ -273,4 +269,3 @@ function StepIndicator({
</button>
);
}

View File

@@ -19,14 +19,7 @@ export function MotivatorCard({
}: MotivatorCardProps) {
const config = MOTIVATOR_BY_TYPE[card.type];
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
disabled,
});
@@ -62,10 +55,7 @@ export function MotivatorCard({
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
{/* Name */}
<div
className="font-semibold text-sm text-center px-2"
style={{ color: config.color }}
>
<div className="font-semibold text-sm text-center px-2" style={{ color: config.color }}>
{config.name}
</div>
@@ -129,9 +119,7 @@ export function MotivatorCardStatic({
/>
{/* Icon */}
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
{config.icon}
</div>
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>{config.icon}</div>
{/* Name */}
<div
@@ -169,4 +157,3 @@ export function MotivatorCardStatic({
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorL
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { MotivatorShareModal } from './MotivatorShareModal';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
@@ -63,7 +64,7 @@ export function MotivatorLiveWrapper({
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
@@ -84,13 +85,13 @@ export function MotivatorLiveWrapper({
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<div
<Avatar
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
@@ -100,11 +101,7 @@ export function MotivatorLiveWrapper({
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShareModalOpen(true)}
>
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -119,9 +116,7 @@ export function MotivatorLiveWrapper({
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
{children}
</div>
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<MotivatorShareModal
@@ -135,4 +130,3 @@ export function MotivatorLiveWrapper({
</>
);
}

View File

@@ -5,6 +5,7 @@ import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { ShareRole } from '@prisma/client';
@@ -104,14 +105,10 @@ export function MotivatorShareModal({
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Collaborateurs ({shares.length})
</p>
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">
Aucun collaborateur pour le moment
</p>
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
@@ -120,19 +117,15 @@ export function MotivatorShareModal({
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<Avatar email={share.user.email} name={share.user.name} size={32} />
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && (
<p className="text-xs text-muted">{share.user.email}</p>
)}
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
@@ -177,4 +170,3 @@ export function MotivatorShareModal({
</Modal>
);
}

View File

@@ -18,10 +18,14 @@ export function MotivatorSummary({ cards }: MotivatorSummaryProps) {
const bottom3 = sortedByImportance.slice(0, 3);
// Cards with positive influence
const positiveInfluence = cards.filter((c) => c.influence > 0).sort((a, b) => b.influence - a.influence);
const positiveInfluence = cards
.filter((c) => c.influence > 0)
.sort((a, b) => b.influence - a.influence);
// Cards with negative influence
const negativeInfluence = cards.filter((c) => c.influence < 0).sort((a, b) => a.influence - b.influence);
const negativeInfluence = cards
.filter((c) => c.influence < 0)
.sort((a, b) => a.influence - b.influence);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -100,4 +104,3 @@ function SummarySection({
</div>
);
}

View File

@@ -4,4 +4,3 @@ export { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';

View File

@@ -103,4 +103,3 @@ export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitl
</button>
);
}

View File

@@ -1,2 +1 @@
export { EditableTitle } from './EditableTitle';

View File

@@ -22,7 +22,10 @@ interface ActionPanelProps {
onActionLeave: () => void;
}
const categoryBadgeVariant: Record<SwotCategory, 'strength' | 'weakness' | 'opportunity' | 'threat'> = {
const categoryBadgeVariant: Record<
SwotCategory,
'strength' | 'weakness' | 'opportunity' | 'threat'
> = {
STRENGTH: 'strength',
WEAKNESS: 'weakness',
OPPORTUNITY: 'opportunity',
@@ -189,7 +192,12 @@ export function ActionPanel({
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -203,7 +211,12 @@ export function ActionPanel({
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -271,7 +284,7 @@ export function ActionPanel({
<Modal
isOpen={showModal}
onClose={closeModal}
title={editingAction ? 'Modifier l\'action' : 'Nouvelle action croisée'}
title={editingAction ? "Modifier l'action" : 'Nouvelle action croisée'}
size="lg"
>
<form onSubmit={handleSubmit}>
@@ -339,7 +352,7 @@ export function ActionPanel({
Annuler
</Button>
<Button type="submit" loading={isPending}>
{editingAction ? 'Enregistrer' : 'Créer l\'action'}
{editingAction ? 'Enregistrer' : "Créer l'action"}
</Button>
</ModalFooter>
</form>
@@ -347,4 +360,3 @@ export function ActionPanel({
</div>
);
}

View File

@@ -11,22 +11,17 @@ interface HelpContent {
const HELP_CONTENT: Record<SwotCategory, HelpContent> = {
STRENGTH: {
description:
'Les atouts internes et qualités qui distinguent positivement.',
description: 'Les atouts internes et qualités qui distinguent positivement.',
examples: [
'Expertise technique solide',
'Excellentes capacités de communication',
'Leadership naturel',
'Rigueur et organisation',
],
questions: [
'Qu\'est-ce qui le/la distingue ?',
'Quels retours positifs reçoit-il/elle ?',
],
questions: ["Qu'est-ce qui le/la distingue ?", 'Quels retours positifs reçoit-il/elle ?'],
},
WEAKNESS: {
description:
'Les axes d\'amélioration et points à travailler.',
description: "Les axes d'amélioration et points à travailler.",
examples: [
'Difficulté à déléguer',
'Gestion du stress à améliorer',
@@ -39,22 +34,17 @@ const HELP_CONTENT: Record<SwotCategory, HelpContent> = {
],
},
OPPORTUNITY: {
description:
'Les facteurs externes favorables à saisir.',
description: 'Les facteurs externes favorables à saisir.',
examples: [
'Nouveau projet stratégique',
'Formation disponible',
'Poste ouvert en interne',
'Mentor potentiel identifié',
],
questions: [
'Quelles évolutions pourraient l\'aider ?',
'Quelles ressources sont disponibles ?',
],
questions: ["Quelles évolutions pourraient l'aider ?", 'Quelles ressources sont disponibles ?'],
},
THREAT: {
description:
'Les risques externes à anticiper.',
description: 'Les risques externes à anticiper.',
examples: [
'Réorganisation menaçant le poste',
'Compétences devenant obsolètes',
@@ -82,9 +72,10 @@ export function QuadrantHelp({ category }: QuadrantHelpProps) {
className={`
flex h-5 w-5 items-center justify-center rounded-full
text-xs font-medium transition-all
${isOpen
? 'bg-foreground/20 text-foreground rotate-45'
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
${
isOpen
? 'bg-foreground/20 text-foreground rotate-45'
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
}
`}
aria-label="Aide"
@@ -113,9 +104,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
<div className="overflow-hidden">
<div className="rounded-lg bg-white/40 dark:bg-black/20 p-3 mb-3">
{/* Description */}
<p className="text-xs text-foreground/80 leading-relaxed">
{content.description}
</p>
<p className="text-xs text-foreground/80 leading-relaxed">{content.description}</p>
<div className="mt-3 flex gap-4">
{/* Examples */}
@@ -125,10 +114,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
</h4>
<ul className="space-y-0.5">
{content.examples.map((example, i) => (
<li
key={i}
className="flex items-start gap-1.5 text-xs text-foreground/70"
>
<li key={i} className="flex items-start gap-1.5 text-xs text-foreground/70">
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-current opacity-50" />
{example}
</li>
@@ -143,10 +129,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
</h4>
<ul className="space-y-1">
{content.questions.map((question, i) => (
<li
key={i}
className="text-xs italic text-foreground/60"
>
<li key={i} className="text-xs italic text-foreground/60">
{question}
</li>
))}
@@ -158,4 +141,3 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from '@hello-pangea/dnd';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
import { SwotQuadrant } from './SwotQuadrant';
import { SwotCard } from './SwotCard';
@@ -67,9 +62,7 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
if (!linkMode) return;
setSelectedItems((prev) =>
prev.includes(itemId)
? prev.filter((id) => id !== itemId)
: [...prev, itemId]
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
);
}
@@ -167,4 +160,3 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
</div>
);
}

View File

@@ -120,7 +120,12 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
aria-label="Modifier"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -137,7 +142,12 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
aria-label="Dupliquer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -154,7 +164,12 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
aria-label="Supprimer"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -168,7 +183,9 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
{/* Selection indicator in link mode */}
{linkMode && isSelected && (
<div className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}>
<div
className={`absolute -right-1 -top-1 rounded-full bg-card p-0.5 shadow ${styles.text}`}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
@@ -182,4 +199,3 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
);
SwotCard.displayName = 'SwotCard';

View File

@@ -92,9 +92,10 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
className={`
flex h-5 w-5 items-center justify-center rounded-full
text-xs font-medium transition-all
${showHelp
? 'bg-foreground/20 text-foreground'
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
${
showHelp
? 'bg-foreground/20 text-foreground'
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
}
`}
aria-label="Aide"
@@ -112,7 +113,12 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
aria-label={`Ajouter un item ${title}`}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
@@ -166,4 +172,3 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
);
SwotQuadrant.displayName = 'SwotQuadrant';

View File

@@ -3,4 +3,3 @@ export { SwotQuadrant } from './SwotQuadrant';
export { SwotCard } from './SwotCard';
export { ActionPanel } from './ActionPanel';
export { QuadrantHelp } from './QuadrantHelp';

View File

@@ -0,0 +1,30 @@
'use client';
import { getGravatarUrl, GravatarDefault } from '@/lib/gravatar';
interface AvatarProps {
email: string;
name?: string | null;
size?: number;
fallback?: GravatarDefault;
className?: string;
}
export function Avatar({
email,
name,
size = 32,
fallback = 'identicon',
className = '',
}: AvatarProps) {
return (
<img
src={getGravatarUrl(email, size * 2, fallback)} // 2x for retina displays
alt={name || email}
width={size}
height={size}
className={`rounded-full ${className}`}
loading="lazy"
/>
);
}

View File

@@ -49,4 +49,3 @@ export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
);
Badge.displayName = 'Badge';

View File

@@ -10,16 +10,11 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-foreground hover:bg-primary-hover border-transparent',
secondary:
'bg-card text-foreground hover:bg-card-hover border-border',
outline:
'bg-transparent text-foreground hover:bg-card-hover border-border',
ghost:
'bg-transparent text-foreground hover:bg-card-hover border-transparent',
destructive:
'bg-destructive text-white hover:bg-destructive/90 border-transparent',
primary: 'bg-primary text-primary-foreground hover:bg-primary-hover border-transparent',
secondary: 'bg-card text-foreground hover:bg-card-hover border-border',
outline: 'bg-transparent text-foreground hover:bg-card-hover border-border',
ghost: 'bg-transparent text-foreground hover:bg-card-hover border-transparent',
destructive: 'bg-destructive text-white hover:bg-destructive/90 border-transparent',
};
const sizeStyles: Record<ButtonSize, string> = {
@@ -29,7 +24,10 @@ const sizeStyles: Record<ButtonSize, string> = {
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'primary', size = 'md', loading, disabled, children, ...props }, ref) => {
(
{ className = '', variant = 'primary', size = 'md', loading, disabled, children, ...props },
ref
) => {
return (
<button
ref={ref}
@@ -74,4 +72,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = 'Button';

View File

@@ -40,11 +40,12 @@ export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadi
CardTitle.displayName = 'CardTitle';
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className = '', ...props }, ref) => (
<p ref={ref} className={`mt-1 text-sm text-muted ${className}`} {...props} />
)
);
export const CardDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className = '', ...props }, ref) => (
<p ref={ref} className={`mt-1 text-sm text-muted ${className}`} {...props} />
));
CardDescription.displayName = 'CardDescription';
@@ -63,4 +64,3 @@ export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,81 @@
import { getGravatarUrl } from '@/lib/gravatar';
interface CollaboratorDisplayProps {
collaborator: {
raw: string;
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
};
size?: 'sm' | 'md' | 'lg';
showEmail?: boolean;
}
const sizeConfig = {
sm: { avatar: 24, text: 'text-sm', gap: 'gap-1.5' },
md: { avatar: 32, text: 'text-base', gap: 'gap-2' },
lg: { avatar: 40, text: 'text-lg', gap: 'gap-3' },
};
export function CollaboratorDisplay({
collaborator,
size = 'md',
showEmail = false,
}: CollaboratorDisplayProps) {
const { raw, matchedUser } = collaborator;
const config = sizeConfig[size];
// If we have a matched user, show their avatar and name
if (matchedUser) {
const displayName = matchedUser.name || matchedUser.email.split('@')[0];
return (
<div className={`flex items-center ${config.gap}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getGravatarUrl(matchedUser.email, config.avatar * 2)}
alt={displayName}
width={config.avatar}
height={config.avatar}
className="rounded-full border border-border"
/>
<div className="min-w-0">
<span className={`${config.text} font-medium text-foreground truncate block`}>
{displayName}
</span>
{showEmail && matchedUser.name && (
<span className="text-xs text-muted truncate block">{matchedUser.email}</span>
)}
</div>
</div>
);
}
// No match - just show the raw name with a default person icon
return (
<div className={`flex items-center ${config.gap}`}>
<div
className="flex items-center justify-center rounded-full border border-border bg-card-hover"
style={{ width: config.avatar, height: config.avatar }}
>
<svg
className="text-muted"
style={{ width: config.avatar * 0.5, height: config.avatar * 0.5 }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<span className={`${config.text} text-muted truncate`}>{raw}</span>
</div>
);
}

View File

@@ -12,10 +12,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="mb-2 block text-sm font-medium text-foreground"
>
<label htmlFor={inputId} className="mb-2 block text-sm font-medium text-foreground">
{label}
</label>
)}
@@ -32,13 +29,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
`}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-destructive">{error}</p>
)}
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -84,12 +84,7 @@ export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalPr
className="rounded-lg p-1 text-muted hover:bg-card-hover hover:text-foreground transition-colors"
aria-label="Fermer"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -12,10 +12,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="mb-2 block text-sm font-medium text-foreground"
>
<label htmlFor={textareaId} className="mb-2 block text-sm font-medium text-foreground">
{label}
</label>
)}
@@ -33,13 +30,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
`}
{...props}
/>
{error && (
<p className="mt-1.5 text-sm text-destructive">{error}</p>
)}
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
</div>
);
}
);
Textarea.displayName = 'Textarea';

View File

@@ -1,7 +1,8 @@
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Button } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { CollaboratorDisplay } from './CollaboratorDisplay';
export { Input } from './Input';
export { Textarea } from './Textarea';
export { Badge } from './Badge';
export { Modal, ModalFooter } from './Modal';
export { Textarea } from './Textarea';

View File

@@ -128,4 +128,3 @@ export function useMotivatorLive({
return { isConnected, lastEvent, error };
}

View File

@@ -71,7 +71,7 @@ export function useSessionLive({
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as LiveEvent;
// Handle connection event
if (data.type === 'connected') {
return;
@@ -129,4 +129,3 @@ export function useSessionLive({
return { isConnected, lastEvent, error };
}

View File

@@ -29,4 +29,3 @@ export const authConfig: NextAuthConfig = {
},
providers: [], // Configured in auth.ts
};

View File

@@ -59,4 +59,3 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
},
});

20
src/lib/gravatar.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createHash } from 'crypto';
export type GravatarDefault =
| 'identicon' // Geometric pattern
| 'mp' // Mystery person silhouette
| 'retro' // 8-bit pixel art
| 'robohash' // Generated robot
| 'wavatar' // Stylized face
| 'monsterid' // Colorful monster
| 'blank'; // Transparent
export function getGravatarUrl(
email: string,
size: number = 40,
fallback: GravatarDefault = 'identicon'
): string {
const hash = createHash('md5').update(email.toLowerCase().trim()).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?d=${fallback}&s=${size}`;
}

View File

@@ -230,7 +230,7 @@ export const MOTIVATORS_CONFIG: MotivatorConfig[] = [
type: 'POWER',
name: 'Pouvoir',
icon: '⚡',
description: 'Avoir de l\'influence et du contrôle sur les décisions',
description: "Avoir de l'influence et du contrôle sur les décisions",
color: '#ef4444', // red
},
{
@@ -291,12 +291,10 @@ export const MOTIVATORS_CONFIG: MotivatorConfig[] = [
},
];
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> =
MOTIVATORS_CONFIG.reduce(
(acc, config) => {
acc[config.type] = config;
return acc;
},
{} as Record<MotivatorType, MotivatorConfig>
);
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> = MOTIVATORS_CONFIG.reduce(
(acc, config) => {
acc[config.type] = config;
return acc;
},
{} as Record<MotivatorType, MotivatorConfig>
);

View File

@@ -7,4 +7,3 @@ export const config = {
// Match all paths except static files and api routes that don't need auth
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -60,6 +60,51 @@ export async function getUserByEmail(email: string) {
});
}
// Check if string looks like an email
function isEmail(str: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
}
export interface ResolvedCollaborator {
raw: string; // Original value (name or email)
matchedUser: {
id: string;
email: string;
name: string | null;
} | null;
}
// Resolve collaborator string to user - try email first, then name
export async function resolveCollaborator(collaborator: string): Promise<ResolvedCollaborator> {
const trimmed = collaborator.trim();
// 1. Try email match first
if (isEmail(trimmed)) {
const user = await prisma.user.findUnique({
where: { email: trimmed.toLowerCase() },
select: { id: true, email: true, name: true },
});
if (user) {
return { raw: collaborator, matchedUser: user };
}
}
// 2. Fallback: try matching by name (case-insensitive via raw query for SQLite)
// SQLite LIKE is case-insensitive by default for ASCII
const users = await prisma.user.findMany({
where: {
name: { not: null },
},
select: { id: true, email: true, name: true },
});
const normalizedSearch = trimmed.toLowerCase();
const userByName = users.find((u) => u.name?.toLowerCase() === normalizedSearch) || null;
return { raw: collaborator, matchedUser: userByName };
}
export async function getUserById(id: string) {
return prisma.user.findUnique({
where: { id },
@@ -144,3 +189,60 @@ export async function updateUserPassword(
return { success: true };
}
export interface UserStats {
sessions: number;
motivatorSessions: number;
sharedSessions: number;
sharedMotivatorSessions: number;
}
export interface UserWithStats {
id: string;
email: string;
name: string | null;
createdAt: Date;
updatedAt: Date;
_count: UserStats;
}
export async function getAllUsersWithStats(): Promise<UserWithStats[]> {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
sessions: true,
sharedSessions: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
// Get motivator sessions count separately (Prisma doesn't have these in User model _count directly)
const usersWithMotivators = await Promise.all(
users.map(async (user) => {
const motivatorCount = await prisma.movingMotivatorsSession.count({
where: { userId: user.id },
});
const sharedMotivatorCount = await prisma.mMSessionShare.count({
where: { userId: user.id },
});
return {
...user,
_count: {
...user._count,
motivatorSessions: motivatorCount,
sharedMotivatorSessions: sharedMotivatorCount,
},
};
})
);
return usersWithMotivators;
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, MotivatorType } from '@prisma/client';
// ============================================
@@ -48,7 +49,11 @@ export async function getMotivatorSessionsByUserId(userId: string) {
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const }));
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
@@ -56,9 +61,19 @@ export async function getMotivatorSessionsByUserId(userId: string) {
sharedAt: s.createdAt,
}));
return [...ownedWithRole, ...sharedWithRole].sort(
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve participants to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedParticipant: await resolveCollaborator(s.participant),
}))
);
return sessionsWithResolved;
}
export async function getMotivatorSessionById(sessionId: string, userId: string) {
@@ -92,7 +107,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
// Resolve participant to user if it's an email
const resolvedParticipant = await resolveCollaborator(session.participant);
return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
@@ -186,10 +204,7 @@ export async function updateMotivatorCard(
});
}
export async function reorderMotivatorCards(
sessionId: string,
cardIds: string[]
) {
export async function reorderMotivatorCards(sessionId: string, cardIds: string[]) {
const updates = cardIds.map((id, index) =>
prisma.motivatorCard.update({
where: { id },
@@ -336,4 +351,3 @@ export async function getLatestMotivatorEventTimestamp(sessionId: string) {
});
return event?.createdAt;
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/services/database';
import { resolveCollaborator } from '@/services/auth';
import type { SwotCategory, ShareRole } from '@prisma/client';
// ============================================
@@ -50,7 +51,11 @@ export async function getSessionsByUserId(userId: string) {
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const }));
const ownedWithRole = owned.map((s) => ({
...s,
isOwner: true as const,
role: 'OWNER' as const,
}));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
@@ -58,9 +63,19 @@ export async function getSessionsByUserId(userId: string) {
sharedAt: s.createdAt,
}));
return [...ownedWithRole, ...sharedWithRole].sort(
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Resolve collaborators to users
const sessionsWithResolved = await Promise.all(
allSessions.map(async (s) => ({
...s,
resolvedCollaborator: await resolveCollaborator(s.collaborator),
}))
);
return sessionsWithResolved;
}
export async function getSessionById(sessionId: string, userId: string) {
@@ -104,7 +119,10 @@ export async function getSessionById(sessionId: string, userId: string) {
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
// Resolve collaborator to user if it's an email
const resolvedCollaborator = await resolveCollaborator(session.collaborator);
return { ...session, isOwner, role, canEdit, resolvedCollaborator };
}
// Check if user can access session (owner or shared)
@@ -234,11 +252,7 @@ export async function reorderSwotItems(
return prisma.$transaction(updates);
}
export async function moveSwotItem(
itemId: string,
newCategory: SwotCategory,
newOrder: number
) {
export async function moveSwotItem(itemId: string, newCategory: SwotCategory, newOrder: number) {
return prisma.swotItem.update({
where: { id: itemId },
data: {
@@ -450,4 +464,3 @@ export async function getLatestEventTimestamp(sessionId: string) {
});
return event?.createdAt;
}