Compare commits
16 Commits
master
..
7b831d5bc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
7b831d5bc4
|
|||
|
170c3c5e90
|
|||
|
dc1f115bd6
|
|||
|
d8a63bc4d8
|
|||
|
22130529f6
|
|||
|
b838f46b2b
|
|||
|
c2f8a4fb19
|
|||
|
a184665ed1
|
|||
|
be45cc1cc0
|
|||
|
5ee2e3707a
|
|||
|
6f214f7468
|
|||
|
8ec761b2c8
|
|||
|
315c71baa4
|
|||
|
d19fbf990b
|
|||
|
760ca0609d
|
|||
|
de81fbfe5c
|
@@ -16,3 +16,6 @@ NEXT_PUBLIC_DIRECTUS_API_WS_URL=$DIRECTUS_API_WS_URL
|
||||
|
||||
# COMMENTS
|
||||
COMMENTS_PER_PAGE=5
|
||||
|
||||
# WEBSOCKET
|
||||
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
|
||||
|
||||
+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
|
||||
```
|
||||
+1
-1
@@ -34,7 +34,7 @@ async function getData() {
|
||||
_eq: 'published'
|
||||
}
|
||||
},
|
||||
sort: 'numero'
|
||||
sort: 'date_created'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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,42 +85,52 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={handleClose}>
|
||||
<DialogTitle>Commentaires</DialogTitle>
|
||||
<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}>
|
||||
<ListItem alignItems='flex-start'>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
|
||||
@{user_created.split('-')[0]}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
sx={{display: 'inline'}}
|
||||
component='span'
|
||||
variant='body2'
|
||||
color='text.primary'
|
||||
>
|
||||
{contenu}
|
||||
</Typography>
|
||||
<br />
|
||||
{formatDate(date_created, 'PPPPpp')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{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}>
|
||||
<ListItem alignItems='flex-start'>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
|
||||
@{user_created.split('-')[0]}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography
|
||||
sx={{display: 'inline'}}
|
||||
component='span'
|
||||
variant='body2'
|
||||
color='text.primary'
|
||||
>
|
||||
{contenu}
|
||||
</Typography>
|
||||
<br />
|
||||
{formatDate(date_created, 'PPPPpp')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<Divider component='li' />
|
||||
</React.Fragment>
|
||||
)) : (
|
||||
<Typography textAlign='center'>Aucun commentaire</Typography>
|
||||
)}
|
||||
</List>
|
||||
<Box sx={{display: 'flex', justifyContent: 'center'}}>
|
||||
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
|
||||
</Box>
|
||||
<Divider component='li' />
|
||||
</React.Fragment>
|
||||
)) : (
|
||||
<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} />
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {createDirectus, realtime, staticToken} from '@directus/sdk'
|
||||
import ConfirmationAlert from './confirmation-alert.js'
|
||||
|
||||
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}} />
|
||||
@@ -40,6 +41,10 @@ export default function Sign({session, navButton}) {
|
||||
useEffect(() => {
|
||||
let cleanup = () => {}
|
||||
|
||||
if (disableWebSocket) {
|
||||
return () => cleanup()
|
||||
}
|
||||
|
||||
if (session?.user?.accessToken) {
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -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, isOutdated = false, 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 () => {
|
||||
@@ -146,6 +184,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
||||
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)
|
||||
|
||||
@@ -159,12 +202,28 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -178,7 +237,7 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
||||
</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.nu le ${formatDate(new Date(), 'PPpp')}
|
||||
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -253,6 +312,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
||||
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'])
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
@@ -85,7 +85,8 @@ function rowContent({
|
||||
setIsErrorAlertOpen,
|
||||
setIsOpenComparison,
|
||||
setVersionCompare,
|
||||
outdatedStatusMap
|
||||
outdatedStatusMap,
|
||||
voteCountsMap
|
||||
}) {
|
||||
const handleButtonClick = async versionId => {
|
||||
const version = await compareVersion({
|
||||
@@ -104,6 +105,7 @@ function rowContent({
|
||||
}
|
||||
|
||||
const isOutdated = outdatedStatusMap[row.id] || false
|
||||
const voteCounts = voteCountsMap[row.id] || null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -141,12 +143,14 @@ function rowContent({
|
||||
<ExportPdfButton
|
||||
versionData={row}
|
||||
isOutdated={isOutdated}
|
||||
voteCounts={voteCounts}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
<PrintButton
|
||||
versionData={row}
|
||||
isOutdated={isOutdated}
|
||||
voteCounts={voteCounts}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
@@ -188,11 +192,13 @@ export default function ListVersions({
|
||||
status: ''
|
||||
})
|
||||
const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
|
||||
const [voteCountsMap, setVoteCountsMap] = useState({})
|
||||
|
||||
// Fetch outdated status for all versions
|
||||
// Fetch outdated status and vote counts for all versions
|
||||
useEffect(() => {
|
||||
async function fetchOutdatedStatus() {
|
||||
async function fetchVersionsData() {
|
||||
const statusMap = {}
|
||||
const countsMap = {}
|
||||
|
||||
await Promise.all(
|
||||
data.map(async version => {
|
||||
@@ -209,18 +215,27 @@ export default function ListVersions({
|
||||
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 outdated status for version ${version.id}:`, 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) {
|
||||
fetchOutdatedStatus()
|
||||
fetchVersionsData()
|
||||
}
|
||||
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
||||
|
||||
@@ -230,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)
|
||||
}
|
||||
@@ -249,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
|
||||
}}
|
||||
>
|
||||
@@ -271,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>
|
||||
@@ -302,25 +333,31 @@ export default function ListVersions({
|
||||
components={VirtuosoTableComponents}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={(index, row) => rowContent({
|
||||
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap
|
||||
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, isOutdated = false, 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'})
|
||||
|
||||
@@ -65,6 +103,11 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
||||
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)
|
||||
|
||||
@@ -303,12 +346,28 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
||||
<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>
|
||||
<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">
|
||||
@@ -319,7 +378,7 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp')}
|
||||
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -392,6 +451,11 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
||||
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'])
|
||||
}
|
||||
|
||||
@@ -2,20 +2,54 @@ import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Paper from '@mui/material/Paper'
|
||||
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,31 +141,51 @@ 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
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
{versionData && (
|
||||
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
|
||||
{formatDate(versionData.date_created)}
|
||||
</Typography>
|
||||
)}
|
||||
<CopyButton
|
||||
content={current.contenu || ''}
|
||||
label='Copier cette version'
|
||||
hasSnackbarVisible={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
{versionData && (
|
||||
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
|
||||
{formatDate(versionData.date_created)}
|
||||
</Typography>
|
||||
)}
|
||||
<CopyButton
|
||||
content={current.contenu || ''}
|
||||
label='Copier cette version'
|
||||
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() {
|
||||
@@ -67,6 +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')
|
||||
@@ -83,7 +91,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
const handleVoteResult = result => {
|
||||
const handleVoteResult = async result => {
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: result.message,
|
||||
@@ -91,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 = () => {
|
||||
@@ -222,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 />
|
||||
|
||||
@@ -1,118 +1,36 @@
|
||||
import {useRef, useState, useEffect} from 'react'
|
||||
import {useTheme} from '@mui/material/styles'
|
||||
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, data) {
|
||||
// Logic to determine version status based on position and data
|
||||
// Find which version is the "main" (published) by checking if it matches current content
|
||||
// This would require the current item content to be passed
|
||||
// For now, we assume the most recent is current unless it's been promoted
|
||||
|
||||
// Check if this is the initial version
|
||||
if (index === totalVersions - 1) {
|
||||
return 'initial' // First version
|
||||
function getStatusColor(isOutdated, index) {
|
||||
if (isOutdated) {
|
||||
return '#D32F2F'
|
||||
}
|
||||
|
||||
// If there's a more recent version after this one, this is outdated
|
||||
// unless this IS the main version (would need item content to determine)
|
||||
if (index > 0) {
|
||||
return 'outdated' // Older versions are outdated
|
||||
if (index === 0) {
|
||||
return '#1976D2'
|
||||
}
|
||||
|
||||
// Most recent version is current (being edited/proposed)
|
||||
return 'current'
|
||||
return '#9E9E9E'
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -122,13 +40,11 @@ function VersionCard({
|
||||
setVersionCompare,
|
||||
onVoteResult
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const [versionStatus, setVersionStatus] = useState(null)
|
||||
const [isOutdated, setIsOutdated] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Fetch real status from API
|
||||
useEffect(() => {
|
||||
async function fetchVersionStatus() {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const comparisonData = await compareVersion({
|
||||
accessToken,
|
||||
@@ -140,41 +56,23 @@ function VersionCard({
|
||||
})
|
||||
|
||||
if (comparisonData) {
|
||||
// Store outdated flag for vote disabling
|
||||
setIsOutdated(comparisonData.outdated)
|
||||
|
||||
// Determine status based on API response
|
||||
let status
|
||||
if (comparisonData.outdated) {
|
||||
status = 'outdated'
|
||||
} else if (index === totalVersions - 1) {
|
||||
status = 'initial'
|
||||
} else {
|
||||
status = 'current'
|
||||
}
|
||||
setVersionStatus(status)
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to position-based status on error
|
||||
setVersionStatus(getVersionStatus(version, index, totalVersions, null))
|
||||
} catch {
|
||||
setIsOutdated(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchVersionStatus()
|
||||
}, [version.id, index, totalVersions, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
||||
fetchStatus()
|
||||
}, [version.id, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
||||
|
||||
const status = versionStatus || getVersionStatus(version, index, totalVersions, null)
|
||||
const statusColor = getStatusColor(isOutdated, index)
|
||||
|
||||
const statusConfig = getStatusConfig(status)
|
||||
const userDisplayName = version.user_created?.split('-')[0] || 'Système'
|
||||
|
||||
// Check if voting is disabled (after 3 days OR if outdated)
|
||||
const createdAt = new Date(version.date_created)
|
||||
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
||||
const isExpired = createdAt < threeDaysAgo
|
||||
const isVoteDisabled = isExpired || isOutdated
|
||||
const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
|
||||
|
||||
const handleCompareClick = async () => {
|
||||
const handleCompare = async () => {
|
||||
const comparisonData = await compareVersion({
|
||||
accessToken,
|
||||
userId,
|
||||
@@ -190,167 +88,127 @@ 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
|
||||
}
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:last-child': {borderBottom: 'none'}
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 2
|
||||
}}
|
||||
{/* 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={{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}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{minWidth: 0, flex: 1}}>
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: statusColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{version.name}
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{formatDate(version.date_created, 'dd/MM/yy HH:mm')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{display: 'flex', gap: 1}}>
|
||||
<Chip
|
||||
label={statusConfig.label}
|
||||
color={statusConfig.chipColor}
|
||||
size='small'
|
||||
variant='outlined'
|
||||
/>
|
||||
{isExpired && !isOutdated && (
|
||||
<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}
|
||||
>
|
||||
{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}
|
||||
isOutdated={isOutdated}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
<PrintButton
|
||||
versionData={version}
|
||||
isOutdated={isOutdated}
|
||||
size='small'
|
||||
variant='text'
|
||||
/>
|
||||
{/* Actions */}
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
|
||||
<VoteButtons
|
||||
hasCountsVisible
|
||||
versionId={version.id}
|
||||
isDisabled={isVoteDisabled}
|
||||
onVoteResult={onVoteResult}
|
||||
/>
|
||||
<IconButton size='small' onClick={handleCompare} title='Comparer'>
|
||||
<CompareArrowsIcon fontSize='small' />
|
||||
</IconButton>
|
||||
<IconButton size='small' onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
<Divider />
|
||||
<CardActions sx={{justifyContent: 'flex-end'}}>
|
||||
<Button
|
||||
size='small'
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
onClick={handleCompareClick}
|
||||
>
|
||||
Comparer
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
{/* 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)
|
||||
@@ -359,66 +217,36 @@ 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'>
|
||||
{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
|
||||
version={version}
|
||||
index={index}
|
||||
totalVersions={data.length}
|
||||
accessToken={accessToken}
|
||||
userId={userId}
|
||||
countdownRef={countdownRef}
|
||||
setError={setError}
|
||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||
setIsOpenComparison={setIsOpenComparison}
|
||||
setVersionCompare={setVersionCompare}
|
||||
onVoteResult={handleVoteResult}
|
||||
/>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
<Box sx={{maxWidth: 500, mx: 'auto'}}>
|
||||
{data.map((version, index) => (
|
||||
<VersionItem
|
||||
key={version.id}
|
||||
version={version}
|
||||
index={index}
|
||||
accessToken={accessToken}
|
||||
userId={userId}
|
||||
countdownRef={countdownRef}
|
||||
setError={setError}
|
||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||
setIsOpenComparison={setIsOpenComparison}
|
||||
setVersionCompare={setVersionCompare}
|
||||
onVoteResult={result => handleVoteResult(result, version.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{isOpenComparison && (
|
||||
@@ -440,9 +268,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={handleCloseSnackbar}>
|
||||
<Alert
|
||||
variant='filled'
|
||||
severity={snackbar.severity}
|
||||
sx={{width: '100%'}}
|
||||
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
@@ -451,18 +284,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,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})
|
||||
})
|
||||
})
|
||||
+42
-3
@@ -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,59 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
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 function middleware(request) {
|
||||
const {pathname} = request.nextUrl
|
||||
const check = limiters[pathname]
|
||||
|
||||
if (!check) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const result = check(`${ip}:${pathname}`)
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
|
||||
{
|
||||
status: 429,
|
||||
headers: {'Retry-After': String(result.retryAfter)},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/api/auth/register', '/api/auth/callback/credentials'],
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
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,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// 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 ?? ''
|
||||
|
||||
// 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}`.trim(),
|
||||
`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 = {
|
||||
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 nextConfig
|
||||
Generated
+11944
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -3,7 +3,10 @@
|
||||
"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": "^20.3.0",
|
||||
@@ -21,14 +24,16 @@
|
||||
"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",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"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 +54,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
- [ ] **Tests extensions Directus** — mocks VersionsService
|
||||
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
|
||||
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
|
||||
- [ ] **Sentry** — tracking erreurs frontend + API routes
|
||||
|
||||
## Améliorations moyennes (P3)
|
||||
|
||||
- [ ] ISR page d'accueil (`revalidate`)
|
||||
- [ ] Dockerisation frontend (`output: standalone`)
|
||||
- [ ] Audit accessibilité WCAG 2.1
|
||||
- [ ] Responsive mobile dashboard
|
||||
- [ ] Lazy loading jsPDF + md-editor
|
||||
- [ ] Migration NextAuth v5 stable
|
||||
@@ -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__/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2,18 +2,16 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@auth/core@0.37.2":
|
||||
version "0.37.2"
|
||||
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.37.2.tgz#0db8a94a076846bd88eb7f9273618513e2285cb2"
|
||||
integrity sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==
|
||||
"@auth/core@0.41.0":
|
||||
version "0.41.0"
|
||||
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.41.0.tgz#6a57e18ab0dd0fc2606f9f0f7460a67190966161"
|
||||
integrity sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==
|
||||
dependencies:
|
||||
"@panva/hkdf" "^1.2.1"
|
||||
"@types/cookie" "0.6.0"
|
||||
cookie "0.7.1"
|
||||
jose "^5.9.3"
|
||||
oauth4webapi "^3.0.0"
|
||||
preact "10.11.3"
|
||||
preact-render-to-string "5.2.3"
|
||||
jose "^6.0.6"
|
||||
oauth4webapi "^3.3.0"
|
||||
preact "10.24.3"
|
||||
preact-render-to-string "6.5.11"
|
||||
|
||||
"@babel/code-frame@^7.0.0":
|
||||
version "7.24.2"
|
||||
@@ -633,11 +631,6 @@
|
||||
dependencies:
|
||||
tslib "^2.8.0"
|
||||
|
||||
"@types/cookie@0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
|
||||
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
|
||||
|
||||
"@types/debug@^4.0.0":
|
||||
version "4.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
|
||||
@@ -1303,11 +1296,6 @@ convert-source-map@^1.5.0:
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
|
||||
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
|
||||
|
||||
cookie@0.7.1:
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
|
||||
integrity sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==
|
||||
|
||||
core-js-compat@^3.34.0:
|
||||
version "3.37.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee"
|
||||
@@ -3083,10 +3071,10 @@ jackspeak@^2.3.5:
|
||||
optionalDependencies:
|
||||
"@pkgjs/parseargs" "^0.11.0"
|
||||
|
||||
jose@^5.9.3:
|
||||
version "5.10.0"
|
||||
resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be"
|
||||
integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==
|
||||
jose@^6.0.6:
|
||||
version "6.1.3"
|
||||
resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5"
|
||||
integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==
|
||||
|
||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -3809,12 +3797,12 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
next-auth@5.0.0-beta.25:
|
||||
version "5.0.0-beta.25"
|
||||
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.25.tgz#3a9f9734e1d8fa5ced545360f1afc24862cb92d5"
|
||||
integrity sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==
|
||||
next-auth@^5.0.0-beta.30:
|
||||
version "5.0.0-beta.30"
|
||||
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.30.tgz#945af66d27d2e6defa34a4d96765df67fe4164cc"
|
||||
integrity sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==
|
||||
dependencies:
|
||||
"@auth/core" "0.37.2"
|
||||
"@auth/core" "0.41.0"
|
||||
|
||||
next@^16.1.0:
|
||||
version "16.1.0"
|
||||
@@ -3867,10 +3855,10 @@ nth-check@^2.0.0:
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
oauth4webapi@^3.0.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.6.0.tgz#839a4520c59f82fdc84d129ce308562646aa3fbc"
|
||||
integrity sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==
|
||||
oauth4webapi@^3.3.0:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.8.3.tgz#8a3e36b88a52db5e619907f031bff3770b2ed1a4"
|
||||
integrity sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==
|
||||
|
||||
obj-props@^1.0.0:
|
||||
version "1.4.0"
|
||||
@@ -4185,17 +4173,15 @@ postcss@8.4.31:
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
preact-render-to-string@5.2.3:
|
||||
version "5.2.3"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4"
|
||||
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==
|
||||
dependencies:
|
||||
pretty-format "^3.8.0"
|
||||
preact-render-to-string@6.5.11:
|
||||
version "6.5.11"
|
||||
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz#467e69908a453497bb93d4d1fc35fb749a78e027"
|
||||
integrity sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==
|
||||
|
||||
preact@10.11.3:
|
||||
version "10.11.3"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
|
||||
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
|
||||
preact@10.24.3:
|
||||
version "10.24.3"
|
||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.24.3.tgz#086386bd47071e3b45410ef20844c21e23828f64"
|
||||
integrity sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==
|
||||
|
||||
prelude-ls@^1.2.1:
|
||||
version "1.2.1"
|
||||
@@ -4214,11 +4200,6 @@ prettier@^3.2.5:
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
|
||||
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
|
||||
|
||||
pretty-format@^3.8.0:
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
||||
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
|
||||
|
||||
prop-types@^15.6.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
|
||||
Reference in New Issue
Block a user