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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
"@prisma/adapter-better-sqlite3": "^7.0.1",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.1.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"next": "16.0.5",
|
"next": "16.0.7",
|
||||||
"next-auth": "5.0.0-beta.30",
|
"next-auth": "5.0.0-beta.30",
|
||||||
"prisma": "^7.0.1",
|
"prisma": "^7.1.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "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
|
specifier: ^7.0.1
|
||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.0.1
|
specifier: ^7.1.0
|
||||||
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)
|
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:
|
bcryptjs:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
@@ -33,14 +33,14 @@ importers:
|
|||||||
specifier: ^12.4.6
|
specifier: ^12.4.6
|
||||||
version: 12.4.6
|
version: 12.4.6
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.5
|
specifier: 16.0.7
|
||||||
version: 16.0.5(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.30
|
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:
|
prisma:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.1.0
|
||||||
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)
|
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:
|
react:
|
||||||
specifier: 19.2.0
|
specifier: 19.2.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
@@ -277,8 +277,8 @@ packages:
|
|||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@hono/node-server@1.14.2':
|
'@hono/node-server@1.19.6':
|
||||||
resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==}
|
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
@@ -459,56 +459,56 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@next/env@16.0.5':
|
'@next/env@16.0.7':
|
||||||
resolution: {integrity: sha512-jRLOw822AE6aaIm9oh0NrauZEM0Vtx5xhYPgqx89txUmv/UmcRwpcXmGeQOvYNT/1bakUwA+nG5CA74upYVVDw==}
|
resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.5':
|
'@next/eslint-plugin-next@16.0.5':
|
||||||
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
|
resolution: {integrity: sha512-m1zPz6hsBvQt1CMRz7rTga8OXpRE9rVW4JHCSjW+tswTxiEU+6ev+GTlgm7ZzcCiMEVQAHTNhpEGFzDtVha9qg==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.5':
|
'@next/swc-darwin-arm64@16.0.7':
|
||||||
resolution: {integrity: sha512-65Mfo1rD+mVbJuBTlXbNelNOJ5ef+5pskifpFHsUt3cnOWjDNKctHBwwSz9tJlPp7qADZtiN/sdcG7mnc0El8Q==}
|
resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.5':
|
'@next/swc-darwin-x64@16.0.7':
|
||||||
resolution: {integrity: sha512-2fDzXD/JpEjY500VUF0uuGq3YZcpC6XxmGabePPLyHCKbw/YXRugv3MRHH7MxE2hVHtryXeSYYnxcESb/3OUIQ==}
|
resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.5':
|
'@next/swc-linux-arm64-gnu@16.0.7':
|
||||||
resolution: {integrity: sha512-meSLB52fw4tgDpPnyuhwA280EWLwwIntrxLYjzKU3e3730ur2WJAmmqoZ1LPIZ2l3eDfh9SBHnJGTczbgPeNeA==}
|
resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.5':
|
'@next/swc-linux-arm64-musl@16.0.7':
|
||||||
resolution: {integrity: sha512-aAJtQkvUzz5t0xVAmK931SIhWnSQAaEoTyG/sKPCYq2u835K/E4a14A+WRPd4dkhxIHNudE8dI+FpHekgdrA4g==}
|
resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.5':
|
'@next/swc-linux-x64-gnu@16.0.7':
|
||||||
resolution: {integrity: sha512-bYwbjBwooMWRhy6vRxenaYdguTM2hlxFt1QBnUF235zTnU2DhGpETm5WU93UvtAy0uhC5Kgqsl8RyNXlprFJ6Q==}
|
resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.5':
|
'@next/swc-linux-x64-musl@16.0.7':
|
||||||
resolution: {integrity: sha512-iGv2K/4gW3mkzh+VcZTf2gEGX5o9xdb5oPqHjgZvHdVzCw0iSAJ7n9vKzl3SIEIIHZmqRsgNasgoLd0cxaD+tg==}
|
resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.5':
|
'@next/swc-win32-arm64-msvc@16.0.7':
|
||||||
resolution: {integrity: sha512-6xf52Hp4SH9+4jbYmfUleqkuxvdB9JJRwwFlVG38UDuEGPqpIA+0KiJEU9lxvb0RGNo2i2ZUhc5LHajij9H9+A==}
|
resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.5':
|
'@next/swc-win32-x64-msvc@16.0.7':
|
||||||
resolution: {integrity: sha512-06kTaOh+Qy/kguN+MMK+/VtKmRkQJrPlGQMvCUbABk1UxI5SKTgJhbmMj9Hf0qWwrS6g9JM6/Zk+etqeMyvHAw==}
|
resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -535,11 +535,11 @@ packages:
|
|||||||
'@prisma/adapter-better-sqlite3@7.0.1':
|
'@prisma/adapter-better-sqlite3@7.0.1':
|
||||||
resolution: {integrity: sha512-fgnn+lkUV/7pUvPnQSI/ZHONwGU+eisWqU6tvBFGK2ovgBUAJN8zG0fbt8v8Brphyo4h4AA3+jyG0TFFb+qVxQ==}
|
resolution: {integrity: sha512-fgnn+lkUV/7pUvPnQSI/ZHONwGU+eisWqU6tvBFGK2ovgBUAJN8zG0fbt8v8Brphyo4h4AA3+jyG0TFFb+qVxQ==}
|
||||||
|
|
||||||
'@prisma/client-runtime-utils@7.0.1':
|
'@prisma/client-runtime-utils@7.1.0':
|
||||||
resolution: {integrity: sha512-R26BVX9D/iw4toUmZKZf3jniM/9pMGHHdZN5LVP2L7HNiCQKNQQx/9LuMtjepbgRqSqQO3oHN0yzojHLnKTGEw==}
|
resolution: {integrity: sha512-39xmeBrNTN40FzF34aJMjfX1PowVCqoT3UKUWBBSP3aXV05NRqGBC3x2wCDs96ti6ZgdiVzqnRDHtbzU8X+lPQ==}
|
||||||
|
|
||||||
'@prisma/client@7.0.1':
|
'@prisma/client@7.1.0':
|
||||||
resolution: {integrity: sha512-O74T6xcfaGAq5gXwCAvfTLvI6fmC3and2g5yLRMkNjri1K8mSpEgclDNuUWs9xj5AwNEMQ88NeD3asI+sovm1g==}
|
resolution: {integrity: sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==}
|
||||||
engines: {node: ^20.19 || ^22.12 || >=24.0}
|
engines: {node: ^20.19 || ^22.12 || >=24.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prisma: '*'
|
prisma: '*'
|
||||||
@@ -550,8 +550,8 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@prisma/config@7.0.1':
|
'@prisma/config@7.1.0':
|
||||||
resolution: {integrity: sha512-MacIjXdo+hNKxPvtMzDXykIIc8HCRWoyjQ2nguJTFqLDzJBD5L6QRaANGTLOqbGtJ3sFvLRmfXhrFg3pWoK1BA==}
|
resolution: {integrity: sha512-Uz+I43Wn1RYNHtuYtOhOnUcNMWp2Pd3GUDDKs37xlHptCGpzEG3MRR9L+8Y2ISMsMI24z/Ni+ww6OB/OO8M0sQ==}
|
||||||
|
|
||||||
'@prisma/debug@6.8.2':
|
'@prisma/debug@6.8.2':
|
||||||
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
|
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
|
||||||
@@ -559,26 +559,29 @@ packages:
|
|||||||
'@prisma/debug@7.0.1':
|
'@prisma/debug@7.0.1':
|
||||||
resolution: {integrity: sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==}
|
resolution: {integrity: sha512-5+25XokVeAK2Z2C9W457AFw7Hk032Q3QI3G58KYKXPlpgxy+9FvV1+S1jqfJ2d4Nmq9LP/uACrM6OVhpJMSr8w==}
|
||||||
|
|
||||||
'@prisma/dev@0.13.0':
|
'@prisma/debug@7.1.0':
|
||||||
resolution: {integrity: sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA==}
|
resolution: {integrity: sha512-pPAckG6etgAsEBusmZiFwM9bldLSNkn++YuC4jCTJACdK5hLOVnOzX7eSL2FgaU6Gomd6wIw21snUX2dYroMZQ==}
|
||||||
|
|
||||||
|
'@prisma/dev@0.15.0':
|
||||||
|
resolution: {integrity: sha512-KhWaipnFlS/fWEs6I6Oqjcy2S08vKGmxJ5LexqUl/3Ve0EgLUsZwdKF0MvqPM5F5ttw8GtfZarjM5y7VLwv9Ow==}
|
||||||
|
|
||||||
'@prisma/driver-adapter-utils@7.0.1':
|
'@prisma/driver-adapter-utils@7.0.1':
|
||||||
resolution: {integrity: sha512-sBbxm/yysHLLF2iMAB+qcX/nn3WFgsiC4DQNz0uM6BwGSIs8lIvgo0u8nR9nxe5gvFgKiIH8f4z2fgOEMeXc8w==}
|
resolution: {integrity: sha512-sBbxm/yysHLLF2iMAB+qcX/nn3WFgsiC4DQNz0uM6BwGSIs8lIvgo0u8nR9nxe5gvFgKiIH8f4z2fgOEMeXc8w==}
|
||||||
|
|
||||||
'@prisma/engines-version@7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6':
|
'@prisma/engines-version@7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba':
|
||||||
resolution: {integrity: sha512-RA7pShKvijHib4USRB3YuLTQamHKJPkTRDc45AwxfahUQngiGVMlIj4ix4emUxkrum4o/jwn82WIwlG57EtgiQ==}
|
resolution: {integrity: sha512-qZUevUh+yPhGT28rDQnV8V2kLnFjirzhVD67elRPIJHRsUV/mkII10HSrJrhK/U2GYgAxXR2VEREtq7AsfS8qw==}
|
||||||
|
|
||||||
'@prisma/engines@7.0.1':
|
'@prisma/engines@7.1.0':
|
||||||
resolution: {integrity: sha512-f+D/vdKeImqUHysd5Bgv8LQ1whl4sbLepHyYMQQMK61cp4WjwJVryophleLUrfEJRpBLGTBI/7fnLVENxxMFPQ==}
|
resolution: {integrity: sha512-KQlraOybdHAzVv45KWKJzpR9mJLkib7/TyApQpqrsL7FUHfgjIcy8jrVGt3iNfG6/GDDl+LNlJ84JSQwIfdzxA==}
|
||||||
|
|
||||||
'@prisma/fetch-engine@7.0.1':
|
'@prisma/fetch-engine@7.1.0':
|
||||||
resolution: {integrity: sha512-5DnSairYIYU7dcv/9pb1KCwIRHZfhVOd34855d01lUI5QdF9rdCkMywPQbBM67YP7iCgQoEZO0/COtOMpR4i9A==}
|
resolution: {integrity: sha512-GZYF5Q8kweXWGfn87hTu17kw7x1DgnehgKoE4Zg1BmHYF3y1Uu0QRY/qtSE4veH3g+LW8f9HKqA0tARG66bxxQ==}
|
||||||
|
|
||||||
'@prisma/get-platform@6.8.2':
|
'@prisma/get-platform@6.8.2':
|
||||||
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
|
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
|
||||||
|
|
||||||
'@prisma/get-platform@7.0.1':
|
'@prisma/get-platform@7.1.0':
|
||||||
resolution: {integrity: sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg==}
|
resolution: {integrity: sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q==}
|
||||||
|
|
||||||
'@prisma/query-plan-executor@6.18.0':
|
'@prisma/query-plan-executor@6.18.0':
|
||||||
resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==}
|
resolution: {integrity: sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA==}
|
||||||
@@ -1478,8 +1481,8 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
grammex@3.1.11:
|
grammex@3.1.12:
|
||||||
resolution: {integrity: sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg==}
|
resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==}
|
||||||
|
|
||||||
graphemer@1.4.0:
|
graphemer@1.4.0:
|
||||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
@@ -1517,8 +1520,8 @@ packages:
|
|||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
hono@4.7.10:
|
hono@4.10.6:
|
||||||
resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==}
|
resolution: {integrity: sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
http-status-codes@2.3.0:
|
http-status-codes@2.3.0:
|
||||||
@@ -1905,8 +1908,8 @@ packages:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
next@16.0.5:
|
next@16.0.7:
|
||||||
resolution: {integrity: sha512-XUPsFqSqu/NDdPfn/cju9yfIedkDI7ytDoALD9todaSMxk1Z5e3WcbUjfI9xsanFTys7xz62lnRWNFqJordzkQ==}
|
resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2071,8 +2074,8 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
prisma@7.0.1:
|
prisma@7.1.0:
|
||||||
resolution: {integrity: sha512-zp93MdFMSU1IHPEXbUHVUuD8wauh2BUm14OVxhxGrWJQQpXpda0rW4VSST2bci4raoldX64/wQxHKkl/wqDskQ==}
|
resolution: {integrity: sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==}
|
||||||
engines: {node: ^20.19 || ^22.12 || >=24.0}
|
engines: {node: ^20.19 || ^22.12 || >=24.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2459,8 +2462,8 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
valibot@1.1.0:
|
valibot@1.2.0:
|
||||||
resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
|
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5'
|
typescript: '>=5'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
@@ -2752,9 +2755,9 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
'@hono/node-server@1.14.2(hono@4.7.10)':
|
'@hono/node-server@1.19.6(hono@4.10.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.7.10
|
hono: 4.10.6
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
@@ -2895,34 +2898,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.0.5': {}
|
'@next/env@16.0.7': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.5':
|
'@next/eslint-plugin-next@16.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.5':
|
'@next/swc-darwin-arm64@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.5':
|
'@next/swc-darwin-x64@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.5':
|
'@next/swc-linux-arm64-gnu@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.5':
|
'@next/swc-linux-arm64-musl@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.5':
|
'@next/swc-linux-x64-gnu@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.5':
|
'@next/swc-linux-x64-musl@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.5':
|
'@next/swc-win32-arm64-msvc@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.5':
|
'@next/swc-win32-x64-msvc@16.0.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -2946,16 +2949,16 @@ snapshots:
|
|||||||
'@prisma/driver-adapter-utils': 7.0.1
|
'@prisma/driver-adapter-utils': 7.0.1
|
||||||
better-sqlite3: 12.4.6
|
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:
|
dependencies:
|
||||||
'@prisma/client-runtime-utils': 7.0.1
|
'@prisma/client-runtime-utils': 7.1.0
|
||||||
optionalDependencies:
|
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
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@prisma/config@7.0.1':
|
'@prisma/config@7.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 3.1.0
|
c12: 3.1.0
|
||||||
deepmerge-ts: 7.1.5
|
deepmerge-ts: 7.1.5
|
||||||
@@ -2968,24 +2971,26 @@ snapshots:
|
|||||||
|
|
||||||
'@prisma/debug@7.0.1': {}
|
'@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:
|
dependencies:
|
||||||
'@electric-sql/pglite': 0.3.2
|
'@electric-sql/pglite': 0.3.2
|
||||||
'@electric-sql/pglite-socket': 0.0.6(@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)
|
'@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
|
'@mrleebo/prisma-ast': 0.12.1
|
||||||
'@prisma/get-platform': 6.8.2
|
'@prisma/get-platform': 6.8.2
|
||||||
'@prisma/query-plan-executor': 6.18.0
|
'@prisma/query-plan-executor': 6.18.0
|
||||||
foreground-child: 3.3.1
|
foreground-child: 3.3.1
|
||||||
get-port-please: 3.1.2
|
get-port-please: 3.1.2
|
||||||
hono: 4.7.10
|
hono: 4.10.6
|
||||||
http-status-codes: 2.3.0
|
http-status-codes: 2.3.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
proper-lockfile: 4.1.2
|
proper-lockfile: 4.1.2
|
||||||
remeda: 2.21.3
|
remeda: 2.21.3
|
||||||
std-env: 3.9.0
|
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
|
zeptomatch: 2.0.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
@@ -2994,28 +2999,28 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 7.0.1
|
'@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:
|
dependencies:
|
||||||
'@prisma/debug': 7.0.1
|
'@prisma/debug': 7.1.0
|
||||||
'@prisma/engines-version': 7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6
|
'@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
|
||||||
'@prisma/fetch-engine': 7.0.1
|
'@prisma/fetch-engine': 7.1.0
|
||||||
'@prisma/get-platform': 7.0.1
|
'@prisma/get-platform': 7.1.0
|
||||||
|
|
||||||
'@prisma/fetch-engine@7.0.1':
|
'@prisma/fetch-engine@7.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 7.0.1
|
'@prisma/debug': 7.1.0
|
||||||
'@prisma/engines-version': 7.1.0-2.f09f2815f091dbba658cdcd2264306d88bb5bda6
|
'@prisma/engines-version': 7.1.0-6.ab635e6b9d606fa5c8fb8b1a7f909c3c3c1c98ba
|
||||||
'@prisma/get-platform': 7.0.1
|
'@prisma/get-platform': 7.1.0
|
||||||
|
|
||||||
'@prisma/get-platform@6.8.2':
|
'@prisma/get-platform@6.8.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 6.8.2
|
'@prisma/debug': 6.8.2
|
||||||
|
|
||||||
'@prisma/get-platform@7.0.1':
|
'@prisma/get-platform@7.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/debug': 7.0.1
|
'@prisma/debug': 7.1.0
|
||||||
|
|
||||||
'@prisma/query-plan-executor@6.18.0': {}
|
'@prisma/query-plan-executor@6.18.0': {}
|
||||||
|
|
||||||
@@ -4071,7 +4076,7 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
grammex@3.1.11: {}
|
grammex@3.1.12: {}
|
||||||
|
|
||||||
graphemer@1.4.0: {}
|
graphemer@1.4.0: {}
|
||||||
|
|
||||||
@@ -4103,7 +4108,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
hono@4.7.10: {}
|
hono@4.10.6: {}
|
||||||
|
|
||||||
http-status-codes@2.3.0: {}
|
http-status-codes@2.3.0: {}
|
||||||
|
|
||||||
@@ -4433,15 +4438,15 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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:
|
dependencies:
|
||||||
'@auth/core': 0.41.0
|
'@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
|
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:
|
dependencies:
|
||||||
'@next/env': 16.0.5
|
'@next/env': 16.0.7
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
caniuse-lite: 1.0.30001757
|
caniuse-lite: 1.0.30001757
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
@@ -4449,14 +4454,14 @@ snapshots:
|
|||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.0.5
|
'@next/swc-darwin-arm64': 16.0.7
|
||||||
'@next/swc-darwin-x64': 16.0.5
|
'@next/swc-darwin-x64': 16.0.7
|
||||||
'@next/swc-linux-arm64-gnu': 16.0.5
|
'@next/swc-linux-arm64-gnu': 16.0.7
|
||||||
'@next/swc-linux-arm64-musl': 16.0.5
|
'@next/swc-linux-arm64-musl': 16.0.7
|
||||||
'@next/swc-linux-x64-gnu': 16.0.5
|
'@next/swc-linux-x64-gnu': 16.0.7
|
||||||
'@next/swc-linux-x64-musl': 16.0.5
|
'@next/swc-linux-x64-musl': 16.0.7
|
||||||
'@next/swc-win32-arm64-msvc': 16.0.5
|
'@next/swc-win32-arm64-msvc': 16.0.7
|
||||||
'@next/swc-win32-x64-msvc': 16.0.5
|
'@next/swc-win32-x64-msvc': 16.0.7
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -4618,11 +4623,11 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.7.1: {}
|
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:
|
dependencies:
|
||||||
'@prisma/config': 7.0.1
|
'@prisma/config': 7.1.0
|
||||||
'@prisma/dev': 0.13.0(typescript@5.9.3)
|
'@prisma/dev': 0.15.0(typescript@5.9.3)
|
||||||
'@prisma/engines': 7.0.1
|
'@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)
|
'@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
|
mysql2: 3.15.3
|
||||||
postgres: 3.4.7
|
postgres: 3.4.7
|
||||||
@@ -5117,7 +5122,7 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
valibot@1.1.0(typescript@5.9.3):
|
valibot@1.2.0(typescript@5.9.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
@@ -5176,7 +5181,7 @@ snapshots:
|
|||||||
|
|
||||||
zeptomatch@2.0.2:
|
zeptomatch@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
grammex: 3.1.11
|
grammex: 3.1.12
|
||||||
|
|
||||||
zod-validation-error@4.0.2(zod@4.1.13):
|
zod-validation-error@4.0.2(zod@4.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ export async function createMotivatorSession(data: { title: string; participant:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const motivatorSession = await motivatorsService.createMotivatorSession(
|
const motivatorSession = await motivatorsService.createMotivatorSession(session.user.id, data);
|
||||||
session.user.id,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
revalidatePath('/motivators');
|
revalidatePath('/motivators');
|
||||||
return { success: true, data: motivatorSession };
|
return { success: true, data: motivatorSession };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,6 +46,7 @@ export async function updateMotivatorSession(
|
|||||||
|
|
||||||
revalidatePath(`/motivators/${sessionId}`);
|
revalidatePath(`/motivators/${sessionId}`);
|
||||||
revalidatePath('/motivators');
|
revalidatePath('/motivators');
|
||||||
|
revalidatePath('/sessions'); // Also revalidate unified workshops page
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating motivator session:', error);
|
console.error('Error updating motivator session:', error);
|
||||||
@@ -65,6 +63,7 @@ export async function deleteMotivatorSession(sessionId: string) {
|
|||||||
try {
|
try {
|
||||||
await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id);
|
await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id);
|
||||||
revalidatePath('/motivators');
|
revalidatePath('/motivators');
|
||||||
|
revalidatePath('/sessions'); // Also revalidate unified workshops page
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting motivator session:', error);
|
console.error('Error deleting motivator session:', error);
|
||||||
@@ -87,10 +86,7 @@ export async function updateMotivatorCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check edit permission
|
// Check edit permission
|
||||||
const canEdit = await motivatorsService.canEditMotivatorSession(
|
const canEdit = await motivatorsService.canEditMotivatorSession(sessionId, authSession.user.id);
|
||||||
sessionId,
|
|
||||||
authSession.user.id
|
|
||||||
);
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return { success: false, error: 'Permission refusée' };
|
return { success: false, error: 'Permission refusée' };
|
||||||
}
|
}
|
||||||
@@ -130,10 +126,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check edit permission
|
// Check edit permission
|
||||||
const canEdit = await motivatorsService.canEditMotivatorSession(
|
const canEdit = await motivatorsService.canEditMotivatorSession(sessionId, authSession.user.id);
|
||||||
sessionId,
|
|
||||||
authSession.user.id
|
|
||||||
);
|
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return { success: false, error: 'Permission refusée' };
|
return { success: false, error: 'Permission refusée' };
|
||||||
}
|
}
|
||||||
@@ -157,11 +150,7 @@ export async function reorderMotivatorCards(sessionId: string, cardIds: string[]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCardInfluence(
|
export async function updateCardInfluence(cardId: string, sessionId: string, influence: number) {
|
||||||
cardId: string,
|
|
||||||
sessionId: string,
|
|
||||||
influence: number
|
|
||||||
) {
|
|
||||||
return updateMotivatorCard(cardId, sessionId, { influence });
|
return updateMotivatorCard(cardId, sessionId, { influence });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +179,7 @@ export async function shareMotivatorSession(
|
|||||||
return { success: true, data: share };
|
return { success: true, data: share };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sharing motivator session:', error);
|
console.error('Error sharing motivator session:', error);
|
||||||
const message =
|
const message = error instanceof Error ? error.message : 'Erreur lors du partage';
|
||||||
error instanceof Error ? error.message : 'Erreur lors du partage';
|
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,11 +191,7 @@ export async function removeMotivatorShare(sessionId: string, shareUserId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await motivatorsService.removeMotivatorShare(
|
await motivatorsService.removeMotivatorShare(sessionId, authSession.user.id, shareUserId);
|
||||||
sessionId,
|
|
||||||
authSession.user.id,
|
|
||||||
shareUserId
|
|
||||||
);
|
|
||||||
revalidatePath(`/motivators/${sessionId}`);
|
revalidatePath(`/motivators/${sessionId}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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' };
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePasswordAction(data: {
|
export async function updatePasswordAction(data: { currentPassword: string; newPassword: string }) {
|
||||||
currentPassword: string;
|
|
||||||
newPassword: string;
|
|
||||||
}) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Non authentifié' };
|
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' };
|
return { success: false, error: 'Le nouveau mot de passe doit faire au moins 6 caractères' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateUserPassword(
|
const result = await updateUserPassword(session.user.id, data.currentPassword, data.newPassword);
|
||||||
session.user.id,
|
|
||||||
data.currentPassword,
|
|
||||||
data.newPassword
|
|
||||||
);
|
|
||||||
return result;
|
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 { revalidatePath } from 'next/cache';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import {
|
import { shareSession, removeShare, getSessionShares } from '@/services/sessions';
|
||||||
shareSession,
|
|
||||||
removeShare,
|
|
||||||
getSessionShares,
|
|
||||||
} from '@/services/sessions';
|
|
||||||
import type { ShareRole } from '@prisma/client';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
export async function shareSessionAction(
|
export async function shareSessionAction(
|
||||||
@@ -26,10 +22,10 @@ export async function shareSessionAction(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Erreur inconnue';
|
const message = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||||
if (message === 'User not found') {
|
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') {
|
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 };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
@@ -65,5 +61,3 @@ export async function getSharesAction(sessionId: string) {
|
|||||||
return { success: false, error: message, data: [] };
|
return { success: false, error: message, data: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ export async function createSwotItem(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.createSwotItem(sessionId, data);
|
const item = await sessionsService.createSwotItem(sessionId, data);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: item };
|
return { success: true, data: item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,13 +48,13 @@ export async function updateSwotItem(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.updateSwotItem(itemId, data);
|
const item = await sessionsService.updateSwotItem(itemId, data);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_UPDATED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_UPDATED', {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: item };
|
return { success: true, data: item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -71,12 +71,12 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsService.deleteSwotItem(itemId);
|
await sessionsService.deleteSwotItem(itemId);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_DELETED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_DELETED', {
|
||||||
itemId,
|
itemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -93,7 +93,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.duplicateSwotItem(itemId);
|
const item = await sessionsService.duplicateSwotItem(itemId);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
@@ -101,7 +101,7 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
|||||||
category: item.category,
|
category: item.category,
|
||||||
duplicatedFrom: itemId,
|
duplicatedFrom: itemId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: item };
|
return { success: true, data: item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -123,14 +123,14 @@ export async function moveSwotItem(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_MOVED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_MOVED', {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
newCategory,
|
newCategory,
|
||||||
newOrder,
|
newOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: item };
|
return { success: true, data: item };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -159,14 +159,14 @@ export async function createAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const action = await sessionsService.createAction(sessionId, data);
|
const action = await sessionsService.createAction(sessionId, data);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_CREATED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_CREATED', {
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
title: action.title,
|
title: action.title,
|
||||||
linkedItemIds: data.linkedItemIds,
|
linkedItemIds: data.linkedItemIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: action };
|
return { success: true, data: action };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,13 +192,13 @@ export async function updateAction(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const action = await sessionsService.updateAction(actionId, data);
|
const action = await sessionsService.updateAction(actionId, data);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_UPDATED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_UPDATED', {
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true, data: action };
|
return { success: true, data: action };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -215,12 +215,12 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await sessionsService.deleteAction(actionId);
|
await sessionsService.deleteAction(actionId);
|
||||||
|
|
||||||
// Emit event for real-time sync
|
// Emit event for real-time sync
|
||||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_DELETED', {
|
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_DELETED', {
|
||||||
actionId,
|
actionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/sessions/${sessionId}`);
|
revalidatePath(`/sessions/${sessionId}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -228,4 +228,3 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
|||||||
return { success: false, error: 'Erreur lors de la suppression' };
|
return { success: false, error: 'Erreur lors de la suppression' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,4 +109,3 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,4 +170,3 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { handlers } from '@/lib/auth';
|
import { handlers } from '@/lib/auth';
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
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 });
|
return NextResponse.json({ error: 'Erreur lors de la création du compte' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import {
|
import { canAccessMotivatorSession, getMotivatorSessionEvents } from '@/services/moving-motivators';
|
||||||
canAccessMotivatorSession,
|
|
||||||
getMotivatorSessionEvents,
|
|
||||||
} from '@/services/moving-motivators';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
// Store active connections per session
|
// Store active connections per session
|
||||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id: sessionId } = await params;
|
const { id: sessionId } = await params;
|
||||||
const session = await auth();
|
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
|
// Store active connections per session
|
||||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const { id: sessionId } = await params;
|
const { id: sessionId } = await params;
|
||||||
const session = await auth();
|
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;
|
const { title, collaborator } = body;
|
||||||
|
|
||||||
if (!title || !collaborator) {
|
if (!title || !collaborator) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Titre et collaborateur requis' }, { status: 400 });
|
||||||
{ error: 'Titre et collaborateur requis' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSession = await prisma.session.create({
|
const newSession = await prisma.session.create({
|
||||||
@@ -68,4 +65,3 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,4 +107,3 @@ export function EditableMotivatorTitle({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
||||||
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||||
import { Badge } from '@/components/ui';
|
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||||
import { EditableMotivatorTitle } from './EditableTitle';
|
import { EditableMotivatorTitle } from './EditableTitle';
|
||||||
|
|
||||||
interface MotivatorSessionPageProps {
|
interface MotivatorSessionPageProps {
|
||||||
@@ -29,7 +29,7 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
<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
|
Moving Motivators
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
@@ -48,9 +48,9 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
initialTitle={session.title}
|
initialTitle={session.title}
|
||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-lg text-muted">
|
<div className="mt-2">
|
||||||
👤 {session.participant}
|
<CollaboratorDisplay collaborator={session.resolvedParticipant} size="lg" showEmail />
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="primary">
|
<Badge variant="primary">
|
||||||
@@ -76,13 +76,8 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
|||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
canEdit={session.canEdit}
|
canEdit={session.canEdit}
|
||||||
>
|
>
|
||||||
<MotivatorBoard
|
<MotivatorBoard sessionId={session.id} cards={session.cards} canEdit={session.canEdit} />
|
||||||
sessionId={session.id}
|
|
||||||
cards={session.cards}
|
|
||||||
canEdit={session.canEdit}
|
|
||||||
/>
|
|
||||||
</MotivatorLiveWrapper>
|
</MotivatorLiveWrapper>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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';
|
import { createMotivatorSession } from '@/actions/moving-motivators';
|
||||||
|
|
||||||
export default function NewMotivatorSessionPage() {
|
export default function NewMotivatorSessionPage() {
|
||||||
@@ -99,4 +107,3 @@ export default function NewMotivatorSessionPage() {
|
|||||||
</main>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
105
src/app/page.tsx
105
src/app/page.tsx
@@ -10,8 +10,8 @@ export default function Home() {
|
|||||||
Vos ateliers, <span className="text-primary">réinventés</span>
|
Vos ateliers, <span className="text-primary">réinventés</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted">
|
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted">
|
||||||
Des outils interactifs et collaboratifs pour accompagner vos équipes.
|
Des outils interactifs et collaboratifs pour accompagner vos équipes. Analysez,
|
||||||
Analysez, comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
|
comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default function Home() {
|
|||||||
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
|
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
|
||||||
{/* SWOT Workshop Card */}
|
{/* SWOT Workshop Card */}
|
||||||
<WorkshopCard
|
<WorkshopCard
|
||||||
href="/sessions"
|
href="/sessions?tab=swot"
|
||||||
icon="📊"
|
icon="📊"
|
||||||
title="Analyse SWOT"
|
title="Analyse SWOT"
|
||||||
tagline="Analysez. Planifiez. Progressez."
|
tagline="Analysez. Planifiez. Progressez."
|
||||||
@@ -39,14 +39,14 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Moving Motivators Workshop Card */}
|
{/* Moving Motivators Workshop Card */}
|
||||||
<WorkshopCard
|
<WorkshopCard
|
||||||
href="/motivators"
|
href="/sessions?tab=motivators"
|
||||||
icon="🎯"
|
icon="🎯"
|
||||||
title="Moving Motivators"
|
title="Moving Motivators"
|
||||||
tagline="Révélez ce qui motive vraiment"
|
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."
|
description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
|
||||||
features={[
|
features={[
|
||||||
'10 cartes de motivation à classer',
|
'10 cartes de motivation à classer',
|
||||||
'Évaluation de l\'influence positive/négative',
|
"Évaluation de l'influence positive/négative",
|
||||||
'Récapitulatif personnalisé des motivations',
|
'Récapitulatif personnalisé des motivations',
|
||||||
]}
|
]}
|
||||||
accentColor="#8b5cf6"
|
accentColor="#8b5cf6"
|
||||||
@@ -73,8 +73,9 @@ export default function Home() {
|
|||||||
Pourquoi faire un SWOT ?
|
Pourquoi faire un SWOT ?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted mb-4">
|
<p className="text-muted mb-4">
|
||||||
L'analyse SWOT est un outil puissant pour prendre du recul sur une situation professionnelle.
|
L'analyse SWOT est un outil puissant pour prendre du recul sur une situation
|
||||||
Elle permet de dresser un portrait objectif et structuré, base indispensable pour définir des actions pertinentes.
|
professionnelle. Elle permet de dresser un portrait objectif et structuré, base
|
||||||
|
indispensable pour définir des actions pertinentes.
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-sm text-muted">
|
<ul className="space-y-2 text-sm text-muted">
|
||||||
<li className="flex items-start gap-2">
|
<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="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg bg-green-500/10 p-3 border border-green-500/20">
|
<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="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>
|
||||||
<div className="rounded-lg bg-orange-500/10 p-3 border border-orange-500/20">
|
<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="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>
|
||||||
<div className="rounded-lg bg-blue-500/10 p-3 border border-blue-500/20">
|
<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="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>
|
||||||
<div className="rounded-lg bg-red-500/10 p-3 border border-red-500/20">
|
<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>
|
<p className="font-semibold text-red-600 text-sm mb-1">🛡️ Menaces</p>
|
||||||
@@ -129,10 +136,26 @@ export default function Home() {
|
|||||||
Comment ça marche ?
|
Comment ça marche ?
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid md:grid-cols-4 gap-4">
|
<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
|
||||||
<StepCard number={2} title="Prioriser" description="Classez les éléments par importance et impact pour concentrer les efforts" />
|
number={1}
|
||||||
<StepCard number={3} title="Croiser" description="Reliez les forces aux opportunités, anticipez les menaces avec les atouts" />
|
title="Remplir la matrice"
|
||||||
<StepCard number={4} title="Agir" description="Définissez des actions concrètes avec des échéances et des responsables" />
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,8 +179,9 @@ export default function Home() {
|
|||||||
Pourquoi explorer ses motivations ?
|
Pourquoi explorer ses motivations ?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted mb-4">
|
<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.
|
Créé par Jurgen Appelo (Management 3.0), cet exercice révèle les motivations
|
||||||
Comprendre ce qui nous motive permet de mieux s'épanouir et d'aligner nos missions avec nos aspirations profondes.
|
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>
|
</p>
|
||||||
<ul className="space-y-2 text-sm text-muted">
|
<ul className="space-y-2 text-sm text-muted">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
@@ -206,20 +230,20 @@ export default function Home() {
|
|||||||
Comment ça marche ?
|
Comment ça marche ?
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
<StepCard
|
<StepCard
|
||||||
number={1}
|
number={1}
|
||||||
title="Classer par importance"
|
title="Classer par importance"
|
||||||
description="Ordonnez les 10 cartes de la moins importante (gauche) à la plus importante (droite) pour vous"
|
description="Ordonnez les 10 cartes de la moins importante (gauche) à la plus importante (droite) pour vous"
|
||||||
/>
|
/>
|
||||||
<StepCard
|
<StepCard
|
||||||
number={2}
|
number={2}
|
||||||
title="Évaluer l'influence"
|
title="Évaluer l'influence"
|
||||||
description="Pour chaque motivation, indiquez si votre situation actuelle l'impacte positivement ou négativement"
|
description="Pour chaque motivation, indiquez si votre situation actuelle l'impacte positivement ou négativement"
|
||||||
/>
|
/>
|
||||||
<StepCard
|
<StepCard
|
||||||
number={3}
|
number={3}
|
||||||
title="Analyser et discuter"
|
title="Analyser et discuter"
|
||||||
description="Le récapitulatif révèle les motivations clés et les points de vigilance pour un échange constructif"
|
description="Le récapitulatif révèle les motivations clés et les points de vigilance pour un échange constructif"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,9 +303,7 @@ function WorkshopCard({
|
|||||||
newHref: string;
|
newHref: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
||||||
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 */}
|
{/* Accent gradient */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 top-0 h-1 opacity-80"
|
className="absolute inset-x-0 top-0 h-1 opacity-80"
|
||||||
@@ -313,7 +335,12 @@ function WorkshopCard({
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
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>
|
</svg>
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
@@ -380,22 +407,16 @@ function StepCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MotivatorPill({
|
function MotivatorPill({ icon, name, color }: { icon: string; name: string; color: string }) {
|
||||||
icon,
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
icon: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full"
|
className="flex items-center gap-2 px-3 py-1.5 rounded-full"
|
||||||
style={{ backgroundColor: `${color}15`, border: `1px solid ${color}30` }}
|
style={{ backgroundColor: `${color}15`, border: `1px solid ${color}30` }}
|
||||||
>
|
>
|
||||||
<span>{icon}</span>
|
<span>{icon}</span>
|
||||||
<span className="font-medium" style={{ color }}>{name}</span>
|
<span className="font-medium" style={{ color }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ export function PasswordForm() {
|
|||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
currentPassword.length > 0 &&
|
currentPassword.length > 0 && newPassword.length >= 6 && newPassword === confirmPassword;
|
||||||
newPassword.length >= 6 &&
|
|
||||||
newPassword === confirmPassword;
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -58,10 +56,7 @@ export function PasswordForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="newPassword" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||||
htmlFor="newPassword"
|
|
||||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
Nouveau mot de passe
|
Nouveau mot de passe
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -90,17 +85,13 @@ export function PasswordForm() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{confirmPassword && newPassword !== confirmPassword && (
|
{confirmPassword && newPassword !== confirmPassword && (
|
||||||
<p className="mt-1 text-xs text-destructive">
|
<p className="mt-1 text-xs text-destructive">Les mots de passe ne correspondent pas</p>
|
||||||
Les mots de passe ne correspondent pas
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<p
|
<p
|
||||||
className={`text-sm ${
|
className={`text-sm ${message.type === 'success' ? 'text-success' : 'text-destructive'}`}
|
||||||
message.type === 'success' ? 'text-success' : 'text-destructive'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
</p>
|
</p>
|
||||||
@@ -112,4 +103,3 @@ export function PasswordForm() {
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<p
|
<p
|
||||||
className={`text-sm ${
|
className={`text-sm ${message.type === 'success' ? 'text-success' : 'text-destructive'}`}
|
||||||
message.type === 'success' ? 'text-success' : 'text-destructive'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
</p>
|
</p>
|
||||||
@@ -81,4 +79,3 @@ export function ProfileForm({ initialData }: ProfileFormProps) {
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import { getUserById } from '@/services/auth';
|
import { getUserById } from '@/services/auth';
|
||||||
import { ProfileForm } from './ProfileForm';
|
import { ProfileForm } from './ProfileForm';
|
||||||
import { PasswordForm } from './PasswordForm';
|
import { PasswordForm } from './PasswordForm';
|
||||||
|
import { getGravatarUrl } from '@/lib/gravatar';
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -19,17 +20,34 @@ export default async function ProfilePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8 flex items-center gap-6">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Mon Profil</h1>
|
<img
|
||||||
<p className="mt-1 text-muted">Gérez vos informations personnelles</p>
|
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>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Profile Info */}
|
{/* Profile Info */}
|
||||||
<section className="rounded-xl border border-border bg-card p-6">
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
<h2 className="mb-6 text-xl font-semibold text-foreground">
|
<div className="mb-6 flex items-start justify-between">
|
||||||
Informations personnelles
|
<h2 className="text-xl font-semibold text-foreground">Informations personnelles</h2>
|
||||||
</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
|
<ProfileForm
|
||||||
initialData={{
|
initialData={{
|
||||||
name: user.name || '',
|
name: user.name || '',
|
||||||
@@ -40,17 +58,13 @@ export default async function ProfilePage() {
|
|||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<section className="rounded-xl border border-border bg-card p-6">
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
<h2 className="mb-6 text-xl font-semibold text-foreground">
|
<h2 className="mb-6 text-xl font-semibold text-foreground">Changer le mot de passe</h2>
|
||||||
Changer le mot de passe
|
|
||||||
</h2>
|
|
||||||
<PasswordForm />
|
<PasswordForm />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Account Info */}
|
{/* Account Info */}
|
||||||
<section className="rounded-xl border border-border bg-card p-6">
|
<section className="rounded-xl border border-border bg-card p-6">
|
||||||
<h2 className="mb-4 text-xl font-semibold text-foreground">
|
<h2 className="mb-4 text-xl font-semibold text-foreground">Informations du compte</h2>
|
||||||
Informations du compte
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted">ID du compte</span>
|
<span className="text-muted">ID du compte</span>
|
||||||
@@ -72,4 +86,3 @@ export default async function ProfilePage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
import Link from 'next/link';
|
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 {
|
interface ShareUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,10 +31,20 @@ interface Share {
|
|||||||
user: ShareUser;
|
user: ShareUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedCollaborator {
|
||||||
|
raw: string;
|
||||||
|
matchedUser: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SwotSession {
|
interface SwotSession {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
collaborator: string;
|
collaborator: string;
|
||||||
|
resolvedCollaborator: ResolvedCollaborator;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||||
@@ -35,6 +58,7 @@ interface MotivatorSession {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
participant: string;
|
participant: string;
|
||||||
|
resolvedParticipant: ResolvedCollaborator;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||||
@@ -51,17 +75,90 @@ interface WorkshopTabsProps {
|
|||||||
motivatorSessions: MotivatorSession[];
|
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) {
|
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
|
// Combine and sort all sessions
|
||||||
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
|
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
(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 =
|
const filteredSessions =
|
||||||
activeTab === 'all'
|
activeTab === 'all' || activeTab === 'byPerson'
|
||||||
? allSessions
|
? allSessions
|
||||||
: activeTab === 'swot'
|
: activeTab === 'swot'
|
||||||
? swotSessions
|
? swotSessions
|
||||||
@@ -71,10 +168,16 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
|
||||||
const sharedSessions = 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tabs */}
|
{/* 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
|
<TabButton
|
||||||
active={activeTab === 'all'}
|
active={activeTab === 'all'}
|
||||||
onClick={() => setActiveTab('all')}
|
onClick={() => setActiveTab('all')}
|
||||||
@@ -82,6 +185,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
label="Tous"
|
label="Tous"
|
||||||
count={allSessions.length}
|
count={allSessions.length}
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'byPerson'}
|
||||||
|
onClick={() => setActiveTab('byPerson')}
|
||||||
|
icon="👥"
|
||||||
|
label="Par personne"
|
||||||
|
count={sessionsByPerson.size}
|
||||||
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'swot'}
|
active={activeTab === 'swot'}
|
||||||
onClick={() => setActiveTab('swot')}
|
onClick={() => setActiveTab('swot')}
|
||||||
@@ -99,10 +209,34 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sessions */}
|
{/* Sessions */}
|
||||||
{filteredSessions.length === 0 ? (
|
{activeTab === 'byPerson' ? (
|
||||||
<div className="text-center py-12 text-muted">
|
// By Person View
|
||||||
Aucun atelier de ce type pour le moment
|
sortedPersons.length === 0 ? (
|
||||||
</div>
|
<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">
|
<div className="space-y-8">
|
||||||
{/* My Sessions */}
|
{/* My Sessions */}
|
||||||
@@ -156,9 +290,10 @@ function TabButton({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
|
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
|
||||||
${active
|
${
|
||||||
? 'bg-primary text-primary-foreground'
|
active
|
||||||
: 'text-muted hover:bg-card-hover hover:text-foreground'
|
? '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 }) {
|
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 isSwot = session.workshopType === 'swot';
|
||||||
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
|
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
|
||||||
const icon = isSwot ? '📊' : '🎯';
|
const icon = isSwot ? '📊' : '🎯';
|
||||||
@@ -180,93 +327,261 @@ function SessionCard({ session }: { session: AnySession }) {
|
|||||||
: (session as MotivatorSession).participant;
|
: (session as MotivatorSession).participant;
|
||||||
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
|
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 (
|
return (
|
||||||
<Link href={href}>
|
<>
|
||||||
<Card hover className="h-full p-4 relative overflow-hidden">
|
<div className="relative group">
|
||||||
{/* Accent bar */}
|
<Link href={href}>
|
||||||
<div
|
<Card hover className="h-full p-4 relative overflow-hidden">
|
||||||
className="absolute top-0 left-0 right-0 h-1"
|
{/* Accent bar */}
|
||||||
style={{ backgroundColor: accentColor }}
|
<div
|
||||||
/>
|
className="absolute top-0 left-0 right-0 h-1"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Header: Icon + Title + Role badge */}
|
{/* Header: Icon + Title + Role badge */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xl">{icon}</span>
|
<span className="text-xl">{icon}</span>
|
||||||
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">
|
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">{session.title}</h3>
|
||||||
{session.title}
|
{!session.isOwner && (
|
||||||
</h3>
|
<span
|
||||||
{!session.isOwner && (
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
<span
|
style={{
|
||||||
className="text-xs px-1.5 py-0.5 rounded"
|
backgroundColor:
|
||||||
style={{
|
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',
|
||||||
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
|
}}
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.role === 'EDITOR' ? '✏️' : '👁️'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participant + Owner info */}
|
|
||||||
<p className="text-sm text-muted mb-3 line-clamp-1">
|
|
||||||
👤 {participant}
|
|
||||||
{!session.isOwner && (
|
|
||||||
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Footer: Stats + Avatars + Date */}
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-2 text-muted">
|
|
||||||
{isSwot ? (
|
|
||||||
<>
|
|
||||||
<span>{(session as SwotSession)._count.items} items</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{(session as SwotSession)._count.actions} actions</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<span className="text-muted">
|
|
||||||
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shared with */}
|
|
||||||
{session.isOwner && session.shares.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
|
||||||
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{session.shares.slice(0, 3).map((share) => (
|
|
||||||
<div
|
|
||||||
key={share.id}
|
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
|
|
||||||
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
|
||||||
>
|
>
|
||||||
<span className="font-medium">
|
{session.role === 'EDITOR' ? '✏️' : '👁️'}
|
||||||
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
|
|
||||||
</span>
|
|
||||||
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{session.shares.length > 3 && (
|
|
||||||
<span className="text-[10px] text-muted">
|
|
||||||
+{session.shares.length - 3}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Participant + Owner info */}
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<CollaboratorDisplay collaborator={getResolvedCollaborator(session)} size="sm" />
|
||||||
|
{!session.isOwner && (
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
· par {session.user.name || session.user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Stats + Avatars + Date */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-2 text-muted">
|
||||||
|
{isSwot ? (
|
||||||
|
<>
|
||||||
|
<span>{(session as SwotSession)._count.items} items</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{(session as SwotSession)._count.actions} actions</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{(session as MotivatorSession)._count.cards}/10</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<span className="text-muted">
|
||||||
|
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shared with */}
|
||||||
|
{session.isOwner && session.shares.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
|
||||||
|
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{session.shares.slice(0, 3).map((share) => (
|
||||||
|
<div
|
||||||
|
key={share.id}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
|
||||||
|
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
|
||||||
|
</span>
|
||||||
|
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{session.shares.length > 3 && (
|
||||||
|
<span className="text-[10px] text-muted">+{session.shares.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Action buttons - only for owner */}
|
||||||
|
{session.isOwner && (
|
||||||
|
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditModal();
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
|
||||||
|
{/* 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 { SwotBoard } from '@/components/swot/SwotBoard';
|
||||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||||
import { EditableTitle } from '@/components/session';
|
import { EditableTitle } from '@/components/session';
|
||||||
import { Badge } from '@/components/ui';
|
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||||
|
|
||||||
interface SessionPageProps {
|
interface SessionPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -30,8 +30,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
<div className="flex items-center gap-2 text-sm text-muted mb-2">
|
||||||
<Link href="/sessions" className="hover:text-foreground">
|
<Link href="/sessions?tab=swot" className="hover:text-foreground">
|
||||||
Mes Sessions
|
SWOT
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{session.title}</span>
|
<span className="text-foreground">{session.title}</span>
|
||||||
@@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
initialTitle={session.title}
|
initialTitle={session.title}
|
||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-lg text-muted">
|
<div className="mt-2">
|
||||||
👤 {session.collaborator}
|
<CollaboratorDisplay
|
||||||
</p>
|
collaborator={session.resolvedCollaborator}
|
||||||
|
size="lg"
|
||||||
|
showEmail
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="primary">{session.items.length} items</Badge>
|
<Badge variant="primary">{session.items.length} items</Badge>
|
||||||
@@ -76,13 +80,8 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
|||||||
isOwner={session.isOwner}
|
isOwner={session.isOwner}
|
||||||
canEdit={session.canEdit}
|
canEdit={session.canEdit}
|
||||||
>
|
>
|
||||||
<SwotBoard
|
<SwotBoard sessionId={session.id} items={session.items} actions={session.actions} />
|
||||||
sessionId={session.id}
|
|
||||||
items={session.items}
|
|
||||||
actions={session.actions}
|
|
||||||
/>
|
|
||||||
</SessionLiveWrapper>
|
</SessionLiveWrapper>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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() {
|
export default function NewSessionPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -100,4 +108,3 @@ export default function NewSessionPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { getSessionsByUserId } from '@/services/sessions';
|
import { getSessionsByUserId } from '@/services/sessions';
|
||||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||||
import { Card, CardContent, Badge, Button } from '@/components/ui';
|
import { Card, Button } from '@/components/ui';
|
||||||
import { WorkshopTabs } from './WorkshopTabs';
|
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() {
|
export default async function SessionsPage() {
|
||||||
const session = await auth();
|
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 className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
|
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
|
||||||
<p className="mt-1 text-muted">
|
<p className="mt-1 text-muted">Tous vos ateliers en un seul endroit</p>
|
||||||
Tous vos ateliers en un seul endroit
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href="/sessions/new">
|
<Link href="/sessions/new">
|
||||||
@@ -70,7 +88,8 @@ export default async function SessionsPage() {
|
|||||||
Commencez votre premier atelier
|
Commencez votre premier atelier
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted mb-6 max-w-md mx-auto">
|
<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>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Link href="/sessions/new">
|
<Link href="/sessions/new">
|
||||||
@@ -88,10 +107,9 @@ export default async function SessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<WorkshopTabs
|
<Suspense fallback={<WorkshopTabsSkeleton />}>
|
||||||
swotSessions={allSwotSessions}
|
<WorkshopTabs swotSessions={allSwotSessions} motivatorSessions={allMotivatorSessions} />
|
||||||
motivatorSessions={allMotivatorSessions}
|
</Suspense>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors ${
|
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors ${
|
||||||
isConnected
|
isConnected ? 'bg-success/10 text-success' : 'bg-yellow/10 text-yellow'
|
||||||
? 'bg-success/10 text-success'
|
|
||||||
: 'bg-yellow/10 text-yellow'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-2 w-2 rounded-full ${
|
className={`h-2 w-2 rounded-full ${isConnected ? 'bg-success animate-pulse' : 'bg-yellow'}`}
|
||||||
isConnected ? 'bg-success animate-pulse' : 'bg-yellow'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span>{isConnected ? 'Live' : 'Connexion...'}</span>
|
<span>{isConnected ? 'Live' : 'Connexion...'}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
|
|||||||
import { LiveIndicator } from './LiveIndicator';
|
import { LiveIndicator } from './LiveIndicator';
|
||||||
import { ShareModal } from './ShareModal';
|
import { ShareModal } from './ShareModal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import type { ShareRole } from '@prisma/client';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
interface ShareUser {
|
interface ShareUser {
|
||||||
@@ -63,7 +64,7 @@ export function SessionLiveWrapper({
|
|||||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<LiveIndicator isConnected={isConnected} error={error} />
|
<LiveIndicator isConnected={isConnected} error={error} />
|
||||||
|
|
||||||
{lastEventUser && (
|
{lastEventUser && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
||||||
<span>✏️</span>
|
<span>✏️</span>
|
||||||
@@ -84,13 +85,13 @@ export function SessionLiveWrapper({
|
|||||||
{shares.length > 0 && (
|
{shares.length > 0 && (
|
||||||
<div className="flex -space-x-2">
|
<div className="flex -space-x-2">
|
||||||
{shares.slice(0, 3).map((share) => (
|
{shares.slice(0, 3).map((share) => (
|
||||||
<div
|
<Avatar
|
||||||
key={share.id}
|
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"
|
email={share.user.email}
|
||||||
title={share.user.name || share.user.email}
|
name={share.user.name}
|
||||||
>
|
size={32}
|
||||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
className="border-2 border-card"
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{shares.length > 3 && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShareModalOpen(true)}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@@ -119,9 +116,7 @@ export function SessionLiveWrapper({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
|
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share Modal */}
|
{/* Share Modal */}
|
||||||
<ShareModal
|
<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 { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { shareSessionAction, removeShareAction } from '@/actions/share';
|
import { shareSessionAction, removeShareAction } from '@/actions/share';
|
||||||
import type { ShareRole } from '@prisma/client';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
@@ -104,14 +105,10 @@ export function ShareModal({
|
|||||||
|
|
||||||
{/* Current shares */}
|
{/* Current shares */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
||||||
Collaborateurs ({shares.length})
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{shares.length === 0 ? (
|
{shares.length === 0 ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
||||||
Aucun collaborateur pour le moment
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
@@ -120,19 +117,15 @@ export function ShareModal({
|
|||||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
|
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 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">
|
<Avatar email={share.user.email} name={share.user.name} size={32} />
|
||||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{share.user.name || share.user.email}
|
{share.user.name || share.user.email}
|
||||||
</p>
|
</p>
|
||||||
{share.user.name && (
|
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
||||||
<p className="text-xs text-muted">{share.user.email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
||||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||||
@@ -177,5 +170,3 @@ export function ShareModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { LiveIndicator } from './LiveIndicator';
|
export { LiveIndicator } from './LiveIndicator';
|
||||||
export { ShareModal } from './ShareModal';
|
export { ShareModal } from './ShareModal';
|
||||||
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { useSession, signOut } from 'next-auth/react';
|
import { useSession, signOut } from 'next-auth/react';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Avatar } from '@/components/ui';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -109,8 +110,9 @@ export function Header() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
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">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{session.user.name || session.user.email?.split('@')[0]}
|
{session.user.name || session.user.email?.split('@')[0]}
|
||||||
</span>
|
</span>
|
||||||
@@ -131,10 +133,7 @@ export function Header() {
|
|||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
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="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">
|
<div className="border-b border-border px-4 py-2">
|
||||||
<p className="text-xs text-muted">Connecté en tant que</p>
|
<p className="text-xs text-muted">Connecté en tant que</p>
|
||||||
@@ -149,6 +148,13 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
👤 Mon Profil
|
👤 Mon Profil
|
||||||
</Link>
|
</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
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
|
||||||
|
|||||||
@@ -140,4 +140,3 @@ function InfluenceSlider({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,15 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
|||||||
|
|
||||||
// Persist to server
|
// Persist to server
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
|
await reorderMotivatorCards(
|
||||||
|
sessionId,
|
||||||
|
newCards.map((c) => c.id)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInfluenceChange(cardId: string, influence: number) {
|
function handleInfluenceChange(cardId: string, influence: number) {
|
||||||
setCards((prev) =>
|
setCards((prev) => prev.map((c) => (c.id === cardId ? { ...c, influence } : c)));
|
||||||
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
|
|
||||||
);
|
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await updateCardInfluence(cardId, sessionId, influence);
|
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">
|
<div className="flex gap-2 min-w-max px-2">
|
||||||
{sortedCards.map((card) => (
|
{sortedCards.map((card) => (
|
||||||
<MotivatorCard
|
<MotivatorCard key={card.id} card={card} disabled={!canEdit} />
|
||||||
key={card.id}
|
|
||||||
card={card}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -182,7 +179,8 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
|||||||
Évaluez l'influence de chaque motivation
|
Évaluez l'influence de chaque motivation
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -216,9 +214,7 @@ export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: Moti
|
|||||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||||
Récapitulatif de vos Moving Motivators
|
Récapitulatif de vos Moving Motivators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted">
|
<p className="text-muted">Voici l'analyse de vos motivations et leur impact</p>
|
||||||
Voici l'analyse de vos motivations et leur impact
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MotivatorSummary cards={sortedCards} />
|
<MotivatorSummary cards={sortedCards} />
|
||||||
@@ -273,4 +269,3 @@ function StepIndicator({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,7 @@ export function MotivatorCard({
|
|||||||
}: MotivatorCardProps) {
|
}: MotivatorCardProps) {
|
||||||
const config = MOTIVATOR_BY_TYPE[card.type];
|
const config = MOTIVATOR_BY_TYPE[card.type];
|
||||||
|
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: card.id,
|
id: card.id,
|
||||||
disabled,
|
disabled,
|
||||||
});
|
});
|
||||||
@@ -62,10 +55,7 @@ export function MotivatorCard({
|
|||||||
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
|
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div
|
<div className="font-semibold text-sm text-center px-2" style={{ color: config.color }}>
|
||||||
className="font-semibold text-sm text-center px-2"
|
|
||||||
style={{ color: config.color }}
|
|
||||||
>
|
|
||||||
{config.name}
|
{config.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,9 +119,7 @@ export function MotivatorCardStatic({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
|
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>{config.icon}</div>
|
||||||
{config.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div
|
<div
|
||||||
@@ -169,4 +157,3 @@ export function MotivatorCardStatic({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorL
|
|||||||
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
||||||
import { MotivatorShareModal } from './MotivatorShareModal';
|
import { MotivatorShareModal } from './MotivatorShareModal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import type { ShareRole } from '@prisma/client';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
interface ShareUser {
|
interface ShareUser {
|
||||||
@@ -63,7 +64,7 @@ export function MotivatorLiveWrapper({
|
|||||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<LiveIndicator isConnected={isConnected} error={error} />
|
<LiveIndicator isConnected={isConnected} error={error} />
|
||||||
|
|
||||||
{lastEventUser && (
|
{lastEventUser && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
|
||||||
<span>✏️</span>
|
<span>✏️</span>
|
||||||
@@ -84,13 +85,13 @@ export function MotivatorLiveWrapper({
|
|||||||
{shares.length > 0 && (
|
{shares.length > 0 && (
|
||||||
<div className="flex -space-x-2">
|
<div className="flex -space-x-2">
|
||||||
{shares.slice(0, 3).map((share) => (
|
{shares.slice(0, 3).map((share) => (
|
||||||
<div
|
<Avatar
|
||||||
key={share.id}
|
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"
|
email={share.user.email}
|
||||||
title={share.user.name || share.user.email}
|
name={share.user.name}
|
||||||
>
|
size={32}
|
||||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
className="border-2 border-card"
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{shares.length > 3 && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShareModalOpen(true)}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@@ -119,9 +116,7 @@ export function MotivatorLiveWrapper({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
|
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share Modal */}
|
{/* Share Modal */}
|
||||||
<MotivatorShareModal
|
<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 { Input } from '@/components/ui/Input';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
|
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
|
||||||
import type { ShareRole } from '@prisma/client';
|
import type { ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
@@ -104,14 +105,10 @@ export function MotivatorShareModal({
|
|||||||
|
|
||||||
{/* Current shares */}
|
{/* Current shares */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">Collaborateurs ({shares.length})</p>
|
||||||
Collaborateurs ({shares.length})
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{shares.length === 0 ? (
|
{shares.length === 0 ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">Aucun collaborateur pour le moment</p>
|
||||||
Aucun collaborateur pour le moment
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
@@ -120,19 +117,15 @@ export function MotivatorShareModal({
|
|||||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
|
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 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">
|
<Avatar email={share.user.email} name={share.user.name} size={32} />
|
||||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{share.user.name || share.user.email}
|
{share.user.name || share.user.email}
|
||||||
</p>
|
</p>
|
||||||
{share.user.name && (
|
{share.user.name && <p className="text-xs text-muted">{share.user.email}</p>}
|
||||||
<p className="text-xs text-muted">{share.user.email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
||||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||||
@@ -177,4 +170,3 @@ export function MotivatorShareModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ export function MotivatorSummary({ cards }: MotivatorSummaryProps) {
|
|||||||
const bottom3 = sortedByImportance.slice(0, 3);
|
const bottom3 = sortedByImportance.slice(0, 3);
|
||||||
|
|
||||||
// Cards with positive influence
|
// 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
|
// 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -100,4 +104,3 @@ function SummarySection({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export { MotivatorSummary } from './MotivatorSummary';
|
|||||||
export { InfluenceZone } from './InfluenceZone';
|
export { InfluenceZone } from './InfluenceZone';
|
||||||
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
||||||
export { MotivatorShareModal } from './MotivatorShareModal';
|
export { MotivatorShareModal } from './MotivatorShareModal';
|
||||||
|
|
||||||
|
|||||||
@@ -103,4 +103,3 @@ export function EditableTitle({ sessionId, initialTitle, isOwner }: EditableTitl
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { EditableTitle } from './EditableTitle';
|
export { EditableTitle } from './EditableTitle';
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ interface ActionPanelProps {
|
|||||||
onActionLeave: () => void;
|
onActionLeave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryBadgeVariant: Record<SwotCategory, 'strength' | 'weakness' | 'opportunity' | 'threat'> = {
|
const categoryBadgeVariant: Record<
|
||||||
|
SwotCategory,
|
||||||
|
'strength' | 'weakness' | 'opportunity' | 'threat'
|
||||||
|
> = {
|
||||||
STRENGTH: 'strength',
|
STRENGTH: 'strength',
|
||||||
WEAKNESS: 'weakness',
|
WEAKNESS: 'weakness',
|
||||||
OPPORTUNITY: 'opportunity',
|
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"
|
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-card-hover hover:text-foreground group-hover:opacity-100"
|
||||||
aria-label="Modifier"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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"
|
className="rounded p-1 text-muted opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||||
aria-label="Supprimer"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -271,7 +284,7 @@ export function ActionPanel({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
title={editingAction ? 'Modifier l\'action' : 'Nouvelle action croisée'}
|
title={editingAction ? "Modifier l'action" : 'Nouvelle action croisée'}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -339,7 +352,7 @@ export function ActionPanel({
|
|||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{editingAction ? 'Enregistrer' : 'Créer l\'action'}
|
{editingAction ? 'Enregistrer' : "Créer l'action"}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -347,4 +360,3 @@ export function ActionPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,17 @@ interface HelpContent {
|
|||||||
|
|
||||||
const HELP_CONTENT: Record<SwotCategory, HelpContent> = {
|
const HELP_CONTENT: Record<SwotCategory, HelpContent> = {
|
||||||
STRENGTH: {
|
STRENGTH: {
|
||||||
description:
|
description: 'Les atouts internes et qualités qui distinguent positivement.',
|
||||||
'Les atouts internes et qualités qui distinguent positivement.',
|
|
||||||
examples: [
|
examples: [
|
||||||
'Expertise technique solide',
|
'Expertise technique solide',
|
||||||
'Excellentes capacités de communication',
|
'Excellentes capacités de communication',
|
||||||
'Leadership naturel',
|
'Leadership naturel',
|
||||||
'Rigueur et organisation',
|
'Rigueur et organisation',
|
||||||
],
|
],
|
||||||
questions: [
|
questions: ["Qu'est-ce qui le/la distingue ?", 'Quels retours positifs reçoit-il/elle ?'],
|
||||||
'Qu\'est-ce qui le/la distingue ?',
|
|
||||||
'Quels retours positifs reçoit-il/elle ?',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
WEAKNESS: {
|
WEAKNESS: {
|
||||||
description:
|
description: "Les axes d'amélioration et points à travailler.",
|
||||||
'Les axes d\'amélioration et points à travailler.',
|
|
||||||
examples: [
|
examples: [
|
||||||
'Difficulté à déléguer',
|
'Difficulté à déléguer',
|
||||||
'Gestion du stress à améliorer',
|
'Gestion du stress à améliorer',
|
||||||
@@ -39,22 +34,17 @@ const HELP_CONTENT: Record<SwotCategory, HelpContent> = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
OPPORTUNITY: {
|
OPPORTUNITY: {
|
||||||
description:
|
description: 'Les facteurs externes favorables à saisir.',
|
||||||
'Les facteurs externes favorables à saisir.',
|
|
||||||
examples: [
|
examples: [
|
||||||
'Nouveau projet stratégique',
|
'Nouveau projet stratégique',
|
||||||
'Formation disponible',
|
'Formation disponible',
|
||||||
'Poste ouvert en interne',
|
'Poste ouvert en interne',
|
||||||
'Mentor potentiel identifié',
|
'Mentor potentiel identifié',
|
||||||
],
|
],
|
||||||
questions: [
|
questions: ["Quelles évolutions pourraient l'aider ?", 'Quelles ressources sont disponibles ?'],
|
||||||
'Quelles évolutions pourraient l\'aider ?',
|
|
||||||
'Quelles ressources sont disponibles ?',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
THREAT: {
|
THREAT: {
|
||||||
description:
|
description: 'Les risques externes à anticiper.',
|
||||||
'Les risques externes à anticiper.',
|
|
||||||
examples: [
|
examples: [
|
||||||
'Réorganisation menaçant le poste',
|
'Réorganisation menaçant le poste',
|
||||||
'Compétences devenant obsolètes',
|
'Compétences devenant obsolètes',
|
||||||
@@ -82,9 +72,10 @@ export function QuadrantHelp({ category }: QuadrantHelpProps) {
|
|||||||
className={`
|
className={`
|
||||||
flex h-5 w-5 items-center justify-center rounded-full
|
flex h-5 w-5 items-center justify-center rounded-full
|
||||||
text-xs font-medium transition-all
|
text-xs font-medium transition-all
|
||||||
${isOpen
|
${
|
||||||
? 'bg-foreground/20 text-foreground rotate-45'
|
isOpen
|
||||||
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
? 'bg-foreground/20 text-foreground rotate-45'
|
||||||
|
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
aria-label="Aide"
|
aria-label="Aide"
|
||||||
@@ -113,9 +104,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
|
|||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="rounded-lg bg-white/40 dark:bg-black/20 p-3 mb-3">
|
<div className="rounded-lg bg-white/40 dark:bg-black/20 p-3 mb-3">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-xs text-foreground/80 leading-relaxed">
|
<p className="text-xs text-foreground/80 leading-relaxed">{content.description}</p>
|
||||||
{content.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-3 flex gap-4">
|
<div className="mt-3 flex gap-4">
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
@@ -125,10 +114,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
|
|||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{content.examples.map((example, i) => (
|
{content.examples.map((example, i) => (
|
||||||
<li
|
<li key={i} className="flex items-start gap-1.5 text-xs text-foreground/70">
|
||||||
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" />
|
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-current opacity-50" />
|
||||||
{example}
|
{example}
|
||||||
</li>
|
</li>
|
||||||
@@ -143,10 +129,7 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
|
|||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{content.questions.map((question, i) => (
|
{content.questions.map((question, i) => (
|
||||||
<li
|
<li key={i} className="text-xs italic text-foreground/60">
|
||||||
key={i}
|
|
||||||
className="text-xs italic text-foreground/60"
|
|
||||||
>
|
|
||||||
{question}
|
{question}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -158,4 +141,3 @@ export function QuadrantHelpPanel({ category, isOpen }: QuadrantHelpPanelProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
import {
|
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||||
DragDropContext,
|
|
||||||
Droppable,
|
|
||||||
Draggable,
|
|
||||||
DropResult,
|
|
||||||
} from '@hello-pangea/dnd';
|
|
||||||
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
import type { SwotItem, Action, ActionLink, SwotCategory } from '@prisma/client';
|
||||||
import { SwotQuadrant } from './SwotQuadrant';
|
import { SwotQuadrant } from './SwotQuadrant';
|
||||||
import { SwotCard } from './SwotCard';
|
import { SwotCard } from './SwotCard';
|
||||||
@@ -67,9 +62,7 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
|
|||||||
if (!linkMode) return;
|
if (!linkMode) return;
|
||||||
|
|
||||||
setSelectedItems((prev) =>
|
setSelectedItems((prev) =>
|
||||||
prev.includes(itemId)
|
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
|
||||||
? prev.filter((id) => id !== itemId)
|
|
||||||
: [...prev, itemId]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,4 +160,3 @@ export function SwotBoard({ sessionId, items, actions }: SwotBoardProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
|||||||
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
|
className="rounded p-1 text-muted hover:bg-card-hover hover:text-foreground"
|
||||||
aria-label="Modifier"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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"
|
className="rounded p-1 text-muted hover:bg-primary/10 hover:text-primary"
|
||||||
aria-label="Dupliquer"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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"
|
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||||
aria-label="Supprimer"
|
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
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -168,7 +183,9 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
|||||||
|
|
||||||
{/* Selection indicator in link mode */}
|
{/* Selection indicator in link mode */}
|
||||||
{linkMode && isSelected && (
|
{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">
|
<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" />
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -182,4 +199,3 @@ export const SwotCard = forwardRef<HTMLDivElement, SwotCardProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
SwotCard.displayName = 'SwotCard';
|
SwotCard.displayName = 'SwotCard';
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,10 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
className={`
|
className={`
|
||||||
flex h-5 w-5 items-center justify-center rounded-full
|
flex h-5 w-5 items-center justify-center rounded-full
|
||||||
text-xs font-medium transition-all
|
text-xs font-medium transition-all
|
||||||
${showHelp
|
${
|
||||||
? 'bg-foreground/20 text-foreground'
|
showHelp
|
||||||
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
? 'bg-foreground/20 text-foreground'
|
||||||
|
: 'bg-foreground/5 text-muted hover:bg-foreground/10 hover:text-foreground'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
aria-label="Aide"
|
aria-label="Aide"
|
||||||
@@ -112,7 +113,12 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
aria-label={`Ajouter un item ${title}`}
|
aria-label={`Ajouter un item ${title}`}
|
||||||
>
|
>
|
||||||
<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" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,4 +172,3 @@ export const SwotQuadrant = forwardRef<HTMLDivElement, SwotQuadrantProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
SwotQuadrant.displayName = 'SwotQuadrant';
|
SwotQuadrant.displayName = 'SwotQuadrant';
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export { SwotQuadrant } from './SwotQuadrant';
|
|||||||
export { SwotCard } from './SwotCard';
|
export { SwotCard } from './SwotCard';
|
||||||
export { ActionPanel } from './ActionPanel';
|
export { ActionPanel } from './ActionPanel';
|
||||||
export { QuadrantHelp } from './QuadrantHelp';
|
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';
|
Badge.displayName = 'Badge';
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,11 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary: 'bg-primary text-primary-foreground hover:bg-primary-hover border-transparent',
|
||||||
'bg-primary text-primary-foreground hover:bg-primary-hover border-transparent',
|
secondary: 'bg-card text-foreground hover:bg-card-hover border-border',
|
||||||
secondary:
|
outline: 'bg-transparent text-foreground hover:bg-card-hover border-border',
|
||||||
'bg-card text-foreground hover:bg-card-hover border-border',
|
ghost: 'bg-transparent text-foreground hover:bg-card-hover border-transparent',
|
||||||
outline:
|
destructive: 'bg-destructive text-white hover:bg-destructive/90 border-transparent',
|
||||||
'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> = {
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
@@ -29,7 +24,10 @@ const sizeStyles: Record<ButtonSize, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -74,4 +72,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadi
|
|||||||
|
|
||||||
CardTitle.displayName = 'CardTitle';
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
|
export const CardDescription = forwardRef<
|
||||||
({ className = '', ...props }, ref) => (
|
HTMLParagraphElement,
|
||||||
<p ref={ref} className={`mt-1 text-sm text-muted ${className}`} {...props} />
|
HTMLAttributes<HTMLParagraphElement>
|
||||||
)
|
>(({ className = '', ...props }, ref) => (
|
||||||
);
|
<p ref={ref} className={`mt-1 text-sm text-muted ${className}`} {...props} />
|
||||||
|
));
|
||||||
|
|
||||||
CardDescription.displayName = 'CardDescription';
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
@@ -63,4 +64,3 @@ export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
|
|||||||
);
|
);
|
||||||
|
|
||||||
CardFooter.displayName = 'CardFooter';
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label htmlFor={inputId} className="mb-2 block text-sm font-medium text-foreground">
|
||||||
htmlFor={inputId}
|
|
||||||
className="mb-2 block text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -32,13 +29,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
|
||||||
<p className="mt-1.5 text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
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"
|
className="rounded-lg p-1 text-muted hover:bg-card-hover hover:text-foreground transition-colors"
|
||||||
aria-label="Fermer"
|
aria-label="Fermer"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label htmlFor={textareaId} className="mb-2 block text-sm font-medium text-foreground">
|
||||||
htmlFor={textareaId}
|
|
||||||
className="mb-2 block text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -33,13 +30,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && <p className="mt-1.5 text-sm text-destructive">{error}</p>}
|
||||||
<p className="mt-1.5 text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Textarea.displayName = 'Textarea';
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
export { Avatar } from './Avatar';
|
||||||
|
export { Badge } from './Badge';
|
||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||||
|
export { CollaboratorDisplay } from './CollaboratorDisplay';
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Textarea } from './Textarea';
|
|
||||||
export { Badge } from './Badge';
|
|
||||||
export { Modal, ModalFooter } from './Modal';
|
export { Modal, ModalFooter } from './Modal';
|
||||||
|
export { Textarea } from './Textarea';
|
||||||
|
|||||||
@@ -128,4 +128,3 @@ export function useMotivatorLive({
|
|||||||
|
|
||||||
return { isConnected, lastEvent, error };
|
return { isConnected, lastEvent, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function useSessionLive({
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as LiveEvent;
|
const data = JSON.parse(event.data) as LiveEvent;
|
||||||
|
|
||||||
// Handle connection event
|
// Handle connection event
|
||||||
if (data.type === 'connected') {
|
if (data.type === 'connected') {
|
||||||
return;
|
return;
|
||||||
@@ -129,4 +129,3 @@ export function useSessionLive({
|
|||||||
|
|
||||||
return { isConnected, lastEvent, error };
|
return { isConnected, lastEvent, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,3 @@ export const authConfig: NextAuthConfig = {
|
|||||||
},
|
},
|
||||||
providers: [], // Configured in auth.ts
|
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',
|
type: 'POWER',
|
||||||
name: 'Pouvoir',
|
name: 'Pouvoir',
|
||||||
icon: '⚡',
|
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
|
color: '#ef4444', // red
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -291,12 +291,10 @@ export const MOTIVATORS_CONFIG: MotivatorConfig[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> =
|
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> = MOTIVATORS_CONFIG.reduce(
|
||||||
MOTIVATORS_CONFIG.reduce(
|
(acc, config) => {
|
||||||
(acc, config) => {
|
acc[config.type] = config;
|
||||||
acc[config.type] = config;
|
return acc;
|
||||||
return acc;
|
},
|
||||||
},
|
{} as Record<MotivatorType, MotivatorConfig>
|
||||||
{} 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
|
// Match all paths except static files and api routes that don't need auth
|
||||||
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
|
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) {
|
export async function getUserById(id: string) {
|
||||||
return prisma.user.findUnique({
|
return prisma.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -144,3 +189,60 @@ export async function updateUserPassword(
|
|||||||
return { success: true };
|
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 { prisma } from '@/services/database';
|
||||||
|
import { resolveCollaborator } from '@/services/auth';
|
||||||
import type { ShareRole, MotivatorType } from '@prisma/client';
|
import type { ShareRole, MotivatorType } from '@prisma/client';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -48,7 +49,11 @@ export async function getMotivatorSessionsByUserId(userId: string) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Mark owned sessions and merge with shared
|
// 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) => ({
|
const sharedWithRole = shared.map((s) => ({
|
||||||
...s.session,
|
...s.session,
|
||||||
isOwner: false as const,
|
isOwner: false as const,
|
||||||
@@ -56,9 +61,19 @@ export async function getMotivatorSessionsByUserId(userId: string) {
|
|||||||
sharedAt: s.createdAt,
|
sharedAt: s.createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
(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) {
|
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 role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||||
const canEdit = isOwner || role === 'EDITOR';
|
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)
|
// Check if user can access session (owner or shared)
|
||||||
@@ -186,10 +204,7 @@ export async function updateMotivatorCard(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderMotivatorCards(
|
export async function reorderMotivatorCards(sessionId: string, cardIds: string[]) {
|
||||||
sessionId: string,
|
|
||||||
cardIds: string[]
|
|
||||||
) {
|
|
||||||
const updates = cardIds.map((id, index) =>
|
const updates = cardIds.map((id, index) =>
|
||||||
prisma.motivatorCard.update({
|
prisma.motivatorCard.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -336,4 +351,3 @@ export async function getLatestMotivatorEventTimestamp(sessionId: string) {
|
|||||||
});
|
});
|
||||||
return event?.createdAt;
|
return event?.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@/services/database';
|
import { prisma } from '@/services/database';
|
||||||
|
import { resolveCollaborator } from '@/services/auth';
|
||||||
import type { SwotCategory, ShareRole } from '@prisma/client';
|
import type { SwotCategory, ShareRole } from '@prisma/client';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -50,7 +51,11 @@ export async function getSessionsByUserId(userId: string) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Mark owned sessions and merge with shared
|
// 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) => ({
|
const sharedWithRole = shared.map((s) => ({
|
||||||
...s.session,
|
...s.session,
|
||||||
isOwner: false as const,
|
isOwner: false as const,
|
||||||
@@ -58,9 +63,19 @@ export async function getSessionsByUserId(userId: string) {
|
|||||||
sharedAt: s.createdAt,
|
sharedAt: s.createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
(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) {
|
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 role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||||
const canEdit = isOwner || role === 'EDITOR';
|
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)
|
// Check if user can access session (owner or shared)
|
||||||
@@ -234,11 +252,7 @@ export async function reorderSwotItems(
|
|||||||
return prisma.$transaction(updates);
|
return prisma.$transaction(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveSwotItem(
|
export async function moveSwotItem(itemId: string, newCategory: SwotCategory, newOrder: number) {
|
||||||
itemId: string,
|
|
||||||
newCategory: SwotCategory,
|
|
||||||
newOrder: number
|
|
||||||
) {
|
|
||||||
return prisma.swotItem.update({
|
return prisma.swotItem.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
data: {
|
data: {
|
||||||
@@ -450,4 +464,3 @@ export async function getLatestEventTimestamp(sessionId: string) {
|
|||||||
});
|
});
|
||||||
return event?.createdAt;
|
return event?.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user