Compare commits
10 Commits
4c63945505
...
291dace07a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
291dace07a | ||
|
|
71d850c985 | ||
|
|
b3157fffbd | ||
|
|
1f666713e8 | ||
|
|
cee3cd7b47 | ||
|
|
eaeb1335fa | ||
|
|
941151553f | ||
|
|
ff7c846ed1 | ||
|
|
cb4873cd40 | ||
|
|
ac079ed8b2 |
47
.dockerignore
Normal file
47
.dockerignore
Normal 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
66
Dockerfile
Normal 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
BIN
data/dev.db
Normal file
Binary file not shown.
BIN
data/prod.db
Normal file
BIN
data/prod.db
Normal file
Binary file not shown.
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3011: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:-http://localhost:3011}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
9
docker-entrypoint.sh
Normal file
9
docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔄 Running database migrations..."
|
||||
pnpm prisma migrate deploy
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
exec node server.js
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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
215
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -228,4 +228,3 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
||||
return { success: false, error: 'Erreur lors de la suppression' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,4 +109,3 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -170,4 +170,3 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
|
||||
@@ -22,4 +22,3 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Erreur lors de la création du compte' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,4 +107,3 @@ export function EditableMotivatorTitle({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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'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'épanouir et d'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'épanouir et d'aligner nos missions avec nos aspirations profondes.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{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,7 +290,8 @@ function TabButton({
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
|
||||
${active
|
||||
${
|
||||
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,7 +327,47 @@ 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 (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<Link href={href}>
|
||||
<Card hover className="h-full p-4 relative overflow-hidden">
|
||||
{/* Accent bar */}
|
||||
@@ -192,14 +379,13 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
{/* 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>
|
||||
<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)',
|
||||
backgroundColor:
|
||||
session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
|
||||
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
|
||||
}}
|
||||
>
|
||||
@@ -209,12 +395,14 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
</div>
|
||||
|
||||
{/* Participant + Owner info */}
|
||||
<p className="text-sm text-muted mb-3 line-clamp-1">
|
||||
👤 {participant}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
|
||||
{!session.isOwner && (
|
||||
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
|
||||
<span className="text-xs text-muted">
|
||||
· par {session.user.name || session.user.email}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer: Stats + Avatars + Date */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
@@ -258,15 +446,142 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
</div>
|
||||
))}
|
||||
{session.shares.length > 3 && (
|
||||
<span className="text-[10px] text-muted">
|
||||
+{session.shares.length - 3}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</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'atelier{' '}
|
||||
<strong className="text-foreground">"{session.title}"</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
219
src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,16 +117,12 @@ 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>
|
||||
|
||||
@@ -177,5 +170,3 @@ export function ShareModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { LiveIndicator } from './LiveIndicator';
|
||||
export { ShareModal } from './ShareModal';
|
||||
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -140,4 +140,3 @@ function InfluenceSlider({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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'analyse de vos motivations et leur impact
|
||||
</p>
|
||||
<p className="text-muted">Voici l'analyse de vos motivations et leur impact</p>
|
||||
</div>
|
||||
|
||||
<MotivatorSummary cards={sortedCards} />
|
||||
@@ -273,4 +269,3 @@ function StepIndicator({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,16 +117,12 @@ 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>
|
||||
|
||||
@@ -177,4 +170,3 @@ export function MotivatorShareModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ export { MotivatorSummary } from './MotivatorSummary';
|
||||
export { InfluenceZone } from './InfluenceZone';
|
||||
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
||||
export { MotivatorShareModal } from './MotivatorShareModal';
|
||||
|
||||
|
||||
@@ -103,4 +103,3 @@ export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitl
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { EditableTitle } from './EditableTitle';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +72,8 @@ export function QuadrantHelp({ category }: QuadrantHelpProps) {
|
||||
className={`
|
||||
flex h-5 w-5 items-center justify-center rounded-full
|
||||
text-xs font-medium transition-all
|
||||
${isOpen
|
||||
${
|
||||
isOpen
|
||||
? 'bg-foreground/20 text-foreground rotate-45'
|
||||
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ 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
|
||||
${
|
||||
showHelp
|
||||
? 'bg-foreground/20 text-foreground'
|
||||
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ export { SwotQuadrant } from './SwotQuadrant';
|
||||
export { SwotCard } from './SwotCard';
|
||||
export { ActionPanel } from './ActionPanel';
|
||||
export { QuadrantHelp } from './QuadrantHelp';
|
||||
|
||||
|
||||
30
src/components/ui/Avatar.tsx
Normal file
30
src/components/ui/Avatar.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -49,4 +49,3 @@ export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
);
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -40,11 +40,12 @@ export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadi
|
||||
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
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';
|
||||
|
||||
|
||||
81
src/components/ui/CollaboratorDisplay.tsx
Normal file
81
src/components/ui/CollaboratorDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -128,4 +128,3 @@ export function useMotivatorLive({
|
||||
|
||||
return { isConnected, lastEvent, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -129,4 +129,3 @@ export function useSessionLive({
|
||||
|
||||
return { isConnected, lastEvent, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,3 @@ export const authConfig: NextAuthConfig = {
|
||||
},
|
||||
providers: [], // Configured in auth.ts
|
||||
};
|
||||
|
||||
|
||||
@@ -59,4 +59,3 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
20
src/lib/gravatar.ts
Normal file
20
src/lib/gravatar.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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(
|
||||
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> = MOTIVATORS_CONFIG.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.type] = config;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<MotivatorType, MotivatorConfig>
|
||||
);
|
||||
|
||||
|
||||
@@ -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).*)'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user