Compare commits
37 Commits
21bc604fb8
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
7d75866803
|
|||
|
42fb5f40f9
|
|||
|
60f5c8ae9c
|
|||
|
1109ceb2bb
|
|||
|
d4deaa7716
|
|||
|
43f1f6e9f2
|
|||
|
e75d2e1c53
|
|||
|
c4762c6437
|
|||
|
a25a610d73
|
|||
|
8016c26e32
|
|||
|
d8a771161c
|
|||
|
7b831d5bc4
|
|||
|
170c3c5e90
|
|||
|
dc1f115bd6
|
|||
|
d8a63bc4d8
|
|||
|
22130529f6
|
|||
|
b838f46b2b
|
|||
|
c2f8a4fb19
|
|||
|
a184665ed1
|
|||
|
be45cc1cc0
|
|||
|
5ee2e3707a
|
|||
|
6f214f7468
|
|||
|
8ec761b2c8
|
|||
|
315c71baa4
|
|||
|
d19fbf990b
|
|||
|
760ca0609d
|
|||
|
de81fbfe5c
|
|||
|
1cf621b752
|
|||
|
e7c4343bfc
|
|||
|
2701957af8
|
|||
|
e101f503d2
|
|||
|
47d58680b3
|
|||
|
5679d71b5b
|
|||
|
6134755888
|
|||
|
7c41beb992
|
|||
|
1cedf24a65
|
|||
|
5249dda717
|
@@ -0,0 +1,8 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
tasks/
|
||||
*.md
|
||||
+18
-6
@@ -1,5 +1,11 @@
|
||||
DIRECTUS_API_URL=http://0.0.0.0:8055
|
||||
DIRECTUS_API_WS_URL=ws://0.0.0.0:8055/websocket
|
||||
# URL interne Docker (server-side Next.js → conteneur Directus via le réseau Docker)
|
||||
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_FOOTER_TEXT=organisation ka internationale (oki)
|
||||
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
|
||||
|
||||
# AUTH
|
||||
NEXTAUTH_URL=http://0.0.0.0:3000
|
||||
NEXTAUTH_URL=http://0.0.0.0:4000
|
||||
NEXTAUTH_SECRET=NEXTAUTH_SECRET
|
||||
USER_ROLE=DIRECTUS_USER_ROLE_ID
|
||||
NEXT_PUBLIC_URL=http://0.0.0.0:3000
|
||||
NEXT_PUBLIC_DIRECTUS_API_URL=$DIRECTUS_API_URL
|
||||
NEXT_PUBLIC_DIRECTUS_API_WS_URL=$DIRECTUS_API_WS_URL
|
||||
NEXT_PUBLIC_URL=http://0.0.0.0:4000
|
||||
|
||||
# COMMENTS
|
||||
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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
- Facilitant le débat et la discussion autour des propositions
|
||||
- Assurant la transparence du processus de rédaction
|
||||
@@ -41,7 +41,7 @@ L'objectif de konstitisyon.la est de démocratiser le processus de rédaction co
|
||||
|
||||
### Structure du projet
|
||||
```
|
||||
konstitisyon.la/
|
||||
konstitisyon.nu/
|
||||
├── app/ # Routes et pages Next.js
|
||||
├── components/
|
||||
│ ├── konstitisyon/ # Composants liés à la constitution
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable new-cap */
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import {readMe, withToken} from '@directus/sdk'
|
||||
import {directusClient} from '@/lib/directus.js'
|
||||
@@ -5,9 +6,39 @@ import {directusClient} from '@/lib/directus.js'
|
||||
const apiUrl = process.env.DIRECTUS_API_URL
|
||||
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 = {
|
||||
providers: [
|
||||
CredentialsProvider({ // eslint-disable-line new-cap
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: {},
|
||||
@@ -22,10 +53,6 @@ export const options = {
|
||||
|
||||
const user = await res.json()
|
||||
|
||||
if (!res.ok && user) {
|
||||
throw new Error('E-mail ou mot de passe incorrect')
|
||||
}
|
||||
|
||||
if (res.ok && user) {
|
||||
return user
|
||||
}
|
||||
@@ -40,18 +67,13 @@ export const options = {
|
||||
signIn: '/login'
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({
|
||||
token,
|
||||
user,
|
||||
account
|
||||
}) {
|
||||
async jwt({token, user, account}) {
|
||||
// Connexion initiale : enrichissement du token avec les données Directus
|
||||
if (account && user) {
|
||||
const userData = await directusClient.request(
|
||||
withToken(
|
||||
user.data.access_token,
|
||||
readMe({
|
||||
fields: ['*']
|
||||
})
|
||||
readMe({fields: ['*']})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -59,11 +81,24 @@ export const options = {
|
||||
...token,
|
||||
accessToken: user.data.access_token,
|
||||
refreshToken: user.data.refresh_token,
|
||||
user: userData
|
||||
accessTokenExpires: Date.now() + (user.data.expires ?? 900_000) - REFRESH_MARGIN_MS,
|
||||
user: userData,
|
||||
}
|
||||
}
|
||||
|
||||
// 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}) {
|
||||
session.user.userId = token.user.id
|
||||
@@ -73,6 +108,7 @@ export const options = {
|
||||
session.user.token = token.user.token
|
||||
session.user.email = token.user.email
|
||||
session.user.password = token.user.password
|
||||
session.error = token.error
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@ export default function LoginForm() {
|
||||
})
|
||||
|
||||
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 s’est produite, contactez l’administrateur !')
|
||||
} else {
|
||||
setError(response.error)
|
||||
|
||||
+11
-2
@@ -1,4 +1,5 @@
|
||||
import {createDirectus, rest, readItems} from '@directus/sdk'
|
||||
import {unstable_cache as nextCache} from 'next/cache'
|
||||
import Container from '@mui/material/Container'
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
@@ -19,7 +20,7 @@ const navButton = {
|
||||
icon: <AdminPanelSettingsIcon fontSize='large' />
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
async function fetchConstitution() {
|
||||
if (!apiUrl) {
|
||||
throw new Error('DIRECTUS_API_URL is required')
|
||||
}
|
||||
@@ -34,7 +35,7 @@ async function getData() {
|
||||
_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() {
|
||||
const session = await auth()
|
||||
const {titres, articles} = await getData()
|
||||
|
||||
@@ -3,8 +3,8 @@ import {redirect} from 'next/navigation'
|
||||
import ResetPasswordForm from './form.js'
|
||||
|
||||
export default async function ResetPasswordPage({searchParams}) {
|
||||
console.log('searchParams', searchParams)
|
||||
const {token} = searchParams
|
||||
const params = await searchParams
|
||||
const {token} = params
|
||||
|
||||
if (!token) {
|
||||
redirect('/login')
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function AuthForm({
|
||||
endAdornment={
|
||||
<InputAdornment position='end'>
|
||||
<IconButton
|
||||
aria-label='password visibility'
|
||||
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
size='large'
|
||||
onClick={handleClickShowPassword}
|
||||
onMouseDown={handleMouseDownPassword}
|
||||
@@ -167,7 +167,7 @@ export default function AuthForm({
|
||||
endAdornment={
|
||||
<InputAdornment position='end'>
|
||||
<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'
|
||||
onClick={handleClickShowPasswordVerifiation}
|
||||
onMouseDown={handleMouseDownPasswordVerification}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function HandleCreate({
|
||||
|
||||
useEffect(() => {
|
||||
if (listItems && listItems.length > 0) {
|
||||
setSelectValue(listItems[0].id)
|
||||
setSelectValue(listItems.at(-1).id)
|
||||
}
|
||||
}, [listItems])
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function HandleCreate({
|
||||
collection={collection}
|
||||
listItems={listItems}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
selectValue={selectValue}
|
||||
setSelectValue={setSelectValue}
|
||||
title='Article'
|
||||
dialogText='Écrivez votre article'
|
||||
|
||||
@@ -4,7 +4,7 @@ import InputLabel from '@mui/material/InputLabel'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import NativeSelect from '@mui/material/NativeSelect'
|
||||
|
||||
export default function ListItems({items, selectLabel, setSelectValue}) {
|
||||
export default function ListItems({items, selectLabel, selectValue, setSelectValue}) {
|
||||
const handleChange = event => {
|
||||
setSelectValue(event.target.value)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
|
||||
{selectLabel}
|
||||
</InputLabel>
|
||||
<NativeSelect
|
||||
defaultValue=''
|
||||
value={selectValue}
|
||||
inputProps={{
|
||||
name: 'content',
|
||||
id: 'titre',
|
||||
@@ -35,5 +35,6 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
|
||||
ListItems.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
selectLabel: PropTypes.string.isRequired,
|
||||
selectValue: PropTypes.string.isRequired,
|
||||
setSelectValue: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function FormHandler({
|
||||
listItems,
|
||||
handleFormSubmit,
|
||||
countdownRef,
|
||||
selectValue,
|
||||
setSelectValue,
|
||||
contenu,
|
||||
collection
|
||||
@@ -51,6 +52,7 @@ export default function FormHandler({
|
||||
<ListItems
|
||||
items={listItems}
|
||||
selectLabel='Titre associé *'
|
||||
selectValue={selectValue}
|
||||
setSelectValue={setSelectValue}
|
||||
/>
|
||||
)}
|
||||
@@ -94,6 +96,7 @@ FormHandler.propTypes = {
|
||||
setError: PropTypes.func.isRequired,
|
||||
setIsErrorAlertOpen: PropTypes.func.isRequired,
|
||||
handleFormSubmit: PropTypes.func.isRequired,
|
||||
selectValue: PropTypes.string,
|
||||
setSelectValue: PropTypes.func.isRequired,
|
||||
dialogText: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
|
||||
@@ -116,6 +116,6 @@ export default function Konstitisyon({session, titres, articles}) {
|
||||
|
||||
Konstitisyon.propTypes = {
|
||||
session: PropTypes.object,
|
||||
titres: PropTypes.object.isRequired,
|
||||
articles: PropTypes.object.isRequired
|
||||
titres: PropTypes.array.isRequired,
|
||||
articles: PropTypes.array.isRequired
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
|
||||
import Pagination from '@mui/material/Pagination'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import {readItems, withToken} from '@directus/sdk'
|
||||
import SessionExpired from '../session/session-expired.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}) {
|
||||
const countdownRef = useRef()
|
||||
const [comments, setComments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
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 selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
|
||||
|
||||
useEffect(() => {
|
||||
setComments([])
|
||||
setPage(1)
|
||||
}, [selectedTitre?.id])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchComments() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
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)
|
||||
setIsErrorAlertOpen(true)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +85,12 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={handleClose}>
|
||||
<DialogTitle>Commentaires</DialogTitle>
|
||||
{isLoading ? (
|
||||
<Box sx={{display: 'flex', justifyContent: 'center', p: 4}}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
|
||||
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
|
||||
<React.Fragment key={id}>
|
||||
@@ -104,12 +121,16 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
||||
<Divider component='li' />
|
||||
</React.Fragment>
|
||||
)) : (
|
||||
<Typography textAlign='center'>Aucun commentaire</Typography>
|
||||
<Typography textAlign='center' sx={{p: 2}}>Aucun commentaire</Typography>
|
||||
)}
|
||||
</List>
|
||||
{pageCount > 1 && (
|
||||
<Box sx={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
|
||||
</>
|
||||
|
||||
+48
-33
@@ -15,7 +15,8 @@ import PersonAddIcon from '@mui/icons-material/PersonAdd'
|
||||
import {createDirectus, realtime, staticToken} from '@directus/sdk'
|
||||
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}) => (
|
||||
<Tooltip {...props} classes={{popper: className}} />
|
||||
@@ -32,17 +33,45 @@ export default function Sign({session, navButton}) {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const directusClientWS = createDirectus(apiWsUrl)
|
||||
.with(staticToken(session?.user?.accessToken))
|
||||
.with(realtime())
|
||||
|
||||
const handleSignout = () => {
|
||||
setIsOpen(false)
|
||||
signOut()
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
const {subscription} = await directusClientWS.subscribe('directus_versions', {
|
||||
useEffect(() => {
|
||||
let cleanup = () => {}
|
||||
|
||||
if (disableWebSocket) {
|
||||
return () => cleanup()
|
||||
}
|
||||
|
||||
if (session?.user?.accessToken) {
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Creating WebSocket client with token...')
|
||||
|
||||
// Create client with static token
|
||||
const client = createDirectus(apiUrl)
|
||||
.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'})
|
||||
})
|
||||
|
||||
client.onWebSocket('message', message => {
|
||||
console.log({event: 'onmessage', message})
|
||||
|
||||
// Once authenticated, subscribe
|
||||
if (message.type === 'auth' && message.status === 'ok') {
|
||||
console.log('WebSocket authenticated successfully!')
|
||||
|
||||
// Subscribe to version changes
|
||||
const startSubscription = async () => {
|
||||
const {subscription} = await client.subscribe('directus_versions', {
|
||||
event: 'create',
|
||||
query: {
|
||||
fields: ['*'],
|
||||
@@ -59,35 +88,21 @@ export default function Sign({session, navButton}) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup = () => {}
|
||||
|
||||
if (session) {
|
||||
(async () => {
|
||||
try {
|
||||
await directusClientWS.connect()
|
||||
|
||||
directusClientWS.onWebSocket('open', () => {
|
||||
console.log({event: 'onopen'})
|
||||
subscribe()
|
||||
})
|
||||
|
||||
directusClientWS.onWebSocket('message', message => {
|
||||
if (message.type === 'auth' && message.status === 'ok') {
|
||||
console.log({event: 'onmessage', message})
|
||||
startSubscription().catch(error => console.error('Subscription error:', error))
|
||||
}
|
||||
})
|
||||
|
||||
directusClientWS.onWebSocket('close', () => {
|
||||
console.log({event: 'onclose'})
|
||||
client.onWebSocket('close', () => {
|
||||
console.log({event: 'onclose', message: 'WebSocket connection closed'})
|
||||
})
|
||||
|
||||
directusClientWS.onWebSocket('error', error => {
|
||||
client.onWebSocket('error', error => {
|
||||
console.log({event: 'onerror', error})
|
||||
})
|
||||
|
||||
cleanup = () => {
|
||||
directusClientWS.disconnect()
|
||||
console.log('Disconnecting WebSocket...')
|
||||
client.disconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebSocket connection error:', error)
|
||||
@@ -96,7 +111,7 @@ export default function Sign({session, navButton}) {
|
||||
}
|
||||
|
||||
return () => cleanup()
|
||||
}, [session]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [session?.user?.accessToken])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -104,12 +119,12 @@ export default function Sign({session, navButton}) {
|
||||
{session ? (
|
||||
<Stack direction='row' spacing={2}>
|
||||
<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' />
|
||||
</Fab>
|
||||
</LightTooltip>
|
||||
<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}
|
||||
</Fab>
|
||||
</LightTooltip>
|
||||
@@ -117,12 +132,12 @@ export default function Sign({session, navButton}) {
|
||||
) : (
|
||||
<Stack direction='row' spacing={2}>
|
||||
<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' />
|
||||
</Fab>
|
||||
</LightTooltip>
|
||||
<LightTooltip title='S’enregistrer' placement='right'>
|
||||
<Fab size='large' color='success' onClick={() => router.push('/register')}>
|
||||
<LightTooltip title="S'enregistrer" placement='right'>
|
||||
<Fab size='large' color='success' aria-label="S'enregistrer" onClick={() => router.push('/register')}>
|
||||
<PersonAddIcon fontSize='large' />
|
||||
</Fab>
|
||||
</LightTooltip>
|
||||
|
||||
@@ -29,8 +29,11 @@ const renderMarkdownToHtml = async content => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamic import of markdown parser
|
||||
const {marked} = await import('marked')
|
||||
// Dynamic import of markdown parser and sanitizer
|
||||
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
||||
import('marked'),
|
||||
import('dompurify')
|
||||
])
|
||||
// Configure marked for better PDF rendering
|
||||
marked.setOptions({
|
||||
breaks: true, // Convert \n to <br>
|
||||
@@ -39,14 +42,49 @@ const renderMarkdownToHtml = async content => {
|
||||
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) {
|
||||
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
||||
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 handleExportPdf = async () => {
|
||||
@@ -142,9 +180,15 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
|
||||
const authorName = versionData.user_created?.split('-')[0] || 'Système'
|
||||
const createdAt = new Date(versionData.date_created)
|
||||
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'
|
||||
|
||||
// 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
|
||||
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
||||
|
||||
@@ -158,12 +202,28 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
|
||||
<strong>Auteur :</strong> @${authorName}
|
||||
</p>
|
||||
<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 style="margin: 5px 0; color: #666; font-size: 14px;">
|
||||
<strong>Statut du vote :</strong>
|
||||
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
|
||||
</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>
|
||||
|
||||
@@ -177,7 +237,7 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
|
||||
</div>
|
||||
|
||||
<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>
|
||||
`
|
||||
|
||||
@@ -251,6 +311,12 @@ export default function ExportPdfButton({versionData, size = 'medium', variant =
|
||||
|
||||
ExportPdfButton.propTypes = {
|
||||
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']),
|
||||
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {useState, useRef, useEffect} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Grid from '@mui/material/Grid2'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import HomeIcon from '@mui/icons-material/Home'
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {forwardRef, useRef, useState} from 'react'
|
||||
import {forwardRef, useRef, useState, useEffect} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
@@ -24,7 +24,7 @@ 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 {compareVersion} from '@/lib/directus.js'
|
||||
import {compareVersion, getVoteCounts} from '@/lib/directus.js'
|
||||
import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
|
||||
|
||||
const columns = [
|
||||
@@ -84,7 +84,9 @@ function rowContent({
|
||||
setError,
|
||||
setIsErrorAlertOpen,
|
||||
setIsOpenComparison,
|
||||
setVersionCompare
|
||||
setVersionCompare,
|
||||
outdatedStatusMap,
|
||||
voteCountsMap
|
||||
}) {
|
||||
const handleButtonClick = async versionId => {
|
||||
const version = await compareVersion({
|
||||
@@ -102,6 +104,9 @@ function rowContent({
|
||||
}
|
||||
}
|
||||
|
||||
const isOutdated = outdatedStatusMap[row.id] || false
|
||||
const voteCounts = voteCountsMap[row.id] || null
|
||||
|
||||
return (
|
||||
<>
|
||||
{columns.map(column => (
|
||||
@@ -137,11 +142,15 @@ function rowContent({
|
||||
/>
|
||||
<ExportPdfButton
|
||||
versionData={row}
|
||||
isOutdated={isOutdated}
|
||||
voteCounts={voteCounts}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
<PrintButton
|
||||
versionData={row}
|
||||
isOutdated={isOutdated}
|
||||
voteCounts={voteCounts}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
@@ -182,6 +191,53 @@ export default function ListVersions({
|
||||
dateTo: '',
|
||||
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
|
||||
const filteredData = filterVersions(data, searchTerm, filters)
|
||||
@@ -189,6 +245,19 @@ export default function ListVersions({
|
||||
|
||||
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 => {
|
||||
setSearchTerm(newSearchTerm)
|
||||
}
|
||||
@@ -208,8 +277,10 @@ export default function ListVersions({
|
||||
<Box>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: {xs: 'column', sm: 'row'},
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: {xs: 'stretch', sm: 'center'},
|
||||
gap: 1,
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
@@ -230,13 +301,14 @@ export default function ListVersions({
|
||||
size='small'
|
||||
value={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
|
||||
>
|
||||
<ToggleButton value='table' aria-label='vue tableau'>
|
||||
<ViewListIcon sx={{mr: 1}} />
|
||||
<ViewListIcon fontSize='small' sx={{mr: 0.5}} />
|
||||
Table
|
||||
</ToggleButton>
|
||||
<ToggleButton value='timeline' aria-label='vue chronologique'>
|
||||
<TimelineIcon sx={{mr: 1}} />
|
||||
<TimelineIcon fontSize='small' sx={{mr: 0.5}} />
|
||||
Timeline
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
@@ -261,25 +333,31 @@ export default function ListVersions({
|
||||
components={VirtuosoTableComponents}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
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>
|
||||
) : (
|
||||
<VersionTimeline
|
||||
collection={collection}
|
||||
data={filteredData}
|
||||
accessToken={accessToken}
|
||||
userId={userId}
|
||||
setError={setError}
|
||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||
onVoteSuccess={refreshVoteCounts}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{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} />
|
||||
</>
|
||||
|
||||
@@ -31,8 +31,11 @@ const renderMarkdownToHtml = async content => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamic import of markdown parser
|
||||
const {marked} = await import('marked')
|
||||
// Dynamic import of markdown parser and sanitizer
|
||||
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
||||
import('marked'),
|
||||
import('dompurify')
|
||||
])
|
||||
|
||||
// Configure marked for better print rendering
|
||||
marked.setOptions({
|
||||
@@ -42,14 +45,49 @@ const renderMarkdownToHtml = async content => {
|
||||
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) {
|
||||
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
||||
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 [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 createdAt = new Date(versionData.date_created)
|
||||
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'
|
||||
|
||||
// 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
|
||||
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
||||
|
||||
@@ -81,7 +125,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${versionData.name} - Konstitisyon.la</title>
|
||||
<title>${versionData.name} - Konstitisyon.nu</title>
|
||||
<style>
|
||||
/* Print-optimized styles */
|
||||
@media print {
|
||||
@@ -302,12 +346,28 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
|
||||
<strong>Auteur :</strong> @${authorName}
|
||||
</div>
|
||||
<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 class="metadata">
|
||||
<strong>Statut du vote :</strong>
|
||||
<span class="vote-status">${voteStatus}</span>
|
||||
</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 class="content-section">
|
||||
@@ -318,7 +378,7 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Imprimé depuis Konstitisyon.la le ${formatDate(new Date(), 'PPpp')}
|
||||
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -390,6 +450,12 @@ export default function PrintButton({versionData, size = 'medium', variant = 'ou
|
||||
|
||||
PrintButton.propTypes = {
|
||||
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']),
|
||||
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ShareButton({
|
||||
// Use native share API if available
|
||||
await navigator.share({
|
||||
title: `Version: ${versionName}`,
|
||||
text: 'Découvrez cette version sur Konstitisyon.la',
|
||||
text: 'Découvrez cette version sur Konstitisyon.nu',
|
||||
url
|
||||
})
|
||||
} else if (navigator.clipboard && window.isSecureContext) {
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
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 Snackbar from '@mui/material/Snackbar'
|
||||
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 VoteButtons from './vote-buttons.js'
|
||||
import CopyButton from './copy-button.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 [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) {
|
||||
// Use the parent's vote result handler if provided
|
||||
onVoteResult(result)
|
||||
@@ -35,7 +69,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
|
||||
|
||||
const createdAt = new Date(versionData.date_created)
|
||||
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
||||
const isVoteDisabled = createdAt < threeDaysAgo
|
||||
const isExpired = createdAt < threeDaysAgo
|
||||
const isVoteDisabled = isExpired || outdated
|
||||
|
||||
return (
|
||||
<Box sx={{padding: 3}}>
|
||||
@@ -106,9 +141,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
|
||||
@{versionData.user_created?.split('-')[0] || 'Système'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!outdated && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
@@ -123,14 +157,35 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
|
||||
hasSnackbarVisible={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
<VoteButtons
|
||||
key={`vote-comparison-${voteRefreshKey}`}
|
||||
versionId={versionCompare.versionId}
|
||||
isDisabled={isVoteDisabled}
|
||||
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>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -214,5 +269,6 @@ VersionComparison.propTypes = {
|
||||
versionId: PropTypes.string
|
||||
}).isRequired,
|
||||
voteRefreshKey: PropTypes.number,
|
||||
onVoteResult: PropTypes.func
|
||||
onVoteResult: PropTypes.func,
|
||||
onVoteSuccess: PropTypes.func
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
|
||||
import {useTheme} from '@mui/material/styles'
|
||||
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 fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function VersionDialog({versionData, versionCompare, isOpen, setI
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{minHeight: '60vh'}}>
|
||||
<VersionComparison versionData={versionData} versionCompare={versionCompare} />
|
||||
<VersionComparison versionData={versionData} versionCompare={versionCompare} onVoteSuccess={onVoteSuccess} />
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{px: 3, py: 2}}>
|
||||
@@ -84,5 +84,6 @@ VersionDialog.propTypes = {
|
||||
main: PropTypes.object.isRequired
|
||||
}).isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
setIsOpen: PropTypes.func.isRequired
|
||||
setIsOpen: PropTypes.func.isRequired,
|
||||
onVoteSuccess: PropTypes.func
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import CopyButton from './copy-button.js'
|
||||
import ExportPdfButton from './export-pdf-button.js'
|
||||
import PrintButton from './print-button.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'
|
||||
|
||||
export default function VersionPage({session, versionId, viewMode}) {
|
||||
@@ -39,6 +39,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
||||
const [voteRefreshKey, setVoteRefreshKey] = useState(0)
|
||||
const [voteCounts, setVoteCounts] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchVersionData() {
|
||||
@@ -54,8 +55,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
|
||||
setVersionData(version)
|
||||
|
||||
// If in comparison mode, also fetch comparison data
|
||||
if (viewMode === 'comparison') {
|
||||
// Fetch comparison data (needed for outdated status even if not in comparison mode)
|
||||
const comparison = await compareVersion({
|
||||
accessToken,
|
||||
userId,
|
||||
@@ -68,7 +68,13 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
if (comparison) {
|
||||
setVersionCompare({...comparison, versionId})
|
||||
}
|
||||
}
|
||||
|
||||
const counts = await getVoteCounts({
|
||||
accessToken,
|
||||
versionId
|
||||
})
|
||||
|
||||
setVoteCounts(counts)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version:', error)
|
||||
setError('Impossible de charger cette version')
|
||||
@@ -79,13 +85,13 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
}
|
||||
|
||||
fetchVersionData()
|
||||
}, [accessToken, userId, versionId, viewMode])
|
||||
}, [accessToken, userId, versionId])
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const handleVoteResult = result => {
|
||||
const handleVoteResult = async result => {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: result.message,
|
||||
@@ -93,6 +99,14 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
})
|
||||
// Force refresh of both VoteButtons components by changing the key
|
||||
setVoteRefreshKey(prev => prev + 1)
|
||||
|
||||
if (result.success) {
|
||||
const counts = await getVoteCounts({
|
||||
accessToken,
|
||||
versionId
|
||||
})
|
||||
setVoteCounts(counts)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
@@ -107,7 +121,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
// Use native share API if available
|
||||
await navigator.share({
|
||||
title: `Version: ${versionData?.name || 'Version'}`,
|
||||
text: 'Découvrez cette version sur Konstitisyon.la',
|
||||
text: 'Découvrez cette version sur Konstitisyon.nu',
|
||||
url
|
||||
})
|
||||
setSnackbar({
|
||||
@@ -224,8 +238,8 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
</Button>
|
||||
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
|
||||
<ExportPdfButton versionData={versionData} size='medium' />
|
||||
<PrintButton versionData={versionData} size='medium' />
|
||||
<ExportPdfButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
|
||||
<PrintButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
|
||||
<Tooltip title='Partager cette version'>
|
||||
<IconButton color='primary' onClick={handleShare}>
|
||||
<ShareIcon />
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function VersionSearch({onSearchChange, placeholder = 'Rechercher
|
||||
size='small'
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
inputProps={{'aria-label': 'Rechercher dans les versions'}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
|
||||
@@ -1,110 +1,36 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {useTheme} from '@mui/material/styles'
|
||||
import {useRef, useState, useEffect} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
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 IconButton from '@mui/material/IconButton'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Snackbar from '@mui/material/Snackbar'
|
||||
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 MarkdownRenderer from '../markdown-renderer/index.js'
|
||||
import VersionDialog from './version-dialog.js'
|
||||
import VoteButtons from './vote-buttons.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 {compareVersion} from '@/lib/directus.js'
|
||||
|
||||
function getVersionStatus(version, index, totalVersions) {
|
||||
// Logic to determine version status based on position and data
|
||||
function getStatusColor(isOutdated, index) {
|
||||
if (isOutdated) {
|
||||
return '#D32F2F'
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return 'current' // Most recent
|
||||
return '#1976D2'
|
||||
}
|
||||
|
||||
if (index === totalVersions - 1) {
|
||||
return 'initial' // First version
|
||||
return '#9E9E9E'
|
||||
}
|
||||
|
||||
return 'archived' // Intermediate versions
|
||||
}
|
||||
|
||||
function getStatusConfig(status) {
|
||||
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({
|
||||
function VersionItem({
|
||||
version,
|
||||
index,
|
||||
totalVersions,
|
||||
accessToken,
|
||||
userId,
|
||||
countdownRef,
|
||||
@@ -114,18 +40,39 @@ function VersionCard({
|
||||
setVersionCompare,
|
||||
onVoteResult
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const status = getVersionStatus(version, index, totalVersions)
|
||||
const [isOutdated, setIsOutdated] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const statusConfig = getStatusConfig(status)
|
||||
const userDisplayName = version.user_created?.split('-')[0] || 'Système'
|
||||
useEffect(() => {
|
||||
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 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({
|
||||
accessToken,
|
||||
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 (
|
||||
<Card
|
||||
<Box
|
||||
sx={{
|
||||
borderLeft: `4px solid ${statusConfig.color}`,
|
||||
backgroundColor: statusConfig.bgColor,
|
||||
mb: 2,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 2
|
||||
gap: 1.5,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {borderBottom: 'none'}
|
||||
}}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
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={{minWidth: 0, flex: 1}}>
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: statusColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
<Avatar sx={{bgcolor: statusConfig.color, width: 32, height: 32}}>
|
||||
{statusConfig.icon}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant='h6' sx={{fontWeight: 'bold', color: statusConfig.color}}>
|
||||
{version.name}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
par @{userDisplayName}
|
||||
{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'}}}>
|
||||
<MarkdownRenderer
|
||||
content={contentPreview}
|
||||
color={theme.palette.text.secondary}
|
||||
fallbackComponent={({children, ...props}) => (
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{fontStyle: 'italic'}}
|
||||
{...props}
|
||||
{/* Actions */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{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
|
||||
hasCountsVisible
|
||||
versionId={version.id}
|
||||
isDisabled={isVoteDisabled}
|
||||
onVoteResult={onVoteResult}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<Divider />
|
||||
<CardActions sx={{justifyContent: 'flex-end'}}>
|
||||
<Button
|
||||
<IconButton size='small' aria-label='Comparer les versions' title='Comparer' onClick={handleCompare}>
|
||||
<CompareArrowsIcon fontSize='small' />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size='small'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={handleCompareClick}
|
||||
aria-label={expanded ? 'Réduire les détails' : 'Afficher les détails'}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
Comparer
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Expanded content */}
|
||||
<Collapse in={expanded}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
{/* Preview */}
|
||||
{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({
|
||||
collection,
|
||||
data,
|
||||
accessToken,
|
||||
userId,
|
||||
setError,
|
||||
setIsErrorAlertOpen
|
||||
setIsErrorAlertOpen,
|
||||
onVoteSuccess
|
||||
}) {
|
||||
const countdownRef = useRef()
|
||||
const [isOpenComparison, setIsOpenComparison] = useState(false)
|
||||
@@ -308,53 +250,26 @@ export default function VersionTimeline({
|
||||
|
||||
const versionData = data.find(({id}) => id === versionCompare?.versionId)
|
||||
|
||||
const handleVoteResult = result => {
|
||||
const handleVoteResult = (result, versionId) => {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: result.message,
|
||||
severity: result.success ? 'success' : 'error'
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseSnackbar = () => {
|
||||
setSnackbar(prev => ({...prev, open: false}))
|
||||
if (result.success && onVoteSuccess && versionId) {
|
||||
onVoteSuccess(versionId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}>
|
||||
Historique des versions - {collection}
|
||||
</Typography>
|
||||
|
||||
<Timeline position='right'>
|
||||
<Box sx={{maxWidth: 500, mx: 'auto'}}>
|
||||
{data.map((version, index) => (
|
||||
<TimelineItem key={version.id}>
|
||||
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{formatDate(version.date_created, 'dd/MM/yyyy')}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{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
|
||||
<VersionItem
|
||||
key={version.id}
|
||||
version={version}
|
||||
index={index}
|
||||
totalVersions={data.length}
|
||||
accessToken={accessToken}
|
||||
userId={userId}
|
||||
countdownRef={countdownRef}
|
||||
@@ -362,12 +277,9 @@ export default function VersionTimeline({
|
||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||
setIsOpenComparison={setIsOpenComparison}
|
||||
setVersionCompare={setVersionCompare}
|
||||
onVoteResult={handleVoteResult}
|
||||
onVoteResult={result => handleVoteResult(result, version.id)}
|
||||
/>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</Box>
|
||||
|
||||
{isOpenComparison && (
|
||||
@@ -389,9 +301,14 @@ export default function VersionTimeline({
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
|
||||
onClose={handleCloseSnackbar}
|
||||
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
||||
>
|
||||
<Alert
|
||||
variant='filled'
|
||||
severity={snackbar.severity}
|
||||
sx={{width: '100%'}}
|
||||
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
||||
>
|
||||
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
@@ -400,18 +317,17 @@ export default function VersionTimeline({
|
||||
}
|
||||
|
||||
VersionTimeline.propTypes = {
|
||||
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
|
||||
data: PropTypes.array.isRequired,
|
||||
accessToken: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
setError: PropTypes.func.isRequired,
|
||||
setIsErrorAlertOpen: PropTypes.func.isRequired
|
||||
setIsErrorAlertOpen: PropTypes.func.isRequired,
|
||||
onVoteSuccess: PropTypes.func
|
||||
}
|
||||
|
||||
VersionCard.propTypes = {
|
||||
VersionItem.propTypes = {
|
||||
version: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
totalVersions: PropTypes.number.isRequired,
|
||||
accessToken: PropTypes.string.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
countdownRef: PropTypes.object.isRequired,
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
+41
-2
@@ -141,9 +141,8 @@ export async function listVersions({
|
||||
|
||||
return versions
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
|
||||
if (error?.errors[0]?.message === 'Token expired.') {
|
||||
if (error) {
|
||||
countdownRef.current.startCountdown()
|
||||
} else {
|
||||
console.log(error?.errors[0]?.message)
|
||||
@@ -370,3 +369,43 @@ export async function getUserVote({
|
||||
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
@@ -19,10 +19,17 @@ export function formatKonstitisyon(titres, articles) {
|
||||
return konstitisyon
|
||||
}
|
||||
|
||||
export function formatDate(date, formatStr = 'PP') {
|
||||
return format(date, formatStr, {
|
||||
export function formatDate(date, formatStr = 'PP', {withTimezone = false} = {}) {
|
||||
const formatted = format(date, formatStr, {
|
||||
locale: fr
|
||||
})
|
||||
|
||||
if (withTimezone) {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
return `${formatted} (${timezone})`
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function hasRestrictedChar(text) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
+42
-16
@@ -3,32 +3,40 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "xo"
|
||||
"lint": "xo",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^18.0.1",
|
||||
"@emotion/cache": "^11.13.5",
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@emotion/styled": "^11.13.5",
|
||||
"@fontsource/roboto": "^5.1.0",
|
||||
"@mui/icons-material": "^6.1.9",
|
||||
"@mui/lab": "^7.0.0-beta.14",
|
||||
"@mui/material": "^6.1.9",
|
||||
"@mui/material-nextjs": "^6.1.9",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"@directus/sdk": "^20.3.0",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/roboto": "^5.2.9",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/lab": "^7.0.1-beta.20",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/material-nextjs": "^7.3.6",
|
||||
"@sentry/nextjs": "^10.48.0",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"date-fns": "^4.1.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.1",
|
||||
"marked": "^16.1.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"marked": "^17.0.1",
|
||||
"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-dom": "^19.2.3",
|
||||
"react-virtuoso": "^4.10.2",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"sharp": "^0.34.5",
|
||||
"use-debounce": "^10.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"eslint-config-xo-nextjs": "^6.0.0",
|
||||
"vitest": "^4.1.4",
|
||||
"xo": "^0.58.0"
|
||||
},
|
||||
"xo": {
|
||||
@@ -49,6 +57,24 @@
|
||||
"n/prefer-global/process": "off",
|
||||
"comma-dangle": "off",
|
||||
"unicorn/prevent-abbreviations": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": "lib/__tests__/**/*.js",
|
||||
"envs": [
|
||||
"node",
|
||||
"es2020"
|
||||
],
|
||||
"rules": {
|
||||
"camelcase": [
|
||||
"error",
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"capitalized-comments": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
tracesSampleRate: 0.1,
|
||||
|
||||
debug: false,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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__/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user