Compare commits

37 Commits

Author SHA1 Message Date
cedric 7d75866803 fix: create docker networks 2026-05-14 17:20:15 +04:00
cedric 42fb5f40f9 build: add node-gyp & sharp lib 2026-05-14 12:26:55 +04:00
cedric 60f5c8ae9c chore: remove package-lock.json 2026-04-30 20:15:43 +04:00
cedric 1109ceb2bb fix: web socket in dev 2026-04-14 17:38:23 +04:00
cedric d4deaa7716 chore: mise à jour todo.md — clôture P3
- Lazy loading : déjà implémenté (dynamic imports jsPDF/html2canvas/md-editor)
- NextAuth v5 stable : bloquée, pas de v5 stable publiée à ce jour
- Responsive mobile : à vérifier manuellement sur appareil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:41:12 +04:00
cedric 43f1f6e9f2 a11y: corrections accessibilité WCAG 2.1 (critères 4.1.2, 4.1.3, 1.3.1)
sign.js :
- aria-label sur les 4 Fab (Se déconnecter, dashboard, Se connecter, S'enregistrer)
- Correction des guillemets typographiques U+2018/U+2019 en ASCII (empêchaient le parsing JSX)
- Suppression de useMemo inutilisé
- IIFE async ;() → startSubscription() nommée + .catch() explicite (semi-style + no-void)

auth-form/index.js :
- aria-label des IconButton visibility traduits en français avec état dynamique :
  'Afficher/Masquer le mot de passe' et 'Afficher/Masquer la vérification'

version-timeline.js :
- aria-label='Comparer les versions' sur IconButton Comparer
- aria-label dynamique + aria-expanded sur le bouton expand/collapse
- Correction object-curly-newline et jsx-closing-bracket-location (pré-existants)

version-search.js :
- inputProps aria-label='Rechercher dans les versions' (placeholder seul insuffisant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 14:36:37 +04:00
cedric e75d2e1c53 feat: dockerisation frontend Next.js (output: standalone)
- next.config.mjs : output: 'standalone' — bundle minimal sans node_modules
- Dockerfile      : multi-stage (deps → builder → runner) sur node:22-alpine
  - ARG build-time pour les vars NEXT_PUBLIC_* et SENTRY_AUTH_TOKEN (optionnel)
  - Utilisateur non-root nextjs:nodejs (uid/gid 1001)
  - Image finale < 200 Mo (pas de node_modules, juste .next/standalone)
- docker-compose.yml : service frontend avec env_file et restart: unless-stopped
- .dockerignore   : exclut node_modules, .next, .env, yarn.lock, docs

Alternative au déploiement PM2 existant (DEPLOYMENT.md inchangé).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:55:41 +04:00
cedric c4762c6437 perf: cache des données constitution avec unstable_cache (ISR data layer)
La page d'accueil appelle auth() (cookies → dynamique) donc export const revalidate
ne s'applique pas au rendu. On cache à la place les appels Directus :

- fetchConstitution() → wrappée avec nextCache (unstable_cache)
- revalidate: 300 (5 min) — la constitution évolue peu
- tag 'constitution' — permet revalidateTag('constitution') depuis une API route
  pour invalider le cache à la demande lors d'un changement Directus

Les appels API Directus (titres + articles) ne sont plus refaits à chaque requête.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:54:07 +04:00
cedric a25a610d73 chore: suppression middleware.js et mise à jour yarn.lock
middleware.js fusionné dans proxy.js depuis le commit 8016c26.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:50:35 +04:00
cedric 8016c26e32 feat: intégration Sentry + migration middleware.js → proxy.js (Next.js 16)
Sentry (tracking erreurs frontend + API routes) :
- sentry.client.config.js  : erreurs navigateur + Session Replay sur erreurs
- sentry.server.config.js  : erreurs API routes (register, jwt callback)
- sentry.edge.config.js    : runtime edge (middleware proxy)
- instrumentation.js       : point d'entrée Next.js 15+ (register + captureRequestError)
- next.config.mjs          : wrappé avec withSentryConfig (source maps désactivés sans SENTRY_AUTH_TOKEN)
- .env.sample              : ajout de NEXT_PUBLIC_SENTRY_DSN (placeholder)

Migration middleware → proxy (bug pré-existant surfacé par le build Sentry) :
- proxy.js : fusion du rate limiting + auth NextAuth en un seul proxy Next.js 16
- middleware.js : supprimé (Next.js 16 n'accepte plus les deux fichiers simultanément)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:48:55 +04:00
cedric d8a771161c feat(auth): refresh token Directus explicite dans le callback JWT NextAuth
Sans ce correctif, l'access token Directus (~15 min) expirait silencieusement,
rendant toutes les requêtes API 401 sans déconnecter l'utilisateur.

- Ajout de refreshDirectusToken() : POST /auth/refresh avec rotation du refresh_token
- accessTokenExpires stocké dès la connexion (expires Directus - marge 60s)
- jwt callback : token valide → pass-through, token expiré → refresh, échec → error flag
- session callback : propagation de session.error = 'RefreshAccessTokenError'
  (permet au client de forcer un signOut si le refresh_token est lui-même expiré)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:37:11 +04:00
cedric 7b831d5bc4 test: tests unitaires Vitest — format, version-utils, rate-limit
- Installe vitest@4 + @vitest/coverage-v8 (40 tests, 0 échec)
- lib/__tests__/format.test.js        : 14 tests (formatKonstitisyon, formatDate, hasRestrictedChar)
- lib/__tests__/version-utils.test.js : 17 tests (filterVersions par texte/auteur/date, getFilterStats)
- lib/__tests__/rate-limit.test.js    : 9 tests avec fake timers (limite, reset, retryAfter, keys indépendantes)
- vitest.config.mjs : environnement node, imports explicites (pas de globals)
- package.json : scripts test / test:watch / test:coverage + override XO pour les fichiers de test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:30:10 +04:00
cedric 170c3c5e90 security: Content Security Policy et headers HTTP sécurité
- Renomme next.config.js → next.config.mjs (ESM, satisfait unicorn/prefer-module)
- Ajout de headers() avec CSP stricte :
    script/style-src 'unsafe-inline' (requis Next.js + Emotion/MUI)
    connect-src dynamique depuis les env vars Directus (API + WebSocket)
    object-src 'none', frame-ancestors 'none', base-uri 'self'
    img-src 'self' data: blob: (html2canvas / export PDF)
- Ajout X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy sur toutes les routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:40 +04:00
cedric dc1f115bd6 security: sanitiser la sortie marked avec DOMPurify (XSS)
export-pdf-button et print-button injectaient marked(content) directement
dans innerHTML / document.write. Un lien Markdown javascript: passait le
filtre hasRestrictedChar et pouvait s'exécuter.

Ajout de DOMPurify.sanitize() via import dynamique (déjà présent en dep
transitive de jspdf) sur les deux composants, avec whitelist de tags
et d'attributs stricte. markdown-renderer n'est pas touché car
react-markdown-preview utilise rehype-sanitize en interne.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:48:26 +04:00
cedric d8a63bc4d8 feat: rate limiting sur les routes d'authentification critiques
- Ajout de lib/rate-limit.js : fabrique de limiter en mémoire (closure +
  Map avec nettoyage lazy), sans dépendance externe, réutilisable
- Ajout de middleware.js : intercepte /api/auth/register (5 req/15min)
  et /api/auth/callback/credentials (10 req/5min), répond 429 + Retry-After
- Ajout de tasks/todo.md et tasks/lessons.md (suivi CLAUDE.md)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:30:38 +04:00
cedric 22130529f6 feat: récupère le total des votes 2026-01-24 23:35:48 +04:00
cedric b838f46b2b fix: change color & variant pour le total des votes 2026-01-24 22:25:53 +04:00
cedric c2f8a4fb19 feat: ajout du nombre de vote total 2026-01-24 22:14:49 +04:00
cedric a184665ed1 feat: simplifie la vue timeline 2026-01-24 21:34:02 +04:00
cedric be45cc1cc0 docs: ajout de proxy_buffer et X-Forwarded-Host dans la configuration nginx 2026-01-24 17:56:56 +04:00
cedric 5ee2e3707a build: upgrade next-auth 2026-01-24 13:42:20 +04:00
cedric 6f214f7468 feat: ajoute la possibilité de désactiver les websockets 2026-01-24 13:22:35 +04:00
cedric 8ec761b2c8 fix: ajout d'un cercle circulaire lors du chargement des commentaires 2026-01-24 12:23:04 +04:00
cedric 315c71baa4 feat: denier titre publié dans le select lors de la création d'article 2026-01-24 09:08:20 +04:00
cedric d19fbf990b feat: ajout de la timezone pour les exports 2026-01-24 00:40:38 +04:00
cedric 760ca0609d docs: ajout de la documentation pour le déploiement 2026-01-23 23:23:09 +04:00
cedric de81fbfe5c fix: change titres sorting 2026-01-22 11:38:30 +04:00
cedric 1cf621b752 chore: renomme domaine konstitisyon.la vers konstitisyon.nu 2026-01-10 23:41:57 +04:00
cedric e7c4343bfc build: upgrade react-md-editor, jspdf, marked & react-virtuoso 2026-01-05 21:07:43 +04:00
cedric 2701957af8 fix: ferme les votes sur les versions obsolètes dans PDF/Print 2026-01-04 13:14:09 +04:00
cedric e101f503d2 feat: améliorer la gestion de WebSocket Directus 2026-01-04 13:13:49 +04:00
cedric 47d58680b3 build: upgrade Mui & d'autres lib liées 2026-01-04 13:13:25 +04:00
cedric 5679d71b5b build: upgrade date-fns vers 4.1.0 2026-01-04 13:13:03 +04:00
cedric 6134755888 fix: ajout de await à searchParams 2026-01-04 13:12:49 +04:00
cedric 7c41beb992 fix: erreur explicite lors de l'échec de connexion 2026-01-04 13:12:10 +04:00
cedric 1cedf24a65 chore: ajout de next.config 2026-01-04 13:11:59 +04:00
cedric 5249dda717 build: upgrade directus/sdk 2026-01-04 13:11:34 +04:00
44 changed files with 4543 additions and 1592 deletions
+8
View File
@@ -0,0 +1,8 @@
Dockerfile
.dockerignore
node_modules
.next
.git
.env
tasks/
*.md
+18 -6
View File
@@ -1,5 +1,11 @@
DIRECTUS_API_URL=http://0.0.0.0:8055 # URL interne Docker (server-side Next.js → conteneur Directus via le réseau Docker)
DIRECTUS_API_WS_URL=ws://0.0.0.0:8055/websocket DIRECTUS_API_URL=http://directus:8055
DIRECTUS_API_WS_URL=ws://directus:8055/websocket
# URL publique (navigateur → Directus exposé sur l'hôte)
NEXT_PUBLIC_DIRECTUS_API_URL=http://0.0.0.0:8055
NEXT_PUBLIC_DIRECTUS_API_WS_URL=ws://0.0.0.0:8055/websocket
APP_TITLE=constitution de karukera APP_TITLE=constitution de karukera
APP_FOOTER_TEXT=organisation ka internationale (oki) APP_FOOTER_TEXT=organisation ka internationale (oki)
APP_FOOTER_URL=https://o-k-i.net APP_FOOTER_URL=https://o-k-i.net
@@ -7,12 +13,18 @@ NEXT_PUBLIC_APP_FOOTER_TEXT=organisation ka internationale (oki)
NEXT_PUBLIC_APP_FOOTER_URL=https://o-k-i.net NEXT_PUBLIC_APP_FOOTER_URL=https://o-k-i.net
# AUTH # AUTH
NEXTAUTH_URL=http://0.0.0.0:3000 NEXTAUTH_URL=http://0.0.0.0:4000
NEXTAUTH_SECRET=NEXTAUTH_SECRET NEXTAUTH_SECRET=NEXTAUTH_SECRET
USER_ROLE=DIRECTUS_USER_ROLE_ID USER_ROLE=DIRECTUS_USER_ROLE_ID
NEXT_PUBLIC_URL=http://0.0.0.0:3000 NEXT_PUBLIC_URL=http://0.0.0.0:4000
NEXT_PUBLIC_DIRECTUS_API_URL=$DIRECTUS_API_URL
NEXT_PUBLIC_DIRECTUS_API_WS_URL=$DIRECTUS_API_WS_URL
# COMMENTS # COMMENTS
COMMENTS_PER_PAGE=5 COMMENTS_PER_PAGE=5
PASSWORD_RESET_URL_ALLOW_LIST=http://0.0.0.0:4000/reset-password
# WEBSOCKET
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
# SENTRY
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
+214
View File
@@ -0,0 +1,214 @@
# Deploiement Frontend Next.js
Guide de deploiement du frontend Next.js sur un serveur Ubuntu.
## Prerequis
- Ubuntu 20.04+ / Debian 11+
- Acces root ou sudo
- Node.js 20+
- Backend Directus deploye (ex: `api.exemple.com`)
- Nom de domaine configure (ex: `exemple.com`)
## 1. Installation des dependances
```bash
sudo apt update && sudo apt upgrade -y
# Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Yarn et PM2
sudo npm install -g yarn pm2
# Nginx et Certbot
sudo apt install -y nginx certbot python3-certbot-nginx
```
## 2. Configuration du projet
```bash
# Cloner le projet
git clone <URL_DU_REPO> frontend
cd frontend
# Configurer l'environnement
cp .env.sample .env
nano .env
```
Variables a modifier dans `.env`:
```env
DIRECTUS_API_URL=https://api.exemple.com
DIRECTUS_API_WS_URL=wss://api.exemple.com/websocket
NEXTAUTH_URL=https://exemple.com
NEXTAUTH_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_URL=https://exemple.com
NEXT_PUBLIC_DIRECTUS_API_URL=https://api.exemple.com
NEXT_PUBLIC_DIRECTUS_API_WS_URL=wss://api.exemple.com/websocket
```
## 3. Build de production
```bash
yarn install --frozen-lockfile
yarn build
```
## 4. Demarrage avec PM2
```bash
pm2 start yarn --name "frontend" -- start
pm2 status
# Demarrage automatique au boot
pm2 startup
pm2 save
```
## 5. Configuration Nginx
```bash
sudo nano /etc/nginx/sites-available/exemple.com
```
```nginx
server {
listen 80;
listen [::]:80;
server_name exemple.com;
client_max_body_size 10M;
access_log /var/log/nginx/exemple.com.access.log;
error_log /var/log/nginx/exemple.com.error.log;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
# Buffer sizes pour les gros headers/cookies (JWT)
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Timeouts
proxy_connect_timeout 60;
proxy_send_timeout 60;
proxy_read_timeout 60;
}
location /_next/static {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 60m;
add_header Cache-Control "public, immutable, max-age=31536000";
}
}
```
Activer le site:
```bash
sudo ln -s /etc/nginx/sites-available/exemple.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 6. Certificat SSL
Verifier le DNS:
```bash
dig +short exemple.com
curl -4 ifconfig.me
```
Obtenir le certificat:
```bash
sudo certbot --nginx -d exemple.com
```
## 7. Verification
Ouvrir `https://exemple.com` dans un navigateur.
## Commandes utiles
```bash
# Logs PM2
pm2 logs frontend
# Statut
pm2 status
# Redemarrer
pm2 restart frontend
# Mise a jour
git pull origin main
yarn install --frozen-lockfile
yarn build
pm2 restart frontend
```
## Troubleshooting
### L'application ne demarre pas
```bash
pm2 logs frontend --lines 50
ls -la .next/
yarn start
```
### Erreur 502
```bash
pm2 status
curl http://localhost:3000
sudo tail -20 /var/log/nginx/exemple.com.error.log
```
Si le curl local fonctionne mais pas via Nginx, verifier les buffer sizes dans la config Nginx (necessaires pour les gros cookies JWT):
```nginx
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
```
### Erreur de connexion API
```bash
curl https://api.exemple.com/server/health
cat .env | grep DIRECTUS
```
### Erreur SSL
```bash
dig +short exemple.com
sudo certbot certificates
sudo certbot renew --force-renewal
```
## Configuration CORS Backend
Verifier que le backend autorise le frontend dans son `.env`:
```env
CORS_ENABLED=true
CORS_ORIGIN=true
```
+56
View File
@@ -0,0 +1,56 @@
# ─── Étape 1 : dépendances ───────────────────────────────────────────────────
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copie des fichiers de dépendances
COPY package.json yarn.lock ./
# Installation avec Yarn
RUN yarn install --frozen-lockfile
# ─── Étape 2 : build de production ───────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Variables nécessaires au build
ARG NEXT_PUBLIC_DIRECTUS_API_URL
ARG NEXT_PUBLIC_DIRECTUS_API_WS_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN
ENV NEXT_PUBLIC_DIRECTUS_API_URL=$NEXT_PUBLIC_DIRECTUS_API_URL
ENV NEXT_PUBLIC_DIRECTUS_API_WS_URL=$NEXT_PUBLIC_DIRECTUS_API_WS_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
# Build Next.js
RUN yarn build
# ─── Étape 3 : image de production minimale ──────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4000
ENV HOSTNAME=0.0.0.0
# Utilisateur non-root
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Fichiers nécessaires au mode standalone
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 4000
CMD ["node", "server.js"]
+4 -4
View File
@@ -1,10 +1,10 @@
# konstitisyon.la # konstitisyon.nu
Plateforme collaborative dédiée à la rédaction citoyenne d'une constitution. Ce projet vise à permettre aux citoyens de participer activement au processus de rédaction constitutionnelle à travers un système transparent et démocratique. Plateforme collaborative dédiée à la rédaction citoyenne d'une constitution. Ce projet vise à permettre aux citoyens de participer activement au processus de rédaction constitutionnelle à travers un système transparent et démocratique.
## Vision du projet ## Vision du projet
L'objectif de konstitisyon.la est de démocratiser le processus de rédaction constitutionnelle en : L'objectif de konstitisyon.nu est de démocratiser le processus de rédaction constitutionnelle en :
- Permettant à chaque citoyen de proposer des modifications - Permettant à chaque citoyen de proposer des modifications
- Facilitant le débat et la discussion autour des propositions - Facilitant le débat et la discussion autour des propositions
- Assurant la transparence du processus de rédaction - Assurant la transparence du processus de rédaction
@@ -41,9 +41,9 @@ L'objectif de konstitisyon.la est de démocratiser le processus de rédaction co
### Structure du projet ### Structure du projet
``` ```
konstitisyon.la/ konstitisyon.nu/
├── app/ # Routes et pages Next.js ├── app/ # Routes et pages Next.js
├── components/ ├── components/
│ ├── konstitisyon/ # Composants liés à la constitution │ ├── konstitisyon/ # Composants liés à la constitution
│ └── versions/ # Gestion des versions et votes │ └── versions/ # Gestion des versions et votes
├── lib/ # Utilitaires et configurations ├── lib/ # Utilitaires et configurations
+51 -15
View File
@@ -1,3 +1,4 @@
/* eslint-disable new-cap */
import CredentialsProvider from 'next-auth/providers/credentials' import CredentialsProvider from 'next-auth/providers/credentials'
import {readMe, withToken} from '@directus/sdk' import {readMe, withToken} from '@directus/sdk'
import {directusClient} from '@/lib/directus.js' import {directusClient} from '@/lib/directus.js'
@@ -5,9 +6,39 @@ import {directusClient} from '@/lib/directus.js'
const apiUrl = process.env.DIRECTUS_API_URL const apiUrl = process.env.DIRECTUS_API_URL
const nextauthSecret = process.env.NEXTAUTH_SECRET const nextauthSecret = process.env.NEXTAUTH_SECRET
// On rafraîchit 60s avant l'échéance réelle pour éviter les races
const REFRESH_MARGIN_MS = 60 * 1000
/**
* Appelle l'endpoint Directus /auth/refresh et retourne les nouveaux tokens.
* Lève une erreur si le refresh échoue (refresh_token expiré ou révoqué).
*
* @param {string} refreshToken
* @returns {Promise<{accessToken: string, refreshToken: string, accessTokenExpires: number}>}
*/
async function refreshDirectusToken(refreshToken) {
const res = await fetch(`${apiUrl}/auth/refresh`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({refresh_token: refreshToken, mode: 'json'}),
})
if (!res.ok) {
throw new Error(`Directus refresh failed with status ${res.status}`)
}
const {data} = await res.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpires: Date.now() + (data.expires ?? 900_000) - REFRESH_MARGIN_MS,
}
}
export const options = { export const options = {
providers: [ providers: [
CredentialsProvider({ // eslint-disable-line new-cap CredentialsProvider({
name: 'Credentials', name: 'Credentials',
credentials: { credentials: {
email: {}, email: {},
@@ -22,10 +53,6 @@ export const options = {
const user = await res.json() const user = await res.json()
if (!res.ok && user) {
throw new Error('E-mail ou mot de passe incorrect')
}
if (res.ok && user) { if (res.ok && user) {
return user return user
} }
@@ -40,18 +67,13 @@ export const options = {
signIn: '/login' signIn: '/login'
}, },
callbacks: { callbacks: {
async jwt({ async jwt({token, user, account}) {
token, // Connexion initiale : enrichissement du token avec les données Directus
user,
account
}) {
if (account && user) { if (account && user) {
const userData = await directusClient.request( const userData = await directusClient.request(
withToken( withToken(
user.data.access_token, user.data.access_token,
readMe({ readMe({fields: ['*']})
fields: ['*']
})
) )
) )
@@ -59,11 +81,24 @@ export const options = {
...token, ...token,
accessToken: user.data.access_token, accessToken: user.data.access_token,
refreshToken: user.data.refresh_token, refreshToken: user.data.refresh_token,
user: userData accessTokenExpires: Date.now() + (user.data.expires ?? 900_000) - REFRESH_MARGIN_MS,
user: userData,
} }
} }
return token // Token encore valide : on le retourne sans modification
if (Date.now() < token.accessTokenExpires) {
return token
}
// Token expiré : tentative de rafraîchissement silencieux
try {
const refreshed = await refreshDirectusToken(token.refreshToken)
return {...token, ...refreshed}
} catch (refreshError) {
console.error('Refresh token Directus échoué :', refreshError.message)
return {...token, error: 'RefreshAccessTokenError'}
}
}, },
async session({session, token}) { async session({session, token}) {
session.user.userId = token.user.id session.user.userId = token.user.id
@@ -73,6 +108,7 @@ export const options = {
session.user.token = token.user.token session.user.token = token.user.token
session.user.email = token.user.email session.user.email = token.user.email
session.user.password = token.user.password session.user.password = token.user.password
session.error = token.error
return session return session
} }
} }
+3 -1
View File
@@ -19,7 +19,9 @@ export default function LoginForm() {
}) })
if (response?.error) { if (response?.error) {
if (response.error === 'Configuration') { if (response.error === 'CredentialsSignin') {
setError('E-mail ou mot de passe incorrect')
} else if (response.error === 'Configuration') {
setError('Une erreur sest produite, contactez ladministrateur !') setError('Une erreur sest produite, contactez ladministrateur !')
} else { } else {
setError(response.error) setError(response.error)
+11 -2
View File
@@ -1,4 +1,5 @@
import {createDirectus, rest, readItems} from '@directus/sdk' import {createDirectus, rest, readItems} from '@directus/sdk'
import {unstable_cache as nextCache} from 'next/cache'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
@@ -19,7 +20,7 @@ const navButton = {
icon: <AdminPanelSettingsIcon fontSize='large' /> icon: <AdminPanelSettingsIcon fontSize='large' />
} }
async function getData() { async function fetchConstitution() {
if (!apiUrl) { if (!apiUrl) {
throw new Error('DIRECTUS_API_URL is required') throw new Error('DIRECTUS_API_URL is required')
} }
@@ -34,7 +35,7 @@ async function getData() {
_eq: 'published' _eq: 'published'
} }
}, },
sort: 'numero' sort: 'date_created'
}) })
) )
@@ -58,6 +59,14 @@ async function getData() {
} }
} }
// Mise en cache des données constitution — revalidation toutes les 5 minutes.
// Le tag 'constitution' permet une invalidation à la demande via revalidateTag().
const getData = nextCache(
fetchConstitution,
['constitution-data'],
{revalidate: 300, tags: ['constitution']}
)
export default async function Page() { export default async function Page() {
const session = await auth() const session = await auth()
const {titres, articles} = await getData() const {titres, articles} = await getData()
+2 -2
View File
@@ -3,8 +3,8 @@ import {redirect} from 'next/navigation'
import ResetPasswordForm from './form.js' import ResetPasswordForm from './form.js'
export default async function ResetPasswordPage({searchParams}) { export default async function ResetPasswordPage({searchParams}) {
console.log('searchParams', searchParams) const params = await searchParams
const {token} = searchParams const {token} = params
if (!token) { if (!token) {
redirect('/login') redirect('/login')
+2 -2
View File
@@ -138,7 +138,7 @@ export default function AuthForm({
endAdornment={ endAdornment={
<InputAdornment position='end'> <InputAdornment position='end'>
<IconButton <IconButton
aria-label='password visibility' aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
size='large' size='large'
onClick={handleClickShowPassword} onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword} onMouseDown={handleMouseDownPassword}
@@ -167,7 +167,7 @@ export default function AuthForm({
endAdornment={ endAdornment={
<InputAdornment position='end'> <InputAdornment position='end'>
<IconButton <IconButton
aria-label='password visibility' aria-label={showPasswordVerification ? 'Masquer la vérification du mot de passe' : 'Afficher la vérification du mot de passe'}
size='large' size='large'
onClick={handleClickShowPasswordVerifiation} onClick={handleClickShowPasswordVerifiation}
onMouseDown={handleMouseDownPasswordVerification} onMouseDown={handleMouseDownPasswordVerification}
@@ -22,7 +22,7 @@ export default function HandleCreate({
useEffect(() => { useEffect(() => {
if (listItems && listItems.length > 0) { if (listItems && listItems.length > 0) {
setSelectValue(listItems[0].id) setSelectValue(listItems.at(-1).id)
} }
}, [listItems]) }, [listItems])
@@ -142,6 +142,7 @@ export default function HandleCreate({
collection={collection} collection={collection}
listItems={listItems} listItems={listItems}
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
selectValue={selectValue}
setSelectValue={setSelectValue} setSelectValue={setSelectValue}
title='Article' title='Article'
dialogText='Écrivez votre article' dialogText='Écrivez votre article'
+3 -2
View File
@@ -4,7 +4,7 @@ import InputLabel from '@mui/material/InputLabel'
import FormControl from '@mui/material/FormControl' import FormControl from '@mui/material/FormControl'
import NativeSelect from '@mui/material/NativeSelect' import NativeSelect from '@mui/material/NativeSelect'
export default function ListItems({items, selectLabel, setSelectValue}) { export default function ListItems({items, selectLabel, selectValue, setSelectValue}) {
const handleChange = event => { const handleChange = event => {
setSelectValue(event.target.value) setSelectValue(event.target.value)
} }
@@ -16,7 +16,7 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
{selectLabel} {selectLabel}
</InputLabel> </InputLabel>
<NativeSelect <NativeSelect
defaultValue='' value={selectValue}
inputProps={{ inputProps={{
name: 'content', name: 'content',
id: 'titre', id: 'titre',
@@ -35,5 +35,6 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
ListItems.propTypes = { ListItems.propTypes = {
items: PropTypes.array.isRequired, items: PropTypes.array.isRequired,
selectLabel: PropTypes.string.isRequired, selectLabel: PropTypes.string.isRequired,
selectValue: PropTypes.string.isRequired,
setSelectValue: PropTypes.func.isRequired setSelectValue: PropTypes.func.isRequired
} }
+3
View File
@@ -24,6 +24,7 @@ export default function FormHandler({
listItems, listItems,
handleFormSubmit, handleFormSubmit,
countdownRef, countdownRef,
selectValue,
setSelectValue, setSelectValue,
contenu, contenu,
collection collection
@@ -51,6 +52,7 @@ export default function FormHandler({
<ListItems <ListItems
items={listItems} items={listItems}
selectLabel='Titre associé *' selectLabel='Titre associé *'
selectValue={selectValue}
setSelectValue={setSelectValue} setSelectValue={setSelectValue}
/> />
)} )}
@@ -94,6 +96,7 @@ FormHandler.propTypes = {
setError: PropTypes.func.isRequired, setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired, setIsErrorAlertOpen: PropTypes.func.isRequired,
handleFormSubmit: PropTypes.func.isRequired, handleFormSubmit: PropTypes.func.isRequired,
selectValue: PropTypes.string,
setSelectValue: PropTypes.func.isRequired, setSelectValue: PropTypes.func.isRequired,
dialogText: PropTypes.string.isRequired, dialogText: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
+2 -2
View File
@@ -116,6 +116,6 @@ export default function Konstitisyon({session, titres, articles}) {
Konstitisyon.propTypes = { Konstitisyon.propTypes = {
session: PropTypes.object, session: PropTypes.object,
titres: PropTypes.object.isRequired, titres: PropTypes.array.isRequired,
articles: PropTypes.object.isRequired articles: PropTypes.array.isRequired
} }
+56 -35
View File
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
import Pagination from '@mui/material/Pagination' import Pagination from '@mui/material/Pagination'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import {readItems, withToken} from '@directus/sdk' import {readItems, withToken} from '@directus/sdk'
import SessionExpired from '../session/session-expired.js' import SessionExpired from '../session/session-expired.js'
import {directusClient, handleUserStatus} from '@/lib/directus.js' import {directusClient, handleUserStatus} from '@/lib/directus.js'
@@ -20,6 +21,7 @@ const commentsPerPage = process.env.NEXT_PUBLIC_COMMENTS_PER_PAGE || 2
export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) { export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) {
const countdownRef = useRef() const countdownRef = useRef()
const [comments, setComments] = useState([]) const [comments, setComments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const pageCount = Math.ceil(comments.length / commentsPerPage) const pageCount = Math.ceil(comments.length / commentsPerPage)
@@ -27,8 +29,15 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
const startIndex = (page - 1) * commentsPerPage const startIndex = (page - 1) * commentsPerPage
const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage) const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
useEffect(() => {
setComments([])
setPage(1)
}, [selectedTitre?.id])
useEffect(() => { useEffect(() => {
async function fetchComments() { async function fetchComments() {
setIsLoading(true)
try { try {
await handleUserStatus(session.user.accessToken, session.user.userId) await handleUserStatus(session.user.accessToken, session.user.userId)
@@ -54,6 +63,8 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
setError(error?.errors[0]?.message) setError(error?.errors[0]?.message)
setIsErrorAlertOpen(true) setIsErrorAlertOpen(true)
} }
} finally {
setIsLoading(false)
} }
} }
@@ -74,42 +85,52 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
<> <>
<Dialog open={isOpen} onClose={handleClose}> <Dialog open={isOpen} onClose={handleClose}>
<DialogTitle>Commentaires</DialogTitle> <DialogTitle>Commentaires</DialogTitle>
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}> {isLoading ? (
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => ( <Box sx={{display: 'flex', justifyContent: 'center', p: 4}}>
<React.Fragment key={id}> <CircularProgress />
<ListItem alignItems='flex-start'> </Box>
<ListItemText ) : (
primary={ <>
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'> <List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
@{user_created.split('-')[0]} {selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
</Typography> <React.Fragment key={id}>
} <ListItem alignItems='flex-start'>
secondary={ <ListItemText
<> primary={
<Typography <Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
sx={{display: 'inline'}} @{user_created.split('-')[0]}
component='span' </Typography>
variant='body2' }
color='text.primary' secondary={
> <>
{contenu} <Typography
</Typography> sx={{display: 'inline'}}
<br /> component='span'
{formatDate(date_created, 'PPPPpp')} variant='body2'
</> color='text.primary'
} >
/> {contenu}
</ListItem> </Typography>
<br />
{formatDate(date_created, 'PPPPpp')}
</>
}
/>
</ListItem>
<Divider component='li' /> <Divider component='li' />
</React.Fragment> </React.Fragment>
)) : ( )) : (
<Typography textAlign='center'>Aucun commentaire</Typography> <Typography textAlign='center' sx={{p: 2}}>Aucun commentaire</Typography>
)} )}
</List> </List>
<Box sx={{display: 'flex', justifyContent: 'center'}}> {pageCount > 1 && (
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} /> <Box sx={{display: 'flex', justifyContent: 'center'}}>
</Box> <Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
</Box>
)}
</>
)}
</Dialog> </Dialog>
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} /> <SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</> </>
+55 -40
View File
@@ -15,7 +15,8 @@ import PersonAddIcon from '@mui/icons-material/PersonAdd'
import {createDirectus, realtime, staticToken} from '@directus/sdk' import {createDirectus, realtime, staticToken} from '@directus/sdk'
import ConfirmationAlert from './confirmation-alert.js' import ConfirmationAlert from './confirmation-alert.js'
const apiWsUrl = process.env.DIRECTUS_API_WS_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL const apiUrl = process.env.DIRECTUS_API_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_URL
const disableWebSocket = process.env.NEXT_PUBLIC_DISABLE_WEBSOCKET === 'true'
const LightTooltip = styled(({className, ...props}) => ( const LightTooltip = styled(({className, ...props}) => (
<Tooltip {...props} classes={{popper: className}} /> <Tooltip {...props} classes={{popper: className}} />
@@ -32,62 +33,76 @@ export default function Sign({session, navButton}) {
const router = useRouter() const router = useRouter()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const directusClientWS = createDirectus(apiWsUrl)
.with(staticToken(session?.user?.accessToken))
.with(realtime())
const handleSignout = () => { const handleSignout = () => {
setIsOpen(false) setIsOpen(false)
signOut() signOut()
} }
async function subscribe() {
const {subscription} = await directusClientWS.subscribe('directus_versions', {
event: 'create',
query: {
fields: ['*'],
filter: {
collection: {
_eq: 'titres'
}
}
}
})
for await (const item of subscription) {
console.log('New version created:', item)
}
}
useEffect(() => { useEffect(() => {
let cleanup = () => {} let cleanup = () => {}
if (session) { if (disableWebSocket) {
return () => cleanup()
}
if (session?.user?.accessToken) {
(async () => { (async () => {
try { try {
await directusClientWS.connect() console.log('Creating WebSocket client with token...')
directusClientWS.onWebSocket('open', () => { // Create client with static token
console.log({event: 'onopen'}) const client = createDirectus(apiUrl)
subscribe() .with(staticToken(session.user.accessToken))
.with(realtime())
console.log('Connecting to WebSocket...')
await client.connect()
client.onWebSocket('open', () => {
console.log({event: 'onopen', message: 'WebSocket connection opened'})
}) })
directusClientWS.onWebSocket('message', message => { client.onWebSocket('message', message => {
console.log({event: 'onmessage', message})
// Once authenticated, subscribe
if (message.type === 'auth' && message.status === 'ok') { if (message.type === 'auth' && message.status === 'ok') {
console.log({event: 'onmessage', message}) console.log('WebSocket authenticated successfully!')
// Subscribe to version changes
const startSubscription = async () => {
const {subscription} = await client.subscribe('directus_versions', {
event: 'create',
query: {
fields: ['*'],
filter: {
collection: {
_eq: 'titres'
}
}
}
})
for await (const item of subscription) {
console.log('New version created:', item)
}
}
startSubscription().catch(error => console.error('Subscription error:', error))
} }
}) })
directusClientWS.onWebSocket('close', () => { client.onWebSocket('close', () => {
console.log({event: 'onclose'}) console.log({event: 'onclose', message: 'WebSocket connection closed'})
}) })
directusClientWS.onWebSocket('error', error => { client.onWebSocket('error', error => {
console.log({event: 'onerror', error}) console.log({event: 'onerror', error})
}) })
cleanup = () => { cleanup = () => {
directusClientWS.disconnect() console.log('Disconnecting WebSocket...')
client.disconnect()
} }
} catch (error) { } catch (error) {
console.error('WebSocket connection error:', error) console.error('WebSocket connection error:', error)
@@ -96,7 +111,7 @@ export default function Sign({session, navButton}) {
} }
return () => cleanup() return () => cleanup()
}, [session]) // eslint-disable-line react-hooks/exhaustive-deps }, [session?.user?.accessToken])
return ( return (
<> <>
@@ -104,12 +119,12 @@ export default function Sign({session, navButton}) {
{session ? ( {session ? (
<Stack direction='row' spacing={2}> <Stack direction='row' spacing={2}>
<LightTooltip title='Se déconnecter' placement='right'> <LightTooltip title='Se déconnecter' placement='right'>
<Fab size='large' color='error' onClick={() => setIsOpen(true)}> <Fab size='large' color='error' aria-label='Se déconnecter' onClick={() => setIsOpen(true)}>
<LogoutIcon fontSize='large' /> <LogoutIcon fontSize='large' />
</Fab> </Fab>
</LightTooltip> </LightTooltip>
<LightTooltip title={navButton.title} placement='right'> <LightTooltip title={navButton.title} placement='right'>
<Fab sx={{mr: 3}} size='large' color={navButton.color} onClick={() => router.push(navButton.path)}> <Fab sx={{mr: 3}} size='large' color={navButton.color} aria-label={navButton.title} onClick={() => router.push(navButton.path)}>
{navButton.icon} {navButton.icon}
</Fab> </Fab>
</LightTooltip> </LightTooltip>
@@ -117,12 +132,12 @@ export default function Sign({session, navButton}) {
) : ( ) : (
<Stack direction='row' spacing={2}> <Stack direction='row' spacing={2}>
<LightTooltip title='Se connecter' placement='left'> <LightTooltip title='Se connecter' placement='left'>
<Fab size='large' color='success' onClick={() => router.push('/login')}> <Fab size='large' color='success' aria-label='Se connecter' onClick={() => router.push('/login')}>
<LoginIcon fontSize='large' /> <LoginIcon fontSize='large' />
</Fab> </Fab>
</LightTooltip> </LightTooltip>
<LightTooltip title='Senregistrer' placement='right'> <LightTooltip title="S'enregistrer" placement='right'>
<Fab size='large' color='success' onClick={() => router.push('/register')}> <Fab size='large' color='success' aria-label="S'enregistrer" onClick={() => router.push('/register')}>
<PersonAddIcon fontSize='large' /> <PersonAddIcon fontSize='large' />
</Fab> </Fab>
</LightTooltip> </LightTooltip>
+113 -47
View File
@@ -29,8 +29,11 @@ const renderMarkdownToHtml = async content => {
} }
try { try {
// Dynamic import of markdown parser // Dynamic import of markdown parser and sanitizer
const {marked} = await import('marked') const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better PDF rendering // Configure marked for better PDF rendering
marked.setOptions({ marked.setOptions({
breaks: true, // Convert \n to <br> breaks: true, // Convert \n to <br>
@@ -39,14 +42,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses mangle: false // Don't mangle email addresses
}) })
return marked(content) return DOMPurify.sanitize(marked(content), {
ALLOWED_TAGS: [
'p',
'strong',
'em',
'b',
'i',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'br',
'hr',
'a',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
],
ALLOW_DATA_ATTR: false,
})
} catch (error) { } catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error) console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>') return content.replaceAll('\n', '<br>')
} }
} }
export default function ExportPdfButton({versionData, size = 'medium', variant = 'outlined'}) { export default function ExportPdfButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const handleExportPdf = async () => { const handleExportPdf = async () => {
@@ -86,55 +124,55 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
.pdf-content p { margin: 10px 0; } .pdf-content p { margin: 10px 0; }
.pdf-content strong, .pdf-content b { font-weight: bold; } .pdf-content strong, .pdf-content b { font-weight: bold; }
.pdf-content em, .pdf-content i { font-style: italic; } .pdf-content em, .pdf-content i { font-style: italic; }
.pdf-content ul, .pdf-content ol { .pdf-content ul, .pdf-content ol {
margin: 10px 0; margin: 10px 0;
padding-left: 25px; padding-left: 25px;
} }
.pdf-content li { margin: 5px 0; } .pdf-content li { margin: 5px 0; }
.pdf-content blockquote { .pdf-content blockquote {
margin: 15px 0; margin: 15px 0;
padding: 10px 20px; padding: 10px 20px;
border-left: 4px solid #1976d2; border-left: 4px solid #1976d2;
background-color: #f5f5f5; background-color: #f5f5f5;
font-style: italic; font-style: italic;
} }
.pdf-content code { .pdf-content code {
background-color: #f5f5f5; background-color: #f5f5f5;
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 13px; font-size: 13px;
} }
.pdf-content pre { .pdf-content pre {
background-color: #f5f5f5; background-color: #f5f5f5;
padding: 15px; padding: 15px;
border-radius: 5px; border-radius: 5px;
overflow-x: auto; overflow-x: auto;
margin: 15px 0; margin: 15px 0;
} }
.pdf-content pre code { .pdf-content pre code {
background: none; background: none;
padding: 0; padding: 0;
} }
.pdf-content a { color: #1976d2; text-decoration: underline; } .pdf-content a { color: #1976d2; text-decoration: underline; }
.pdf-content hr { .pdf-content hr {
border: none; border: none;
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
margin: 20px 0; margin: 20px 0;
} }
.pdf-content table { .pdf-content table {
border-collapse: collapse; border-collapse: collapse;
margin: 15px 0; margin: 15px 0;
width: 100%; width: 100%;
} }
.pdf-content th, .pdf-content td { .pdf-content th, .pdf-content td {
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 8px; padding: 8px;
text-align: left; text-align: left;
} }
.pdf-content th { .pdf-content th {
background-color: #f5f5f5; background-color: #f5f5f5;
font-weight: bold; font-weight: bold;
} }
` `
document.head.append(styleElement) document.head.append(styleElement)
@@ -142,9 +180,15 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
const authorName = versionData.user_created?.split('-')[0] || 'Système' const authorName = versionData.user_created?.split('-')[0] || 'Système'
const createdAt = new Date(versionData.date_created) const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const voteStatus = createdAt < threeDaysAgo ? 'fermé' : 'ouvert' const isExpired = createdAt < threeDaysAgo
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f' const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
// Vote counts display
const voteTotal = voteCounts ? voteCounts.total : 0
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
const voteTotalSign = voteTotal >= 0 ? '+' : ''
// Render markdown content to HTML // Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu) const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -158,12 +202,28 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
<strong>Auteur :</strong> @${authorName} <strong>Auteur :</strong> @${authorName}
</p> </p>
<p style="margin: 5px 0; color: #666; font-size: 14px;"> <p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')} <strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</p> </p>
<p style="margin: 5px 0; color: #666; font-size: 14px;"> <p style="margin: 5px 0; color: #666; font-size: 14px;">
<strong>Statut du vote :</strong> <strong>Statut du vote :</strong>
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span> <span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
</p> </p>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 8px;">
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
📊 Résultats des votes
</p>
<p style="margin: 5px 0; font-size: 14px;">
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
</p>
<p style="margin: 5px 0; font-size: 14px;">
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
</p>
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
</p>
</div>
` : ''}
</div> </div>
</div> </div>
@@ -177,7 +237,7 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
</div> </div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;"> <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
Exporté depuis Konstitisyon.la le ${formatDate(new Date(), 'PPpp')} Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div> </div>
` `
@@ -251,6 +311,12 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
ExportPdfButton.propTypes = { ExportPdfButton.propTypes = {
versionData: PropTypes.object.isRequired, versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']), size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']) variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
} }
+1 -1
View File
@@ -2,7 +2,7 @@
import {useState, useRef, useEffect} from 'react' import {useState, useRef, useEffect} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Grid from '@mui/material/Grid2' import Grid from '@mui/material/Grid'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import HomeIcon from '@mui/icons-material/Home' import HomeIcon from '@mui/icons-material/Home'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
+87 -9
View File
@@ -1,4 +1,4 @@
import {forwardRef, useRef, useState} from 'react' import {forwardRef, useRef, useState, useEffect} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Table from '@mui/material/Table' import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody' import TableBody from '@mui/material/TableBody'
@@ -24,7 +24,7 @@ import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js' import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js' import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.js' import {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js' import {compareVersion, getVoteCounts} from '@/lib/directus.js'
import {filterVersions, getFilterStats} from '@/lib/version-utils.js' import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
const columns = [ const columns = [
@@ -84,7 +84,9 @@ function rowContent({
setError, setError,
setIsErrorAlertOpen, setIsErrorAlertOpen,
setIsOpenComparison, setIsOpenComparison,
setVersionCompare setVersionCompare,
outdatedStatusMap,
voteCountsMap
}) { }) {
const handleButtonClick = async versionId => { const handleButtonClick = async versionId => {
const version = await compareVersion({ const version = await compareVersion({
@@ -102,6 +104,9 @@ function rowContent({
} }
} }
const isOutdated = outdatedStatusMap[row.id] || false
const voteCounts = voteCountsMap[row.id] || null
return ( return (
<> <>
{columns.map(column => ( {columns.map(column => (
@@ -137,11 +142,15 @@ function rowContent({
/> />
<ExportPdfButton <ExportPdfButton
versionData={row} versionData={row}
isOutdated={isOutdated}
voteCounts={voteCounts}
size='small' size='small'
variant='text' variant='text'
/> />
<PrintButton <PrintButton
versionData={row} versionData={row}
isOutdated={isOutdated}
voteCounts={voteCounts}
size='small' size='small'
variant='text' variant='text'
/> />
@@ -182,6 +191,53 @@ export default function ListVersions({
dateTo: '', dateTo: '',
status: '' status: ''
}) })
const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
const [voteCountsMap, setVoteCountsMap] = useState({})
// Fetch outdated status and vote counts for all versions
useEffect(() => {
async function fetchVersionsData() {
const statusMap = {}
const countsMap = {}
await Promise.all(
data.map(async version => {
try {
const comparisonData = await compareVersion({
accessToken,
userId,
versionId: version.id,
countdownRef,
setError,
setIsErrorAlertOpen
})
if (comparisonData) {
statusMap[version.id] = comparisonData.outdated || false
}
// Fetch vote counts
const counts = await getVoteCounts({
accessToken,
versionId: version.id
})
countsMap[version.id] = counts
} catch (error) {
console.warn(`Failed to fetch data for version ${version.id}:`, error)
statusMap[version.id] = false
countsMap[version.id] = {positive: 0, negative: 0, total: 0}
}
})
)
setOutdatedStatusMap(statusMap)
setVoteCountsMap(countsMap)
}
if (data.length > 0) {
fetchVersionsData()
}
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
// Filter data based on search and filters // Filter data based on search and filters
const filteredData = filterVersions(data, searchTerm, filters) const filteredData = filterVersions(data, searchTerm, filters)
@@ -189,6 +245,19 @@ export default function ListVersions({
const versionData = data.find(({id}) => id === versionCompare?.versionId) const versionData = data.find(({id}) => id === versionCompare?.versionId)
// Function to refresh vote counts for a specific version after voting
const refreshVoteCounts = async versionId => {
try {
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCountsMap(prev => ({...prev, [versionId]: counts}))
} catch (error) {
console.warn(`Failed to refresh vote counts for version ${versionId}:`, error)
}
}
const handleSearchChange = newSearchTerm => { const handleSearchChange = newSearchTerm => {
setSearchTerm(newSearchTerm) setSearchTerm(newSearchTerm)
} }
@@ -208,8 +277,10 @@ export default function ListVersions({
<Box> <Box>
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
flexDirection: {xs: 'column', sm: 'row'},
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: {xs: 'stretch', sm: 'center'},
gap: 1,
mb: 2 mb: 2
}} }}
> >
@@ -230,13 +301,14 @@ export default function ListVersions({
size='small' size='small'
value={viewMode} value={viewMode}
onChange={handleViewModeChange} onChange={handleViewModeChange}
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
> >
<ToggleButton value='table' aria-label='vue tableau'> <ToggleButton value='table' aria-label='vue tableau'>
<ViewListIcon sx={{mr: 1}} /> <ViewListIcon fontSize='small' sx={{mr: 0.5}} />
Table Table
</ToggleButton> </ToggleButton>
<ToggleButton value='timeline' aria-label='vue chronologique'> <ToggleButton value='timeline' aria-label='vue chronologique'>
<TimelineIcon sx={{mr: 1}} /> <TimelineIcon fontSize='small' sx={{mr: 0.5}} />
Timeline Timeline
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
@@ -261,25 +333,31 @@ export default function ListVersions({
components={VirtuosoTableComponents} components={VirtuosoTableComponents}
fixedHeaderContent={fixedHeaderContent} fixedHeaderContent={fixedHeaderContent}
itemContent={(index, row) => rowContent({ itemContent={(index, row) => rowContent({
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap, voteCountsMap
})} })}
/> />
</Paper> </Paper>
) : ( ) : (
<VersionTimeline <VersionTimeline
collection={collection}
data={filteredData} data={filteredData}
accessToken={accessToken} accessToken={accessToken}
userId={userId} userId={userId}
setError={setError} setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen} setIsErrorAlertOpen={setIsErrorAlertOpen}
onVoteSuccess={refreshVoteCounts}
/> />
) )
)} )}
</Box> </Box>
{isOpenComparison && ( {isOpenComparison && (
<VersionDialog versionData={versionData} versionCompare={versionCompare} isOpen={isOpenComparison} setIsOpen={setIsOpenComparison} /> <VersionDialog
versionData={versionData}
versionCompare={versionCompare}
isOpen={isOpenComparison}
setIsOpen={setIsOpenComparison}
onVoteSuccess={refreshVoteCounts}
/>
)} )}
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} /> <SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
</> </>
+75 -9
View File
@@ -31,8 +31,11 @@ const renderMarkdownToHtml = async content => {
} }
try { try {
// Dynamic import of markdown parser // Dynamic import of markdown parser and sanitizer
const {marked} = await import('marked') const [{marked}, {default: DOMPurify}] = await Promise.all([
import('marked'),
import('dompurify')
])
// Configure marked for better print rendering // Configure marked for better print rendering
marked.setOptions({ marked.setOptions({
@@ -42,14 +45,49 @@ const renderMarkdownToHtml = async content => {
mangle: false // Don't mangle email addresses mangle: false // Don't mangle email addresses
}) })
return marked(content) return DOMPurify.sanitize(marked(content), {
ALLOWED_TAGS: [
'p',
'strong',
'em',
'b',
'i',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'code',
'pre',
'br',
'hr',
'a',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
],
ALLOWED_ATTR: [
'href',
'target',
'rel',
],
ALLOW_DATA_ATTR: false,
})
} catch (error) { } catch (error) {
console.warn('Failed to parse markdown, falling back to plain text:', error) console.warn('Failed to parse markdown, falling back to plain text:', error)
return content.replaceAll('\n', '<br>') return content.replaceAll('\n', '<br>')
} }
} }
export default function PrintButton({versionData, size = 'medium', variant = 'outlined'}) { export default function PrintButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
const [isPrinting, setIsPrinting] = useState(false) const [isPrinting, setIsPrinting] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
@@ -61,9 +99,15 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
const authorName = versionData.user_created?.split('-')[0] || 'Système' const authorName = versionData.user_created?.split('-')[0] || 'Système'
const createdAt = new Date(versionData.date_created) const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const voteStatus = createdAt < threeDaysAgo ? 'fermé' : 'ouvert' const isExpired = createdAt < threeDaysAgo
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f' const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
// Vote counts display
const voteTotal = voteCounts ? voteCounts.total : 0
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
const voteTotalSign = voteTotal >= 0 ? '+' : ''
// Render markdown content to HTML // Render markdown content to HTML
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu) const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
@@ -81,7 +125,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${versionData.name} - Konstitisyon.la</title> <title>${versionData.name} - Konstitisyon.nu</title>
<style> <style>
/* Print-optimized styles */ /* Print-optimized styles */
@media print { @media print {
@@ -302,12 +346,28 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
<strong>Auteur :</strong> @${authorName} <strong>Auteur :</strong> @${authorName}
</div> </div>
<div class="metadata"> <div class="metadata">
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')} <strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
</div> </div>
<div class="metadata"> <div class="metadata">
<strong>Statut du vote :</strong> <strong>Statut du vote :</strong>
<span class="vote-status">${voteStatus}</span> <span class="vote-status">${voteStatus}</span>
</div> </div>
${voteCounts ? `
<div style="margin-top: 15px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
📊 Résultats des votes
</p>
<p style="margin: 5px 0; font-size: 14px;">
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
</p>
<p style="margin: 5px 0; font-size: 14px;">
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
</p>
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
</p>
</div>
` : ''}
</div> </div>
<div class="content-section"> <div class="content-section">
@@ -318,7 +378,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
</div> </div>
<div class="footer"> <div class="footer">
Imprimé depuis Konstitisyon.la le ${formatDate(new Date(), 'PPpp')} Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
</div> </div>
</body> </body>
</html> </html>
@@ -390,6 +450,12 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
PrintButton.propTypes = { PrintButton.propTypes = {
versionData: PropTypes.object.isRequired, versionData: PropTypes.object.isRequired,
isOutdated: PropTypes.bool,
voteCounts: PropTypes.shape({
positive: PropTypes.number,
negative: PropTypes.number,
total: PropTypes.number
}),
size: PropTypes.oneOf(['small', 'medium', 'large']), size: PropTypes.oneOf(['small', 'medium', 'large']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']) variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export default function ShareButton({
// Use native share API if available // Use native share API if available
await navigator.share({ await navigator.share({
title: `Version: ${versionName}`, title: `Version: ${versionName}`,
text: 'Découvrez cette version sur Konstitisyon.la', text: 'Découvrez cette version sur Konstitisyon.nu',
url url
}) })
} else if (navigator.clipboard && window.isSecureContext) { } else if (navigator.clipboard && window.isSecureContext) {
+80 -24
View File
@@ -1,21 +1,55 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Grid from '@mui/material/Grid2' import Grid from '@mui/material/Grid'
import Chip from '@mui/material/Chip'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Snackbar from '@mui/material/Snackbar' import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import {useState} from 'react' import ThumbUpIcon from '@mui/icons-material/ThumbUp'
import ThumbDownIcon from '@mui/icons-material/ThumbDown'
import {useState, useEffect} from 'react'
import {useSession} from 'next-auth/react'
import MarkdownRenderer from '../markdown-renderer/index.js' import MarkdownRenderer from '../markdown-renderer/index.js'
import VoteButtons from './vote-buttons.js' import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js' import CopyButton from './copy-button.js'
import {formatDate} from '@/lib/format.js' import {formatDate} from '@/lib/format.js'
import {getVoteCounts} from '@/lib/directus.js'
export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult}) { export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult, onVoteSuccess}) {
const {data: session} = useSession()
const {current, main, outdated} = versionCompare const {current, main, outdated} = versionCompare
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const [voteCounts, setVoteCounts] = useState({positive: 0, negative: 0, total: 0})
useEffect(() => {
async function fetchVoteCounts() {
if (session?.user?.accessToken && versionCompare?.versionId) {
const counts = await getVoteCounts({
accessToken: session.user.accessToken,
versionId: versionCompare.versionId
})
setVoteCounts(counts)
}
}
fetchVoteCounts()
}, [session?.user?.accessToken, versionCompare?.versionId, voteRefreshKey])
const handleVoteResult = async result => {
if (result.success && session?.user?.accessToken && versionCompare?.versionId) {
const counts = await getVoteCounts({
accessToken: session.user.accessToken,
versionId: versionCompare.versionId
})
setVoteCounts(counts)
if (onVoteSuccess) {
onVoteSuccess(versionCompare.versionId)
}
}
const handleVoteResult = result => {
if (onVoteResult) { if (onVoteResult) {
// Use the parent's vote result handler if provided // Use the parent's vote result handler if provided
onVoteResult(result) onVoteResult(result)
@@ -35,7 +69,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
const createdAt = new Date(versionData.date_created) const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo const isExpired = createdAt < threeDaysAgo
const isVoteDisabled = isExpired || outdated
return ( return (
<Box sx={{padding: 3}}> <Box sx={{padding: 3}}>
@@ -106,31 +141,51 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
@{versionData.user_created?.split('-')[0] || 'Système'} @{versionData.user_created?.split('-')[0] || 'Système'}
</Typography> </Typography>
</Box> </Box>
{!outdated && ( <Box sx={{
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}
}} >
> <Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}> {versionData && (
{versionData && ( <Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}> {formatDate(versionData.date_created)}
{formatDate(versionData.date_created)} </Typography>
</Typography> )}
)} <CopyButton
<CopyButton content={current.contenu || ''}
content={current.contenu || ''} label='Copier cette version'
label='Copier cette version' hasSnackbarVisible={false}
hasSnackbarVisible={false} />
/> </Box>
</Box> <Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<VoteButtons <VoteButtons
key={`vote-comparison-${voteRefreshKey}`} key={`vote-comparison-${voteRefreshKey}`}
versionId={versionCompare.versionId} versionId={versionCompare.versionId}
isDisabled={isVoteDisabled} isDisabled={isVoteDisabled}
onVoteResult={handleVoteResult} onVoteResult={handleVoteResult}
/> />
<Chip
icon={<ThumbUpIcon />}
label={voteCounts.positive}
size='small'
color='success'
variant='outlined'
/>
<Chip
icon={<ThumbDownIcon />}
label={voteCounts.negative}
size='small'
color='error'
variant='outlined'
/>
<Chip
label={`Total: ${voteCounts.total >= 0 ? '+' : ''}${voteCounts.total}`}
size='small'
color={voteCounts.total > 0 ? 'success' : (voteCounts.total < 0 ? 'error' : 'primary')}
variant='outlined'
/>
</Box> </Box>
)} </Box>
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
@@ -214,5 +269,6 @@ VersionComparison.propTypes = {
versionId: PropTypes.string versionId: PropTypes.string
}).isRequired, }).isRequired,
voteRefreshKey: PropTypes.number, voteRefreshKey: PropTypes.number,
onVoteResult: PropTypes.func onVoteResult: PropTypes.func,
onVoteSuccess: PropTypes.func
} }
+4 -3
View File
@@ -14,7 +14,7 @@ import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
import {useTheme} from '@mui/material/styles' import {useTheme} from '@mui/material/styles'
import VersionComparison from './version-comparison.js' import VersionComparison from './version-comparison.js'
export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen}) { export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen, onVoteSuccess}) {
const theme = useTheme() const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md')) const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -59,7 +59,7 @@ export default function VersionDialog({versionData, versionCompare, isOpen, setI
</DialogTitle> </DialogTitle>
<DialogContent sx={{minHeight: '60vh'}}> <DialogContent sx={{minHeight: '60vh'}}>
<VersionComparison versionData={versionData} versionCompare={versionCompare} /> <VersionComparison versionData={versionData} versionCompare={versionCompare} onVoteSuccess={onVoteSuccess} />
</DialogContent> </DialogContent>
<DialogActions sx={{px: 3, py: 2}}> <DialogActions sx={{px: 3, py: 2}}>
@@ -84,5 +84,6 @@ VersionDialog.propTypes = {
main: PropTypes.object.isRequired main: PropTypes.object.isRequired
}).isRequired, }).isRequired,
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
setIsOpen: PropTypes.func.isRequired setIsOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
} }
+33 -19
View File
@@ -24,7 +24,7 @@ import CopyButton from './copy-button.js'
import ExportPdfButton from './export-pdf-button.js' import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js' import PrintButton from './print-button.js'
import VersionComparison from './version-comparison.js' import VersionComparison from './version-comparison.js'
import {getVersion, compareVersion} from '@/lib/directus.js' import {getVersion, compareVersion, getVoteCounts} from '@/lib/directus.js'
import {formatDate} from '@/lib/format.js' import {formatDate} from '@/lib/format.js'
export default function VersionPage({session, versionId, viewMode}) { export default function VersionPage({session, versionId, viewMode}) {
@@ -39,6 +39,7 @@ export default function VersionPage({session, versionId, viewMode}) {
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false) const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'}) const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
const [voteRefreshKey, setVoteRefreshKey] = useState(0) const [voteRefreshKey, setVoteRefreshKey] = useState(0)
const [voteCounts, setVoteCounts] = useState(null)
useEffect(() => { useEffect(() => {
async function fetchVersionData() { async function fetchVersionData() {
@@ -54,21 +55,26 @@ export default function VersionPage({session, versionId, viewMode}) {
setVersionData(version) setVersionData(version)
// If in comparison mode, also fetch comparison data // Fetch comparison data (needed for outdated status even if not in comparison mode)
if (viewMode === 'comparison') { const comparison = await compareVersion({
const comparison = await compareVersion({ accessToken,
accessToken, userId,
userId, versionId,
versionId, countdownRef,
countdownRef, setError,
setError, setIsErrorAlertOpen
setIsErrorAlertOpen })
})
if (comparison) { if (comparison) {
setVersionCompare({...comparison, versionId}) setVersionCompare({...comparison, versionId})
}
} }
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
} catch (error) { } catch (error) {
console.error('Failed to fetch version:', error) console.error('Failed to fetch version:', error)
setError('Impossible de charger cette version') setError('Impossible de charger cette version')
@@ -79,13 +85,13 @@ export default function VersionPage({session, versionId, viewMode}) {
} }
fetchVersionData() fetchVersionData()
}, [accessToken, userId, versionId, viewMode]) }, [accessToken, userId, versionId])
const handleBack = () => { const handleBack = () => {
router.push('/dashboard') router.push('/dashboard')
} }
const handleVoteResult = result => { const handleVoteResult = async result => {
setSnackbar({ setSnackbar({
open: true, open: true,
message: result.message, message: result.message,
@@ -93,6 +99,14 @@ export default function VersionPage({session, versionId, viewMode}) {
}) })
// Force refresh of both VoteButtons components by changing the key // Force refresh of both VoteButtons components by changing the key
setVoteRefreshKey(prev => prev + 1) setVoteRefreshKey(prev => prev + 1)
if (result.success) {
const counts = await getVoteCounts({
accessToken,
versionId
})
setVoteCounts(counts)
}
} }
const handleCloseSnackbar = () => { const handleCloseSnackbar = () => {
@@ -107,7 +121,7 @@ export default function VersionPage({session, versionId, viewMode}) {
// Use native share API if available // Use native share API if available
await navigator.share({ await navigator.share({
title: `Version: ${versionData?.name || 'Version'}`, title: `Version: ${versionData?.name || 'Version'}`,
text: 'Découvrez cette version sur Konstitisyon.la', text: 'Découvrez cette version sur Konstitisyon.nu',
url url
}) })
setSnackbar({ setSnackbar({
@@ -224,8 +238,8 @@ export default function VersionPage({session, versionId, viewMode}) {
</Button> </Button>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}> <Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<ExportPdfButton versionData={versionData} size='medium' /> <ExportPdfButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<PrintButton versionData={versionData} size='medium' /> <PrintButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
<Tooltip title='Partager cette version'> <Tooltip title='Partager cette version'>
<IconButton color='primary' onClick={handleShare}> <IconButton color='primary' onClick={handleShare}>
<ShareIcon /> <ShareIcon />
+1
View File
@@ -34,6 +34,7 @@ export default function VersionSearch({onSearchChange, placeholder = 'Rechercher
size='small' size='small'
placeholder={placeholder} placeholder={placeholder}
value={searchValue} value={searchValue}
inputProps={{'aria-label': 'Rechercher dans les versions'}}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position='start'> <InputAdornment position='start'>
+200 -284
View File
@@ -1,110 +1,36 @@
import {useRef, useState} from 'react' import {useRef, useState, useEffect} from 'react'
import {useTheme} from '@mui/material/styles'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card' import IconButton from '@mui/material/IconButton'
import CardContent from '@mui/material/CardContent' import Collapse from '@mui/material/Collapse'
import CardActions from '@mui/material/CardActions'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Avatar from '@mui/material/Avatar'
import Divider from '@mui/material/Divider'
import Timeline from '@mui/lab/Timeline'
import TimelineItem from '@mui/lab/TimelineItem'
import TimelineSeparator from '@mui/lab/TimelineSeparator'
import TimelineConnector from '@mui/lab/TimelineConnector'
import TimelineContent from '@mui/lab/TimelineContent'
import TimelineDot from '@mui/lab/TimelineDot'
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'
import AccessTimeIcon from '@mui/icons-material/AccessTime'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ErrorIcon from '@mui/icons-material/Error'
import EditIcon from '@mui/icons-material/Edit'
import Snackbar from '@mui/material/Snackbar' import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SessionExpired from '../session/session-expired.js' import SessionExpired from '../session/session-expired.js'
import MarkdownRenderer from '../markdown-renderer/index.js'
import VersionDialog from './version-dialog.js' import VersionDialog from './version-dialog.js'
import VoteButtons from './vote-buttons.js' import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js' import CopyButton from './copy-button.js'
import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.js' import {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js' import {compareVersion} from '@/lib/directus.js'
function getVersionStatus(version, index, totalVersions) { function getStatusColor(isOutdated, index) {
// Logic to determine version status based on position and data if (isOutdated) {
return '#D32F2F'
}
if (index === 0) { if (index === 0) {
return 'current' // Most recent return '#1976D2'
} }
if (index === totalVersions - 1) { return '#9E9E9E'
return 'initial' // First version
}
return 'archived' // Intermediate versions
} }
function getStatusConfig(status) { function VersionItem({
switch (status) {
case 'current': {
return {
color: '#1976D2',
bgColor: '#E3F2FD',
icon: <EditIcon />,
label: 'En cours',
chipColor: 'primary'
}
}
case 'published': {
return {
color: '#2E7D32',
bgColor: '#E8F5E9',
icon: <CheckCircleIcon />,
label: 'Publié',
chipColor: 'success'
}
}
case 'archived': {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
case 'outdated': {
return {
color: '#D32F2F',
bgColor: '#F9E8E8',
icon: <ErrorIcon />,
label: 'Obsolète',
chipColor: 'error'
}
}
default: {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
}
}
function VersionCard({
version, version,
index, index,
totalVersions,
accessToken, accessToken,
userId, userId,
countdownRef, countdownRef,
@@ -114,18 +40,39 @@ function VersionCard({
setVersionCompare, setVersionCompare,
onVoteResult onVoteResult
}) { }) {
const theme = useTheme() const [isOutdated, setIsOutdated] = useState(false)
const status = getVersionStatus(version, index, totalVersions) const [expanded, setExpanded] = useState(false)
const statusConfig = getStatusConfig(status) useEffect(() => {
const userDisplayName = version.user_created?.split('-')[0] || 'Système' async function fetchStatus() {
try {
const comparisonData = await compareVersion({
accessToken,
userId,
versionId: version.id,
countdownRef,
setError,
setIsErrorAlertOpen
})
if (comparisonData) {
setIsOutdated(comparisonData.outdated)
}
} catch {
setIsOutdated(false)
}
}
fetchStatus()
}, [version.id, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
const statusColor = getStatusColor(isOutdated, index)
// Check if voting is disabled (after 3 days)
const createdAt = new Date(version.date_created) const createdAt = new Date(version.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
const handleCompareClick = async () => { const handleCompare = async () => {
const comparisonData = await compareVersion({ const comparisonData = await compareVersion({
accessToken, accessToken,
userId, userId,
@@ -141,165 +88,160 @@ function VersionCard({
} }
} }
// Create content preview preserving markdown structure
const createContentPreview = content => {
if (!content) {
return 'Contenu non disponible'
}
// If content is short enough, return as is
if (content.length <= 150) {
return content
}
// Find a good breaking point (end of sentence, paragraph, or word)
const preview = content.slice(0, 150)
const lastSentence = Math.max(preview.lastIndexOf('.'), preview.lastIndexOf('!'), preview.lastIndexOf('?'))
const lastParagraph = preview.lastIndexOf('\n\n')
const lastWord = preview.lastIndexOf(' ')
// Choose the best breaking point
let breakPoint = 150
if (lastSentence > 100) {
breakPoint = lastSentence + 1
} else if (lastParagraph > 80) {
breakPoint = lastParagraph
} else if (lastWord > 100) {
breakPoint = lastWord
}
return content.slice(0, breakPoint) + (content.length > breakPoint ? '...' : '')
}
const contentPreview = createContentPreview(version?.delta?.contenu)
return ( return (
<Card <Box
sx={{ sx={{
borderLeft: `4px solid ${statusConfig.color}`, display: 'flex',
backgroundColor: statusConfig.bgColor, gap: 1.5,
mb: 2, py: 1.5,
transition: 'all 0.2s ease-in-out', borderBottom: '1px solid',
'&:hover': { borderColor: 'divider',
transform: 'translateY(-2px)', '&:last-child': {borderBottom: 'none'}
boxShadow: 3
}
}} }}
> >
<CardContent> {/* Status indicator */}
<Box sx={{ <Box
sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'center',
mb: 2 pt: 0.5
}} }}
>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: statusColor,
flexShrink: 0
}}
/>
<Box
sx={{
width: 2,
flex: 1,
bgcolor: 'divider',
mt: 0.5,
display: index === 0 ? 'none' : 'block'
}}
/>
</Box>
{/* Content */}
<Box sx={{flex: 1, minWidth: 0}}>
{/* Header row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1
}}
> >
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}> <Box sx={{minWidth: 0, flex: 1}}>
<Avatar sx={{bgcolor: statusConfig.color, width: 32, height: 32}}> <Typography
{statusConfig.icon} variant='body2'
</Avatar> sx={{
<Box> fontWeight: 600,
<Typography variant='h6' sx={{fontWeight: 'bold', color: statusConfig.color}}> color: statusColor,
{version.name} overflow: 'hidden',
</Typography> textOverflow: 'ellipsis',
<Typography variant='caption' color='text.secondary'> whiteSpace: 'nowrap'
par @{userDisplayName} }}
</Typography> >
</Box> {version.name}
</Typography>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'dd/MM/yy HH:mm')}
</Typography>
</Box> </Box>
<Box sx={{display: 'flex', gap: 1}}>
<Chip
label={statusConfig.label}
color={statusConfig.chipColor}
size='small'
variant='outlined'
/>
{isVoteDisabled && (
<Chip
label='Vote fermé'
color='error'
size='small'
variant='outlined'
/>
)}
</Box>
</Box>
<Box sx={{mb: 2, '& > div': {fontSize: '0.875rem', fontStyle: 'italic'}}}> {/* Actions */}
<MarkdownRenderer <Box
content={contentPreview} sx={{
color={theme.palette.text.secondary} display: 'flex',
fallbackComponent={({children, ...props}) => ( alignItems: 'center',
<Typography gap: 0.5,
variant='body2' flexShrink: 0
color='text.secondary' }}
sx={{fontStyle: 'italic'}} >
{...props}
>
{children}
</Typography>
)}
/>
</Box>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'PPpp')}
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier le contenu de cette version'
hasSnackbarVisible={false}
/>
<ShareButton
versionId={version.id}
versionName={version.name}
hasSnackbarVisible={false}
/>
<ExportPdfButton
versionData={version}
size='small'
variant='text'
/>
<PrintButton
versionData={version}
size='small'
variant='text'
/>
<VoteButtons <VoteButtons
hasCountsVisible hasCountsVisible
versionId={version.id} versionId={version.id}
isDisabled={isVoteDisabled} isDisabled={isVoteDisabled}
onVoteResult={onVoteResult} onVoteResult={onVoteResult}
/> />
<IconButton size='small' aria-label='Comparer les versions' title='Comparer' onClick={handleCompare}>
<CompareArrowsIcon fontSize='small' />
</IconButton>
<IconButton
size='small'
aria-label={expanded ? 'Réduire les détails' : 'Afficher les détails'}
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
</IconButton>
</Box> </Box>
</Box> </Box>
</CardContent>
<Divider /> {/* Expanded content */}
<CardActions sx={{justifyContent: 'flex-end'}}> <Collapse in={expanded}>
<Button <Box
size='small' sx={{
variant='outlined' mt: 1.5,
color='primary' display: 'flex',
onClick={handleCompareClick} flexDirection: 'column',
> gap: 1
Comparer }}
</Button> >
</CardActions> {/* Preview */}
</Card> {version.delta?.contenu && (
<Typography
variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontStyle: 'italic'
}}
>
{version.delta.contenu}
</Typography>
)}
{/* Actions row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap'
}}
>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier'
hasSnackbarVisible={false}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box>
) )
} }
export default function VersionTimeline({ export default function VersionTimeline({
collection,
data, data,
accessToken, accessToken,
userId, userId,
setError, setError,
setIsErrorAlertOpen setIsErrorAlertOpen,
onVoteSuccess
}) { }) {
const countdownRef = useRef() const countdownRef = useRef()
const [isOpenComparison, setIsOpenComparison] = useState(false) const [isOpenComparison, setIsOpenComparison] = useState(false)
@@ -308,66 +250,36 @@ export default function VersionTimeline({
const versionData = data.find(({id}) => id === versionCompare?.versionId) const versionData = data.find(({id}) => id === versionCompare?.versionId)
const handleVoteResult = result => { const handleVoteResult = (result, versionId) => {
setSnackbar({ setSnackbar({
open: true, open: true,
message: result.message, message: result.message,
severity: result.success ? 'success' : 'error' severity: result.success ? 'success' : 'error'
}) })
}
const handleCloseSnackbar = () => { if (result.success && onVoteSuccess && versionId) {
setSnackbar(prev => ({...prev, open: false})) onVoteSuccess(versionId)
}
} }
return ( return (
<> <>
<Box> <Box sx={{maxWidth: 500, mx: 'auto'}}>
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}> {data.map((version, index) => (
Historique des versions - {collection} <VersionItem
</Typography> key={version.id}
version={version}
<Timeline position='right'> index={index}
{data.map((version, index) => ( accessToken={accessToken}
<TimelineItem key={version.id}> userId={userId}
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}> countdownRef={countdownRef}
<Typography variant='caption' color='text.secondary'> setError={setError}
{formatDate(version.date_created, 'dd/MM/yyyy')} setIsErrorAlertOpen={setIsErrorAlertOpen}
</Typography> setIsOpenComparison={setIsOpenComparison}
<br /> setVersionCompare={setVersionCompare}
<Typography variant='caption' color='text.secondary'> onVoteResult={result => handleVoteResult(result, version.id)}
{formatDate(version.date_created, 'HH:mm')} />
</Typography> ))}
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineDot
color={index === 0 ? 'primary' : 'grey'}
variant={index === 0 ? 'filled' : 'outlined'}
>
{index === 0 ? <EditIcon /> : <AccessTimeIcon />}
</TimelineDot>
{index < data.length - 1 && <TimelineConnector />}
</TimelineSeparator>
<TimelineContent sx={{flex: 1}}>
<VersionCard
version={version}
index={index}
totalVersions={data.length}
accessToken={accessToken}
userId={userId}
countdownRef={countdownRef}
setError={setError}
setIsErrorAlertOpen={setIsErrorAlertOpen}
setIsOpenComparison={setIsOpenComparison}
setVersionCompare={setVersionCompare}
onVoteResult={handleVoteResult}
/>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</Box> </Box>
{isOpenComparison && ( {isOpenComparison && (
@@ -389,9 +301,14 @@ export default function VersionTimeline({
open={snackbar.open} open={snackbar.open}
autoHideDuration={6000} autoHideDuration={6000}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}} anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
onClose={handleCloseSnackbar} onClose={() => setSnackbar(prev => ({...prev, open: false}))}
> >
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}> <Alert
variant='filled'
severity={snackbar.severity}
sx={{width: '100%'}}
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
>
{snackbar.message} {snackbar.message}
</Alert> </Alert>
</Snackbar> </Snackbar>
@@ -400,18 +317,17 @@ export default function VersionTimeline({
} }
VersionTimeline.propTypes = { VersionTimeline.propTypes = {
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
data: PropTypes.array.isRequired, data: PropTypes.array.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
setError: PropTypes.func.isRequired, setError: PropTypes.func.isRequired,
setIsErrorAlertOpen: PropTypes.func.isRequired setIsErrorAlertOpen: PropTypes.func.isRequired,
onVoteSuccess: PropTypes.func
} }
VersionCard.propTypes = { VersionItem.propTypes = {
version: PropTypes.object.isRequired, version: PropTypes.object.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
totalVersions: PropTypes.number.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
countdownRef: PropTypes.object.isRequired, countdownRef: PropTypes.object.isRequired,
+18
View File
@@ -0,0 +1,18 @@
services:
frontend:
build:
context: .
args:
NEXT_PUBLIC_DIRECTUS_API_URL: ${NEXT_PUBLIC_DIRECTUS_API_URL}
NEXT_PUBLIC_DIRECTUS_API_WS_URL: ${NEXT_PUBLIC_DIRECTUS_API_WS_URL}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
# SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN} # décommenter pour uploader les source maps
ports:
- "4000:4000"
env_file: .env
restart: unless-stopped
networks:
- konstitisyon_network
networks:
konstitisyon_network:
external: true
+11
View File
@@ -0,0 +1,11 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config.js')
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config.js')
}
}
export {captureRequestError as onRequestError} from '@sentry/nextjs'
+111
View File
@@ -0,0 +1,111 @@
import {describe, it, expect} from 'vitest'
import {formatKonstitisyon, formatDate, hasRestrictedChar} from '../format.js'
describe('formatKonstitisyon', () => {
it('renvoie un tableau vide si aucun titre', () => {
expect(formatKonstitisyon([], [])).toEqual([])
})
it('regroupe les articles sous leur titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
const articles = [
{id: 10, titre: 1, contenu: 'Art. 1'},
{id: 20, titre: 2, contenu: 'Art. 2'}, // autre titre — exclu
]
expect(formatKonstitisyon(titres, articles)).toEqual([
{titre: 'Titre I', titreId: 1, articles: [{id: 10, titre: 1, contenu: 'Art. 1'}]},
])
})
it('produit un article vide si aucun article pour ce titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
expect(formatKonstitisyon(titres, [])).toEqual([
{titre: 'Titre I', titreId: 1, articles: []},
])
})
it('gère plusieurs titres et plusieurs articles', () => {
const titres = [
{id: 1, contenu: 'Titre I'},
{id: 2, contenu: 'Titre II'},
]
const articles = [
{id: 10, titre: 1},
{id: 11, titre: 1},
{id: 20, titre: 2},
]
const result = formatKonstitisyon(titres, articles)
expect(result).toHaveLength(2)
expect(result[0].articles).toHaveLength(2)
expect(result[1].articles).toHaveLength(1)
})
})
describe('formatDate', () => {
const fixedDate = new Date('2024-03-15T10:30:00Z')
it('renvoie une chaîne non vide par défaut', () => {
const result = formatDate(fixedDate)
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('ne contient pas de timezone par défaut', () => {
const result = formatDate(fixedDate)
expect(result).not.toMatch(/\(.+\)$/)
})
it('ajoute la timezone entre parenthèses quand withTimezone: true', () => {
const result = formatDate(fixedDate, 'PP', {withTimezone: true})
expect(result).toMatch(/\(.+\)$/)
})
it('utilise la locale française (contient un mois en français)', () => {
// date-fns format 'MMMM' en fr → mars / janvier / etc.
const result = formatDate(new Date('2024-01-15'), 'MMMM')
expect(result).toBe('janvier')
})
it('respecte le format personnalisé', () => {
const result = formatDate(new Date('2024-03-15'), 'dd/MM/yyyy')
expect(result).toBe('15/03/2024')
})
})
describe('hasRestrictedChar', () => {
it('détecte le caractère <', () => {
expect(hasRestrictedChar('<script>')).toBe(true)
})
it('détecte le caractère >', () => {
expect(hasRestrictedChar('a > b')).toBe(true)
})
it('détecte le caractère &', () => {
expect(hasRestrictedChar('a & b')).toBe(true)
})
it('détecte le caractère "', () => {
expect(hasRestrictedChar('"texte"')).toBe(true)
})
it('renvoie false pour un texte sans caractères restreints', () => {
expect(hasRestrictedChar('hello world')).toBe(false)
})
it('renvoie false pour une chaîne vide', () => {
expect(hasRestrictedChar('')).toBe(false)
})
it('renvoie false pour des apostrophes droites', () => {
expect(hasRestrictedChar('l\'article')).toBe(false)
})
})
+108
View File
@@ -0,0 +1,108 @@
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
} from 'vitest'
import {createRateLimiter} from '../rate-limit.js'
describe('createRateLimiter', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0) // Temps fixe et prévisible
})
afterEach(() => {
vi.useRealTimers()
})
it('autorise les requêtes dans la limite', () => {
const check = createRateLimiter({windowMs: 60_000, max: 3})
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
})
it('bloque quand la limite est atteinte', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
const result = check('ip1')
expect(result.success).toBe(false)
})
it('renvoie retryAfter en secondes quand bloqué', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
const result = check('ip1') // now = 0, retryAfter = ceil((0 + 60000 - 0) / 1000)
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(60)
})
it('retryAfter décroît avec le temps écoulé', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
vi.advanceTimersByTime(30_000) // 30s plus tard
const result = check('ip1') // retryAfter = ceil((0 + 60000 - 30000) / 1000) = 30
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(30)
})
it('réinitialise le compteur après expiration de la fenêtre', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000) // Fenêtre expirée
expect(check('ip1').success).toBe(true)
})
it('suit les clés indépendamment', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
expect(check('ip2').success).toBe(true) // ip2 non affectée
})
it('réinitialise proprement à la requête suivante après expiration', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000)
// Nouvelle fenêtre : 2 requêtes autorisées à nouveau
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(false)
})
it('purge les entrées expirées lors du cleanup', () => {
const check = createRateLimiter({windowMs: 60_000, max: 5})
// Plusieurs IPs remplissent le store
check('ip1')
check('ip2')
check('ip3')
// Avancer d'une fenêtre complète pour déclencher le cleanup
vi.advanceTimersByTime(61_000)
// ip1 repart de zéro (entrée purgée)
expect(check('ip1').success).toBe(true)
})
})
+147
View File
@@ -0,0 +1,147 @@
import {describe, it, expect} from 'vitest'
import {filterVersions, getFilterStats} from '../version-utils.js'
// Usine à versions pour les tests
const makeVersion = (id, overrides = {}) => ({
id,
name: `Version ${id}`,
date_created: '2024-03-15T10:00:00Z',
user_created: `user-${id}-suffix`,
delta: {contenu: `Contenu de la version ${id}`},
...overrides,
})
describe('filterVersions', () => {
it('renvoie un tableau vide si versions est vide', () => {
expect(filterVersions([], '', {})).toEqual([])
})
it('renvoie un tableau vide si versions est null', () => {
expect(filterVersions(null, '', {})).toEqual([])
})
it('renvoie toutes les versions sans filtre ni recherche', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, '', {})).toHaveLength(2)
})
describe('recherche textuelle (searchTerm)', () => {
it('filtre par contenu', () => {
const versions = [
makeVersion(1, {delta: {contenu: 'liberté égalité fraternité'}}),
makeVersion(2, {delta: {contenu: 'droits fondamentaux'}}),
]
const result = filterVersions(versions, 'liberté', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par nom de version', () => {
const versions = [
makeVersion(1, {name: 'Proposition Dupont'}),
makeVersion(2, {name: 'Version Martin'}),
]
const result = filterVersions(versions, 'dupont', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par auteur (préfixe du user_created)', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, 'alice', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('est insensible à la casse', () => {
const versions = [makeVersion(1, {name: 'Proposition Alpha'})]
expect(filterVersions(versions, 'ALPHA', {})).toHaveLength(1)
})
it('renvoie vide si aucune correspondance', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, 'zzz-inexistant', {})).toHaveLength(0)
})
})
describe('filtre par auteur (filters.author)', () => {
it('ne retient que les versions de l\'auteur indiqué', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, '', {author: 'alice'})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('renvoie vide si aucune version de cet auteur', () => {
const versions = [makeVersion(1, {user_created: 'alice-uuid'})]
expect(filterVersions(versions, '', {author: 'bob'})).toHaveLength(0)
})
})
describe('filtre par plage de dates', () => {
const versions = [
makeVersion(1, {date_created: '2024-01-10T00:00:00Z'}),
makeVersion(2, {date_created: '2024-03-15T00:00:00Z'}),
makeVersion(3, {date_created: '2024-06-20T00:00:00Z'}),
]
it('filtre les versions antérieures à dateFrom', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-03-01'})
expect(result.map(v => v.id)).toEqual([2, 3])
})
it('filtre les versions postérieures à dateTo', () => {
const result = filterVersions(versions, '', {dateTo: '2024-04-01'})
expect(result.map(v => v.id)).toEqual([1, 2])
})
it('combine dateFrom et dateTo', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-02-01', dateTo: '2024-05-01'})
expect(result.map(v => v.id)).toEqual([2])
})
})
})
describe('getFilterStats', () => {
it('calcule les totaux correctement', () => {
const original = [makeVersion(1), makeVersion(2), makeVersion(3)]
const filtered = [makeVersion(1)]
expect(getFilterStats(original, filtered)).toEqual({
total: 3,
filtered: 1,
hidden: 2,
})
})
it('renvoie hidden: 0 quand rien n\'est filtré', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(getFilterStats(versions, versions)).toEqual({
total: 2,
filtered: 2,
hidden: 0,
})
})
it('gère les tableaux vides', () => {
expect(getFilterStats([], [])).toEqual({total: 0, filtered: 0, hidden: 0})
})
})
+42 -3
View File
@@ -141,9 +141,8 @@ export async function listVersions({
return versions return versions
} catch (error) { } catch (error) {
console.log('error', error)
if (error) {
if (error?.errors[0]?.message === 'Token expired.') {
countdownRef.current.startCountdown() countdownRef.current.startCountdown()
} else { } else {
console.log(error?.errors[0]?.message) console.log(error?.errors[0]?.message)
@@ -370,3 +369,43 @@ export async function getUserVote({
throw error throw error
} }
} }
export async function getVoteCounts({
accessToken,
versionId
}) {
try {
const votes = await directusClient.request(
withToken(
accessToken,
readItems('votes', {
filter: {
content_version_id: {_eq: versionId}
},
fields: ['vote']
})
)
)
const counts = {
positive: 0,
negative: 0,
total: 0
}
for (const v of votes) {
if (v.vote === 1) {
counts.positive++
} else if (v.vote === -1) {
counts.negative++
}
}
counts.total = counts.positive - counts.negative
return counts
} catch (error) {
console.error('Error fetching vote counts:', error)
return {positive: 0, negative: 0, total: 0}
}
}
+9 -2
View File
@@ -19,10 +19,17 @@ export function formatKonstitisyon(titres, articles) {
return konstitisyon return konstitisyon
} }
export function formatDate(date, formatStr = 'PP') { export function formatDate(date, formatStr = 'PP', {withTimezone = false} = {}) {
return format(date, formatStr, { const formatted = format(date, formatStr, {
locale: fr locale: fr
}) })
if (withTimezone) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return `${formatted} (${timezone})`
}
return formatted
} }
export function hasRestrictedChar(text) { export function hasRestrictedChar(text) {
+61
View File
@@ -0,0 +1,61 @@
/**
* Fabrique de rate-limiter en mémoire.
*
* Adapté à un déploiement single-process (PM2 sans cluster).
* Pour un déploiement multi-instances, remplacer le Map par un store
* Redis partagé (ex. @upstash/ratelimit).
*
* @param {object} options
* @param {number} options.windowMs - Durée de la fenêtre en millisecondes
* @param {number} options.max - Nombre maximum de requêtes par fenêtre
*/
export function createRateLimiter({windowMs, max}) {
const store = new Map()
let lastCleanup = Date.now()
/**
* Supprime les entrées expirées du store.
* Appelé automatiquement une fois par fenêtre temporelle.
*/
function cleanup(now) {
for (const [storeKey, entry] of store) {
if (now - entry.start >= windowMs) {
store.delete(storeKey)
}
}
lastCleanup = now
}
/**
* Vérifie si la clé (IP:route) dépasse la limite autorisée.
*
* @param {string} key - Identifiant unique (ex. "1.2.3.4:/api/auth/register")
* @returns {{ success: boolean, retryAfter?: number }}
*/
return key => {
const now = Date.now()
if (now - lastCleanup >= windowMs) {
cleanup(now)
}
const entry = store.get(key)
// Première requête ou fenêtre expirée : on repart à zéro
if (!entry || now - entry.start >= windowMs) {
store.set(key, {count: 1, start: now})
return {success: true}
}
// Limite atteinte
if (entry.count >= max) {
const retryAfter = Math.ceil((entry.start + windowMs - now) / 1000)
return {success: false, retryAfter}
}
// Incrément normal
store.set(key, {...entry, count: entry.count + 1})
return {success: true}
}
}
+98
View File
@@ -0,0 +1,98 @@
/** @type {import('next').NextConfig} */
import {withSentryConfig} from '@sentry/nextjs'
// Les URL Directus sont lues à l'exécution — elles s'adaptent à l'environnement
// (dev local ou production) sans rebuild.
const apiUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_URL ?? ''
const wsUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL ?? ''
// Le SDK Directus dérive l'URL WebSocket depuis apiUrl (https→wss, http→ws).
// On l'inclut toujours dans connect-src pour garantir que CSP autorise la connexion,
// même si NEXT_PUBLIC_DIRECTUS_API_WS_URL pointe vers un hôte différent.
const derivedWsUrl = apiUrl
.replace(/^https:\/\//, 'wss://')
.replace(/^http:\/\//, 'ws://')
// Tokens CSP — les guillemets simples font partie de la spec CSP, pas de JS
const SELF = '\'self\''
const NONE = '\'none\''
const UNSAFE_INLINE = '\'unsafe-inline\''
// Content Security Policy
// - unsafe-inline sur script-src : requis par Next.js App Router (hydratation inline)
// - unsafe-inline sur style-src : requis par Emotion/MUI (styles injectés dynamiquement)
// - data: blob: sur img-src : requis par html2canvas (export PDF)
// - frame-ancestors 'none' : anti-clickjacking (complète X-Frame-Options)
const cspDirectives = [
`default-src ${SELF}`,
`script-src ${SELF} ${UNSAFE_INLINE}`,
`style-src ${SELF} ${UNSAFE_INLINE}`,
`connect-src ${SELF} ${apiUrl} ${wsUrl} ${derivedWsUrl}`.trim().replace(/ {2,}/g, ' '),
`img-src ${SELF} data: blob:`,
`font-src ${SELF}`,
`object-src ${NONE}`,
`base-uri ${SELF}`,
`form-action ${SELF}`,
`frame-ancestors ${NONE}`,
]
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: cspDirectives.join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
]
const nextConfig = {
// Active le mode standalone : bundle minimal autonome pour Docker
output: 'standalone',
experimental: {
// Optimiser les imports pour réduire la mémoire
optimizePackageImports: ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
},
// Réduire l'utilisation mémoire
compress: true,
// Désactiver les source maps en dev pour économiser la mémoire
productionBrowserSourceMaps: false,
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
export default withSentryConfig(nextConfig, {
// Organisation et projet Sentry pour l'upload des source maps
// Nécessite SENTRY_AUTH_TOKEN en CI pour activer l'upload
silent: !process.env.CI,
// Ne pas uploader les source maps si le token est absent (dev local)
sourcemaps: {
disable: !process.env.SENTRY_AUTH_TOKEN,
},
// Évite d'exposer les source maps aux utilisateurs finaux
hideSourceMaps: true,
// Tunnel Sentry via notre propre domaine (contourne les adblockers)
// tunnelRoute: '/monitoring',
})
+43 -17
View File
@@ -3,32 +3,40 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "xo" "lint": "xo",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@directus/sdk": "^18.0.1", "@directus/sdk": "^20.3.0",
"@emotion/cache": "^11.13.5", "@emotion/cache": "^11.14.0",
"@emotion/react": "^11.13.5", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.13.5", "@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.1.0", "@fontsource/roboto": "^5.2.9",
"@mui/icons-material": "^6.1.9", "@mui/icons-material": "^7.3.6",
"@mui/lab": "^7.0.0-beta.14", "@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^6.1.9", "@mui/material": "^7.3.6",
"@mui/material-nextjs": "^6.1.9", "@mui/material-nextjs": "^7.3.6",
"@uiw/react-md-editor": "^4.0.8", "@sentry/nextjs": "^10.48.0",
"date-fns": "^3.6.0", "@uiw/react-md-editor": "^4.0.11",
"date-fns": "^4.1.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^3.0.1", "jspdf": "^4.0.0",
"marked": "^16.1.1", "marked": "^17.0.1",
"next": "^16.1.0", "next": "^16.1.0",
"next-auth": "5.0.0-beta.25", "next-auth": "^5.0.0-beta.30",
"node-gyp": "^12.3.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-virtuoso": "^4.10.2", "react-virtuoso": "^4.18.1",
"sharp": "^0.34.5",
"use-debounce": "^10.0.5" "use-debounce": "^10.0.5"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.4",
"eslint-config-xo-nextjs": "^6.0.0", "eslint-config-xo-nextjs": "^6.0.0",
"vitest": "^4.1.4",
"xo": "^0.58.0" "xo": "^0.58.0"
}, },
"xo": { "xo": {
@@ -49,6 +57,24 @@
"n/prefer-global/process": "off", "n/prefer-global/process": "off",
"comma-dangle": "off", "comma-dangle": "off",
"unicorn/prevent-abbreviations": "off" "unicorn/prevent-abbreviations": "off"
} },
"overrides": [
{
"files": "lib/__tests__/**/*.js",
"envs": [
"node",
"es2020"
],
"rules": {
"camelcase": [
"error",
{
"properties": "never"
}
],
"capitalized-comments": "off"
}
}
]
} }
} }
+55 -1
View File
@@ -1 +1,55 @@
export {auth as proxy} from './auth.js' import {NextResponse} from 'next/server'
import {auth} from './auth.js'
import {createRateLimiter} from '@/lib/rate-limit.js'
// 5 inscriptions max par IP toutes les 15 minutes
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
// 10 tentatives de connexion max par IP toutes les 5 minutes
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
const limiters = {
'/api/auth/register': checkRegister,
'/api/auth/callback/credentials': checkSignin,
}
/**
* Extrait l'IP cliente depuis les headers HTTP.
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
*/
function getClientIp(request) {
const realIp = request.headers.get('x-real-ip')
if (realIp) {
return realIp.trim()
}
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) {
return forwarded.split(',')[0].trim()
}
return 'unknown'
}
export async function proxy(request) {
const {pathname} = request.nextUrl
const check = limiters[pathname]
// Rate limiting sur les routes d'authentification critiques
if (check) {
const ip = getClientIp(request)
const result = check(`${ip}:${pathname}`)
if (!result.success) {
return NextResponse.json(
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
{
status: 429,
headers: {'Retry-After': String(result.retryAfter)},
}
)
}
}
return auth(request)
}
+24
View File
@@ -0,0 +1,24 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Taux d'échantillonnage des traces de performance (0 = désactivé, 1 = 100%)
// Valeur basse en production pour limiter le volume
tracesSampleRate: 0.1,
// Capture des replays de session uniquement sur les erreurs
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: 0,
integrations: [
Sentry.replayIntegration({
// Masquer les champs sensibles dans les replays
maskAllText: false,
blockAllMedia: false,
}),
],
// Ne pas afficher les erreurs Sentry dans la console en production
debug: false,
})
+9
View File
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
debug: false,
})
+10
View File
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs'
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Taux d'échantillonnage des traces serveur
tracesSampleRate: 0.1,
debug: false,
})
+18
View File
@@ -0,0 +1,18 @@
# Leçons — Konstitisyon Frontend
## 2026-04-13 — Rate limiting / Premier chantier
### Erreur commise
Implémentation directe sans passer par le mode planification ni créer `tasks/todo.md` avant de coder.
La tâche a été présentée comme terminée après validation XO, sans preuve de fonctionnement réel.
### Règle à retenir
1. **Toujours** créer/mettre à jour `tasks/todo.md` avant de toucher au code pour toute tâche 3+ étapes.
2. **Toujours** prouver le fonctionnement après implémentation : test unitaire, `curl`, ou vérification Node directe — pas seulement le linter.
3. Le linter qui passe ≠ la feature qui fonctionne.
### Ce qui a bien fonctionné
- Lecture complète des fichiers impactés avant d'écrire (middleware, register, options, package.json)
- Zéro dépendance externe ajoutée — solution en-mémoire adaptée à PM2 single-process
- Correction autonome des erreurs XO (curly, arrow-parens, func-names) sans intervention
- Abstraction propre : `createRateLimiter` est réutilisable pour d'autres routes futures
+27
View File
@@ -0,0 +1,27 @@
# Tâches — Konstitisyon Frontend
## Améliorations critiques (P1)
- [x] **Rate limiting**`lib/rate-limit.js` + `middleware.js`
- Routes protégées : `/api/auth/register` (5/15min) et `/api/auth/callback/credentials` (10/5min)
- Logique vérifiée : comptage, blocage 429 + Retry-After, expiration fenêtre ✓
- [x] **CORS whitelist** — restreindre `CORS_ORIGIN=true` dans l'env Directus
- [x] **Sanitisation Markdown** — DOMPurify sur la sortie `marked` dans export-pdf et print-button
## Améliorations hautes (P2)
- [x] **Headers CSP**`next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [x] **Tests extensions Directus** — mocks VersionsService
- [x] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI** — Forgejo Actions / Woodpecker CI (lint + test + build) ⏸ en attente
- [x] **Sentry** — tracking erreurs frontend + API routes
## Améliorations moyennes (P3)
- [x] ISR page d'accueil (`revalidate`)
- [x] Dockerisation frontend (`output: standalone`)
- [x] Audit accessibilité WCAG 2.1
- [ ] Responsive mobile dashboard ⚠️ à vérifier manuellement (DevTools mobile / vrai appareil)
- [x] Lazy loading jsPDF + md-editor (déjà implémenté : dynamic import + next/dynamic)
- [ ] Migration NextAuth v5 stable ⏸ bloquée — v5 stable non publiée (beta.30 = dernière dispo)
+14
View File
@@ -0,0 +1,14 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
// Imports explicites (pas de globals) — cohérent avec le style XO du projet
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
include: ['lib/**/*.js'],
exclude: ['lib/__tests__/**'],
},
},
})
+2653 -1060
View File
File diff suppressed because it is too large Load Diff