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
|
||||||
COMMENTS_PER_PAGE=5
|
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'
|
_eq: 'published'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sort: 'numero'
|
sort: 'date_created'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function HandleCreate({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listItems && listItems.length > 0) {
|
if (listItems && listItems.length > 0) {
|
||||||
setSelectValue(listItems[0].id)
|
setSelectValue(listItems.at(-1).id)
|
||||||
}
|
}
|
||||||
}, [listItems])
|
}, [listItems])
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ export default function HandleCreate({
|
|||||||
collection={collection}
|
collection={collection}
|
||||||
listItems={listItems}
|
listItems={listItems}
|
||||||
handleFormSubmit={handleFormSubmit}
|
handleFormSubmit={handleFormSubmit}
|
||||||
|
selectValue={selectValue}
|
||||||
setSelectValue={setSelectValue}
|
setSelectValue={setSelectValue}
|
||||||
title='Article'
|
title='Article'
|
||||||
dialogText='Écrivez votre article'
|
dialogText='Écrivez votre article'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import InputLabel from '@mui/material/InputLabel'
|
|||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import NativeSelect from '@mui/material/NativeSelect'
|
import NativeSelect from '@mui/material/NativeSelect'
|
||||||
|
|
||||||
export default function ListItems({items, selectLabel, setSelectValue}) {
|
export default function ListItems({items, selectLabel, selectValue, setSelectValue}) {
|
||||||
const handleChange = event => {
|
const handleChange = event => {
|
||||||
setSelectValue(event.target.value)
|
setSelectValue(event.target.value)
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
|
|||||||
{selectLabel}
|
{selectLabel}
|
||||||
</InputLabel>
|
</InputLabel>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
defaultValue=''
|
value={selectValue}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
name: 'content',
|
name: 'content',
|
||||||
id: 'titre',
|
id: 'titre',
|
||||||
@@ -35,5 +35,6 @@ export default function ListItems({items, selectLabel, setSelectValue}) {
|
|||||||
ListItems.propTypes = {
|
ListItems.propTypes = {
|
||||||
items: PropTypes.array.isRequired,
|
items: PropTypes.array.isRequired,
|
||||||
selectLabel: PropTypes.string.isRequired,
|
selectLabel: PropTypes.string.isRequired,
|
||||||
|
selectValue: PropTypes.string.isRequired,
|
||||||
setSelectValue: PropTypes.func.isRequired
|
setSelectValue: PropTypes.func.isRequired
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function FormHandler({
|
|||||||
listItems,
|
listItems,
|
||||||
handleFormSubmit,
|
handleFormSubmit,
|
||||||
countdownRef,
|
countdownRef,
|
||||||
|
selectValue,
|
||||||
setSelectValue,
|
setSelectValue,
|
||||||
contenu,
|
contenu,
|
||||||
collection
|
collection
|
||||||
@@ -51,6 +52,7 @@ export default function FormHandler({
|
|||||||
<ListItems
|
<ListItems
|
||||||
items={listItems}
|
items={listItems}
|
||||||
selectLabel='Titre associé *'
|
selectLabel='Titre associé *'
|
||||||
|
selectValue={selectValue}
|
||||||
setSelectValue={setSelectValue}
|
setSelectValue={setSelectValue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -94,6 +96,7 @@ FormHandler.propTypes = {
|
|||||||
setError: PropTypes.func.isRequired,
|
setError: PropTypes.func.isRequired,
|
||||||
setIsErrorAlertOpen: PropTypes.func.isRequired,
|
setIsErrorAlertOpen: PropTypes.func.isRequired,
|
||||||
handleFormSubmit: PropTypes.func.isRequired,
|
handleFormSubmit: PropTypes.func.isRequired,
|
||||||
|
selectValue: PropTypes.string,
|
||||||
setSelectValue: PropTypes.func.isRequired,
|
setSelectValue: PropTypes.func.isRequired,
|
||||||
dialogText: PropTypes.string.isRequired,
|
dialogText: PropTypes.string.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -116,6 +116,6 @@ export default function Konstitisyon({session, titres, articles}) {
|
|||||||
|
|
||||||
Konstitisyon.propTypes = {
|
Konstitisyon.propTypes = {
|
||||||
session: PropTypes.object,
|
session: PropTypes.object,
|
||||||
titres: PropTypes.object.isRequired,
|
titres: PropTypes.array.isRequired,
|
||||||
articles: PropTypes.object.isRequired
|
articles: PropTypes.array.isRequired
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import Pagination from '@mui/material/Pagination'
|
import Pagination from '@mui/material/Pagination'
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
import {readItems, withToken} from '@directus/sdk'
|
import {readItems, withToken} from '@directus/sdk'
|
||||||
import SessionExpired from '../session/session-expired.js'
|
import SessionExpired from '../session/session-expired.js'
|
||||||
import {directusClient, handleUserStatus} from '@/lib/directus.js'
|
import {directusClient, handleUserStatus} from '@/lib/directus.js'
|
||||||
@@ -20,6 +21,7 @@ const commentsPerPage = process.env.NEXT_PUBLIC_COMMENTS_PER_PAGE || 2
|
|||||||
export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) {
|
export default function ListComments({session, selectedTitre, isOpen, setIsOpen, setError, setIsErrorAlertOpen}) {
|
||||||
const countdownRef = useRef()
|
const countdownRef = useRef()
|
||||||
const [comments, setComments] = useState([])
|
const [comments, setComments] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const pageCount = Math.ceil(comments.length / commentsPerPage)
|
const pageCount = Math.ceil(comments.length / commentsPerPage)
|
||||||
@@ -27,8 +29,15 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
|||||||
const startIndex = (page - 1) * commentsPerPage
|
const startIndex = (page - 1) * commentsPerPage
|
||||||
const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
|
const selectedComments = comments.slice(startIndex, startIndex + commentsPerPage)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComments([])
|
||||||
|
setPage(1)
|
||||||
|
}, [selectedTitre?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchComments() {
|
async function fetchComments() {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleUserStatus(session.user.accessToken, session.user.userId)
|
await handleUserStatus(session.user.accessToken, session.user.userId)
|
||||||
|
|
||||||
@@ -54,6 +63,8 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
|||||||
setError(error?.errors[0]?.message)
|
setError(error?.errors[0]?.message)
|
||||||
setIsErrorAlertOpen(true)
|
setIsErrorAlertOpen(true)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,42 +85,52 @@ export default function ListComments({session, selectedTitre, isOpen, setIsOpen,
|
|||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onClose={handleClose}>
|
<Dialog open={isOpen} onClose={handleClose}>
|
||||||
<DialogTitle>Commentaires</DialogTitle>
|
<DialogTitle>Commentaires</DialogTitle>
|
||||||
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
|
{isLoading ? (
|
||||||
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
|
<Box sx={{display: 'flex', justifyContent: 'center', p: 4}}>
|
||||||
<React.Fragment key={id}>
|
<CircularProgress />
|
||||||
<ListItem alignItems='flex-start'>
|
</Box>
|
||||||
<ListItemText
|
) : (
|
||||||
primary={
|
<>
|
||||||
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
|
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
|
||||||
@{user_created.split('-')[0]}
|
{selectedComments && selectedComments.length > 0 ? selectedComments.map(({id, date_created, contenu, user_created}) => (
|
||||||
</Typography>
|
<React.Fragment key={id}>
|
||||||
}
|
<ListItem alignItems='flex-start'>
|
||||||
secondary={
|
<ListItemText
|
||||||
<>
|
primary={
|
||||||
<Typography
|
<Typography sx={{textDecoration: 'underline'}} component='span' variant='body2'>
|
||||||
sx={{display: 'inline'}}
|
@{user_created.split('-')[0]}
|
||||||
component='span'
|
</Typography>
|
||||||
variant='body2'
|
}
|
||||||
color='text.primary'
|
secondary={
|
||||||
>
|
<>
|
||||||
{contenu}
|
<Typography
|
||||||
</Typography>
|
sx={{display: 'inline'}}
|
||||||
<br />
|
component='span'
|
||||||
{formatDate(date_created, 'PPPPpp')}
|
variant='body2'
|
||||||
</>
|
color='text.primary'
|
||||||
}
|
>
|
||||||
/>
|
{contenu}
|
||||||
</ListItem>
|
</Typography>
|
||||||
|
<br />
|
||||||
|
{formatDate(date_created, 'PPPPpp')}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<Divider component='li' />
|
<Divider component='li' />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)) : (
|
)) : (
|
||||||
<Typography textAlign='center'>Aucun commentaire</Typography>
|
<Typography textAlign='center' sx={{p: 2}}>Aucun commentaire</Typography>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
<Box sx={{display: 'flex', justifyContent: 'center'}}>
|
{pageCount > 1 && (
|
||||||
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
|
<Box sx={{display: 'flex', justifyContent: 'center'}}>
|
||||||
</Box>
|
<Pagination size='small' sx={{marginBlock: 3}} color='success' count={pageCount} page={page} onChange={handleChange} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
|
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {createDirectus, realtime, staticToken} from '@directus/sdk'
|
|||||||
import ConfirmationAlert from './confirmation-alert.js'
|
import ConfirmationAlert from './confirmation-alert.js'
|
||||||
|
|
||||||
const apiUrl = process.env.DIRECTUS_API_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_URL
|
const apiUrl = process.env.DIRECTUS_API_URL || process.env.NEXT_PUBLIC_DIRECTUS_API_URL
|
||||||
|
const disableWebSocket = process.env.NEXT_PUBLIC_DISABLE_WEBSOCKET === 'true'
|
||||||
|
|
||||||
const LightTooltip = styled(({className, ...props}) => (
|
const LightTooltip = styled(({className, ...props}) => (
|
||||||
<Tooltip {...props} classes={{popper: className}} />
|
<Tooltip {...props} classes={{popper: className}} />
|
||||||
@@ -40,6 +41,10 @@ export default function Sign({session, navButton}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cleanup = () => {}
|
let cleanup = () => {}
|
||||||
|
|
||||||
|
if (disableWebSocket) {
|
||||||
|
return () => cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
if (session?.user?.accessToken) {
|
if (session?.user?.accessToken) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ const renderMarkdownToHtml = async content => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import of markdown parser
|
// Dynamic import of markdown parser and sanitizer
|
||||||
const {marked} = await import('marked')
|
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
||||||
|
import('marked'),
|
||||||
|
import('dompurify')
|
||||||
|
])
|
||||||
// Configure marked for better PDF rendering
|
// Configure marked for better PDF rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true, // Convert \n to <br>
|
breaks: true, // Convert \n to <br>
|
||||||
@@ -39,14 +42,49 @@ const renderMarkdownToHtml = async content => {
|
|||||||
mangle: false // Don't mangle email addresses
|
mangle: false // Don't mangle email addresses
|
||||||
})
|
})
|
||||||
|
|
||||||
return marked(content)
|
return DOMPurify.sanitize(marked(content), {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'pre',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'a',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
||||||
return content.replaceAll('\n', '<br>')
|
return content.replaceAll('\n', '<br>')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExportPdfButton({versionData, 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 [isExporting, setIsExporting] = useState(false)
|
||||||
|
|
||||||
const handleExportPdf = async () => {
|
const handleExportPdf = async () => {
|
||||||
@@ -146,6 +184,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
|||||||
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
|
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
|
||||||
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
||||||
|
|
||||||
|
// Vote counts display
|
||||||
|
const voteTotal = voteCounts ? voteCounts.total : 0
|
||||||
|
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
|
||||||
|
const voteTotalSign = voteTotal >= 0 ? '+' : ''
|
||||||
|
|
||||||
// Render markdown content to HTML
|
// Render markdown content to HTML
|
||||||
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
||||||
|
|
||||||
@@ -159,12 +202,28 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
|||||||
<strong>Auteur :</strong> @${authorName}
|
<strong>Auteur :</strong> @${authorName}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
||||||
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')}
|
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
<p style="margin: 5px 0; color: #666; font-size: 14px;">
|
||||||
<strong>Statut du vote :</strong>
|
<strong>Statut du vote :</strong>
|
||||||
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
|
<span style="color: ${voteColor}; font-weight: bold;">${voteStatus}</span>
|
||||||
</p>
|
</p>
|
||||||
|
${voteCounts ? `
|
||||||
|
<div style="margin-top: 15px; padding: 15px; background-color: #f5f5f5; border-radius: 8px;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
|
||||||
|
📊 Résultats des votes
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
|
||||||
|
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,7 +237,7 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
|
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #888; text-align: center;">
|
||||||
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp')}
|
Exporté depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -253,6 +312,11 @@ export default function ExportPdfButton({versionData, isOutdated = false, size =
|
|||||||
ExportPdfButton.propTypes = {
|
ExportPdfButton.propTypes = {
|
||||||
versionData: PropTypes.object.isRequired,
|
versionData: PropTypes.object.isRequired,
|
||||||
isOutdated: PropTypes.bool,
|
isOutdated: PropTypes.bool,
|
||||||
|
voteCounts: PropTypes.shape({
|
||||||
|
positive: PropTypes.number,
|
||||||
|
negative: PropTypes.number,
|
||||||
|
total: PropTypes.number
|
||||||
|
}),
|
||||||
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
||||||
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import ShareButton from './share-button.js'
|
|||||||
import ExportPdfButton from './export-pdf-button.js'
|
import ExportPdfButton from './export-pdf-button.js'
|
||||||
import PrintButton from './print-button.js'
|
import PrintButton from './print-button.js'
|
||||||
import {formatDate} from '@/lib/format.js'
|
import {formatDate} from '@/lib/format.js'
|
||||||
import {compareVersion} from '@/lib/directus.js'
|
import {compareVersion, getVoteCounts} from '@/lib/directus.js'
|
||||||
import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
|
import {filterVersions, getFilterStats} from '@/lib/version-utils.js'
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -85,7 +85,8 @@ function rowContent({
|
|||||||
setIsErrorAlertOpen,
|
setIsErrorAlertOpen,
|
||||||
setIsOpenComparison,
|
setIsOpenComparison,
|
||||||
setVersionCompare,
|
setVersionCompare,
|
||||||
outdatedStatusMap
|
outdatedStatusMap,
|
||||||
|
voteCountsMap
|
||||||
}) {
|
}) {
|
||||||
const handleButtonClick = async versionId => {
|
const handleButtonClick = async versionId => {
|
||||||
const version = await compareVersion({
|
const version = await compareVersion({
|
||||||
@@ -104,6 +105,7 @@ function rowContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOutdated = outdatedStatusMap[row.id] || false
|
const isOutdated = outdatedStatusMap[row.id] || false
|
||||||
|
const voteCounts = voteCountsMap[row.id] || null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -141,12 +143,14 @@ function rowContent({
|
|||||||
<ExportPdfButton
|
<ExportPdfButton
|
||||||
versionData={row}
|
versionData={row}
|
||||||
isOutdated={isOutdated}
|
isOutdated={isOutdated}
|
||||||
|
voteCounts={voteCounts}
|
||||||
size='small'
|
size='small'
|
||||||
variant='text'
|
variant='text'
|
||||||
/>
|
/>
|
||||||
<PrintButton
|
<PrintButton
|
||||||
versionData={row}
|
versionData={row}
|
||||||
isOutdated={isOutdated}
|
isOutdated={isOutdated}
|
||||||
|
voteCounts={voteCounts}
|
||||||
size='small'
|
size='small'
|
||||||
variant='text'
|
variant='text'
|
||||||
/>
|
/>
|
||||||
@@ -188,11 +192,13 @@ export default function ListVersions({
|
|||||||
status: ''
|
status: ''
|
||||||
})
|
})
|
||||||
const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
|
const [outdatedStatusMap, setOutdatedStatusMap] = useState({})
|
||||||
|
const [voteCountsMap, setVoteCountsMap] = useState({})
|
||||||
|
|
||||||
// Fetch outdated status for all versions
|
// Fetch outdated status and vote counts for all versions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchOutdatedStatus() {
|
async function fetchVersionsData() {
|
||||||
const statusMap = {}
|
const statusMap = {}
|
||||||
|
const countsMap = {}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
data.map(async version => {
|
data.map(async version => {
|
||||||
@@ -209,18 +215,27 @@ export default function ListVersions({
|
|||||||
if (comparisonData) {
|
if (comparisonData) {
|
||||||
statusMap[version.id] = comparisonData.outdated || false
|
statusMap[version.id] = comparisonData.outdated || false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch vote counts
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken,
|
||||||
|
versionId: version.id
|
||||||
|
})
|
||||||
|
countsMap[version.id] = counts
|
||||||
} catch (error) {
|
} 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
|
statusMap[version.id] = false
|
||||||
|
countsMap[version.id] = {positive: 0, negative: 0, total: 0}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
setOutdatedStatusMap(statusMap)
|
setOutdatedStatusMap(statusMap)
|
||||||
|
setVoteCountsMap(countsMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
fetchOutdatedStatus()
|
fetchVersionsData()
|
||||||
}
|
}
|
||||||
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
}, [data, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
||||||
|
|
||||||
@@ -230,6 +245,19 @@ export default function ListVersions({
|
|||||||
|
|
||||||
const versionData = data.find(({id}) => id === versionCompare?.versionId)
|
const versionData = data.find(({id}) => id === versionCompare?.versionId)
|
||||||
|
|
||||||
|
// Function to refresh vote counts for a specific version after voting
|
||||||
|
const refreshVoteCounts = async versionId => {
|
||||||
|
try {
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken,
|
||||||
|
versionId
|
||||||
|
})
|
||||||
|
setVoteCountsMap(prev => ({...prev, [versionId]: counts}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to refresh vote counts for version ${versionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearchChange = newSearchTerm => {
|
const handleSearchChange = newSearchTerm => {
|
||||||
setSearchTerm(newSearchTerm)
|
setSearchTerm(newSearchTerm)
|
||||||
}
|
}
|
||||||
@@ -249,8 +277,10 @@ export default function ListVersions({
|
|||||||
<Box>
|
<Box>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: {xs: 'column', sm: 'row'},
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: {xs: 'stretch', sm: 'center'},
|
||||||
|
gap: 1,
|
||||||
mb: 2
|
mb: 2
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -271,13 +301,14 @@ export default function ListVersions({
|
|||||||
size='small'
|
size='small'
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={handleViewModeChange}
|
onChange={handleViewModeChange}
|
||||||
|
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
|
||||||
>
|
>
|
||||||
<ToggleButton value='table' aria-label='vue tableau'>
|
<ToggleButton value='table' aria-label='vue tableau'>
|
||||||
<ViewListIcon sx={{mr: 1}} />
|
<ViewListIcon fontSize='small' sx={{mr: 0.5}} />
|
||||||
Table
|
Table
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value='timeline' aria-label='vue chronologique'>
|
<ToggleButton value='timeline' aria-label='vue chronologique'>
|
||||||
<TimelineIcon sx={{mr: 1}} />
|
<TimelineIcon fontSize='small' sx={{mr: 0.5}} />
|
||||||
Timeline
|
Timeline
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
@@ -302,25 +333,31 @@ export default function ListVersions({
|
|||||||
components={VirtuosoTableComponents}
|
components={VirtuosoTableComponents}
|
||||||
fixedHeaderContent={fixedHeaderContent}
|
fixedHeaderContent={fixedHeaderContent}
|
||||||
itemContent={(index, row) => rowContent({
|
itemContent={(index, row) => rowContent({
|
||||||
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap
|
index, row, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen, setIsOpenComparison, setVersionCompare, outdatedStatusMap, voteCountsMap
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<VersionTimeline
|
<VersionTimeline
|
||||||
collection={collection}
|
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||||
|
onVoteSuccess={refreshVoteCounts}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isOpenComparison && (
|
{isOpenComparison && (
|
||||||
<VersionDialog versionData={versionData} versionCompare={versionCompare} isOpen={isOpenComparison} setIsOpen={setIsOpenComparison} />
|
<VersionDialog
|
||||||
|
versionData={versionData}
|
||||||
|
versionCompare={versionCompare}
|
||||||
|
isOpen={isOpenComparison}
|
||||||
|
setIsOpen={setIsOpenComparison}
|
||||||
|
onVoteSuccess={refreshVoteCounts}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
|
<SessionExpired ref={countdownRef} setError={setError} setIsErrorAlertOpen={setIsErrorAlertOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ const renderMarkdownToHtml = async content => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import of markdown parser
|
// Dynamic import of markdown parser and sanitizer
|
||||||
const {marked} = await import('marked')
|
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
||||||
|
import('marked'),
|
||||||
|
import('dompurify')
|
||||||
|
])
|
||||||
|
|
||||||
// Configure marked for better print rendering
|
// Configure marked for better print rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -42,14 +45,49 @@ const renderMarkdownToHtml = async content => {
|
|||||||
mangle: false // Don't mangle email addresses
|
mangle: false // Don't mangle email addresses
|
||||||
})
|
})
|
||||||
|
|
||||||
return marked(content)
|
return DOMPurify.sanitize(marked(content), {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'pre',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'a',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
||||||
return content.replaceAll('\n', '<br>')
|
return content.replaceAll('\n', '<br>')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PrintButton({versionData, 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 [isPrinting, setIsPrinting] = useState(false)
|
||||||
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
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 voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
|
||||||
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
||||||
|
|
||||||
|
// Vote counts display
|
||||||
|
const voteTotal = voteCounts ? voteCounts.total : 0
|
||||||
|
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
|
||||||
|
const voteTotalSign = voteTotal >= 0 ? '+' : ''
|
||||||
|
|
||||||
// Render markdown content to HTML
|
// Render markdown content to HTML
|
||||||
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
||||||
|
|
||||||
@@ -303,12 +346,28 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
|||||||
<strong>Auteur :</strong> @${authorName}
|
<strong>Auteur :</strong> @${authorName}
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp')}
|
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<strong>Statut du vote :</strong>
|
<strong>Statut du vote :</strong>
|
||||||
<span class="vote-status">${voteStatus}</span>
|
<span class="vote-status">${voteStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${voteCounts ? `
|
||||||
|
<div style="margin-top: 15px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
|
||||||
|
📊 Résultats des votes
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; font-size: 14px;">
|
||||||
|
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
|
||||||
|
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-section">
|
<div class="content-section">
|
||||||
@@ -319,7 +378,7 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp')}
|
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -392,6 +451,11 @@ export default function PrintButton({versionData, isOutdated = false, size = 'me
|
|||||||
PrintButton.propTypes = {
|
PrintButton.propTypes = {
|
||||||
versionData: PropTypes.object.isRequired,
|
versionData: PropTypes.object.isRequired,
|
||||||
isOutdated: PropTypes.bool,
|
isOutdated: PropTypes.bool,
|
||||||
|
voteCounts: PropTypes.shape({
|
||||||
|
positive: PropTypes.number,
|
||||||
|
negative: PropTypes.number,
|
||||||
|
total: PropTypes.number
|
||||||
|
}),
|
||||||
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
||||||
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,54 @@ import Box from '@mui/material/Box'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import Paper from '@mui/material/Paper'
|
import Paper from '@mui/material/Paper'
|
||||||
import Grid from '@mui/material/Grid'
|
import Grid from '@mui/material/Grid'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import Snackbar from '@mui/material/Snackbar'
|
import Snackbar from '@mui/material/Snackbar'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import {useState} from 'react'
|
import ThumbUpIcon from '@mui/icons-material/ThumbUp'
|
||||||
|
import ThumbDownIcon from '@mui/icons-material/ThumbDown'
|
||||||
|
import {useState, useEffect} from 'react'
|
||||||
|
import {useSession} from 'next-auth/react'
|
||||||
import MarkdownRenderer from '../markdown-renderer/index.js'
|
import MarkdownRenderer from '../markdown-renderer/index.js'
|
||||||
import VoteButtons from './vote-buttons.js'
|
import VoteButtons from './vote-buttons.js'
|
||||||
import CopyButton from './copy-button.js'
|
import CopyButton from './copy-button.js'
|
||||||
import {formatDate} from '@/lib/format.js'
|
import {formatDate} from '@/lib/format.js'
|
||||||
|
import {getVoteCounts} from '@/lib/directus.js'
|
||||||
|
|
||||||
export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult}) {
|
export default function VersionComparison({versionData, versionCompare, voteRefreshKey = 0, onVoteResult, onVoteSuccess}) {
|
||||||
|
const {data: session} = useSession()
|
||||||
const {current, main, outdated} = versionCompare
|
const {current, main, outdated} = versionCompare
|
||||||
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
||||||
|
const [voteCounts, setVoteCounts] = useState({positive: 0, negative: 0, total: 0})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchVoteCounts() {
|
||||||
|
if (session?.user?.accessToken && versionCompare?.versionId) {
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken: session.user.accessToken,
|
||||||
|
versionId: versionCompare.versionId
|
||||||
|
})
|
||||||
|
setVoteCounts(counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchVoteCounts()
|
||||||
|
}, [session?.user?.accessToken, versionCompare?.versionId, voteRefreshKey])
|
||||||
|
|
||||||
|
const handleVoteResult = async result => {
|
||||||
|
if (result.success && session?.user?.accessToken && versionCompare?.versionId) {
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken: session.user.accessToken,
|
||||||
|
versionId: versionCompare.versionId
|
||||||
|
})
|
||||||
|
|
||||||
|
setVoteCounts(counts)
|
||||||
|
|
||||||
|
if (onVoteSuccess) {
|
||||||
|
onVoteSuccess(versionCompare.versionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleVoteResult = result => {
|
|
||||||
if (onVoteResult) {
|
if (onVoteResult) {
|
||||||
// Use the parent's vote result handler if provided
|
// Use the parent's vote result handler if provided
|
||||||
onVoteResult(result)
|
onVoteResult(result)
|
||||||
@@ -35,7 +69,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
|
|||||||
|
|
||||||
const createdAt = new Date(versionData.date_created)
|
const createdAt = new Date(versionData.date_created)
|
||||||
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
||||||
const isVoteDisabled = createdAt < threeDaysAgo
|
const isExpired = createdAt < threeDaysAgo
|
||||||
|
const isVoteDisabled = isExpired || outdated
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{padding: 3}}>
|
<Box sx={{padding: 3}}>
|
||||||
@@ -106,31 +141,51 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
|
|||||||
@{versionData.user_created?.split('-')[0] || 'Système'}
|
@{versionData.user_created?.split('-')[0] || 'Système'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!outdated && (
|
<Box sx={{
|
||||||
<Box sx={{
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1, flexWrap: 'wrap', gap: 1
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
{versionData && (
|
||||||
{versionData && (
|
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
|
||||||
<Typography sx={{fontWeight: 'bold'}} color={isVoteDisabled ? 'error' : 'primary'}>
|
{formatDate(versionData.date_created)}
|
||||||
{formatDate(versionData.date_created)}
|
</Typography>
|
||||||
</Typography>
|
)}
|
||||||
)}
|
<CopyButton
|
||||||
<CopyButton
|
content={current.contenu || ''}
|
||||||
content={current.contenu || ''}
|
label='Copier cette version'
|
||||||
label='Copier cette version'
|
hasSnackbarVisible={false}
|
||||||
hasSnackbarVisible={false}
|
/>
|
||||||
/>
|
</Box>
|
||||||
</Box>
|
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||||
<VoteButtons
|
<VoteButtons
|
||||||
key={`vote-comparison-${voteRefreshKey}`}
|
key={`vote-comparison-${voteRefreshKey}`}
|
||||||
versionId={versionCompare.versionId}
|
versionId={versionCompare.versionId}
|
||||||
isDisabled={isVoteDisabled}
|
isDisabled={isVoteDisabled}
|
||||||
onVoteResult={handleVoteResult}
|
onVoteResult={handleVoteResult}
|
||||||
/>
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<ThumbUpIcon />}
|
||||||
|
label={voteCounts.positive}
|
||||||
|
size='small'
|
||||||
|
color='success'
|
||||||
|
variant='outlined'
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
icon={<ThumbDownIcon />}
|
||||||
|
label={voteCounts.negative}
|
||||||
|
size='small'
|
||||||
|
color='error'
|
||||||
|
variant='outlined'
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={`Total: ${voteCounts.total >= 0 ? '+' : ''}${voteCounts.total}`}
|
||||||
|
size='small'
|
||||||
|
color={voteCounts.total > 0 ? 'success' : (voteCounts.total < 0 ? 'error' : 'primary')}
|
||||||
|
variant='outlined'
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -214,5 +269,6 @@ VersionComparison.propTypes = {
|
|||||||
versionId: PropTypes.string
|
versionId: PropTypes.string
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
voteRefreshKey: PropTypes.number,
|
voteRefreshKey: PropTypes.number,
|
||||||
onVoteResult: PropTypes.func
|
onVoteResult: PropTypes.func,
|
||||||
|
onVoteSuccess: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
|
|||||||
import {useTheme} from '@mui/material/styles'
|
import {useTheme} from '@mui/material/styles'
|
||||||
import VersionComparison from './version-comparison.js'
|
import VersionComparison from './version-comparison.js'
|
||||||
|
|
||||||
export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen}) {
|
export default function VersionDialog({versionData, versionCompare, isOpen, setIsOpen, onVoteSuccess}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function VersionDialog({versionData, versionCompare, isOpen, setI
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent sx={{minHeight: '60vh'}}>
|
<DialogContent sx={{minHeight: '60vh'}}>
|
||||||
<VersionComparison versionData={versionData} versionCompare={versionCompare} />
|
<VersionComparison versionData={versionData} versionCompare={versionCompare} onVoteSuccess={onVoteSuccess} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions sx={{px: 3, py: 2}}>
|
<DialogActions sx={{px: 3, py: 2}}>
|
||||||
@@ -84,5 +84,6 @@ VersionDialog.propTypes = {
|
|||||||
main: PropTypes.object.isRequired
|
main: PropTypes.object.isRequired
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
setIsOpen: PropTypes.func.isRequired
|
setIsOpen: PropTypes.func.isRequired,
|
||||||
|
onVoteSuccess: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import CopyButton from './copy-button.js'
|
|||||||
import ExportPdfButton from './export-pdf-button.js'
|
import ExportPdfButton from './export-pdf-button.js'
|
||||||
import PrintButton from './print-button.js'
|
import PrintButton from './print-button.js'
|
||||||
import VersionComparison from './version-comparison.js'
|
import VersionComparison from './version-comparison.js'
|
||||||
import {getVersion, compareVersion} from '@/lib/directus.js'
|
import {getVersion, compareVersion, getVoteCounts} from '@/lib/directus.js'
|
||||||
import {formatDate} from '@/lib/format.js'
|
import {formatDate} from '@/lib/format.js'
|
||||||
|
|
||||||
export default function VersionPage({session, versionId, viewMode}) {
|
export default function VersionPage({session, versionId, viewMode}) {
|
||||||
@@ -39,6 +39,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
|||||||
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
|
const [isErrorAlertOpen, setIsErrorAlertOpen] = useState(false)
|
||||||
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
||||||
const [voteRefreshKey, setVoteRefreshKey] = useState(0)
|
const [voteRefreshKey, setVoteRefreshKey] = useState(0)
|
||||||
|
const [voteCounts, setVoteCounts] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVersionData() {
|
async function fetchVersionData() {
|
||||||
@@ -67,6 +68,13 @@ export default function VersionPage({session, versionId, viewMode}) {
|
|||||||
if (comparison) {
|
if (comparison) {
|
||||||
setVersionCompare({...comparison, versionId})
|
setVersionCompare({...comparison, versionId})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken,
|
||||||
|
versionId
|
||||||
|
})
|
||||||
|
|
||||||
|
setVoteCounts(counts)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch version:', error)
|
console.error('Failed to fetch version:', error)
|
||||||
setError('Impossible de charger cette version')
|
setError('Impossible de charger cette version')
|
||||||
@@ -83,7 +91,7 @@ export default function VersionPage({session, versionId, viewMode}) {
|
|||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVoteResult = result => {
|
const handleVoteResult = async result => {
|
||||||
setSnackbar({
|
setSnackbar({
|
||||||
open: true,
|
open: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
@@ -91,6 +99,14 @@ export default function VersionPage({session, versionId, viewMode}) {
|
|||||||
})
|
})
|
||||||
// Force refresh of both VoteButtons components by changing the key
|
// Force refresh of both VoteButtons components by changing the key
|
||||||
setVoteRefreshKey(prev => prev + 1)
|
setVoteRefreshKey(prev => prev + 1)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const counts = await getVoteCounts({
|
||||||
|
accessToken,
|
||||||
|
versionId
|
||||||
|
})
|
||||||
|
setVoteCounts(counts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseSnackbar = () => {
|
const handleCloseSnackbar = () => {
|
||||||
@@ -222,8 +238,8 @@ export default function VersionPage({session, versionId, viewMode}) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
|
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
|
||||||
<ExportPdfButton versionData={versionData} size='medium' />
|
<ExportPdfButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
|
||||||
<PrintButton versionData={versionData} size='medium' />
|
<PrintButton versionData={versionData} isOutdated={versionCompare?.outdated} voteCounts={voteCounts} size='medium' />
|
||||||
<Tooltip title='Partager cette version'>
|
<Tooltip title='Partager cette version'>
|
||||||
<IconButton color='primary' onClick={handleShare}>
|
<IconButton color='primary' onClick={handleShare}>
|
||||||
<ShareIcon />
|
<ShareIcon />
|
||||||
|
|||||||
@@ -1,118 +1,36 @@
|
|||||||
import {useRef, useState, useEffect} from 'react'
|
import {useRef, useState, useEffect} from 'react'
|
||||||
import {useTheme} from '@mui/material/styles'
|
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import Card from '@mui/material/Card'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import CardContent from '@mui/material/CardContent'
|
import Collapse from '@mui/material/Collapse'
|
||||||
import CardActions from '@mui/material/CardActions'
|
|
||||||
import Button from '@mui/material/Button'
|
|
||||||
import Chip from '@mui/material/Chip'
|
|
||||||
import Avatar from '@mui/material/Avatar'
|
|
||||||
import Divider from '@mui/material/Divider'
|
|
||||||
import Timeline from '@mui/lab/Timeline'
|
|
||||||
import TimelineItem from '@mui/lab/TimelineItem'
|
|
||||||
import TimelineSeparator from '@mui/lab/TimelineSeparator'
|
|
||||||
import TimelineConnector from '@mui/lab/TimelineConnector'
|
|
||||||
import TimelineContent from '@mui/lab/TimelineContent'
|
|
||||||
import TimelineDot from '@mui/lab/TimelineDot'
|
|
||||||
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime'
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
|
||||||
import ErrorIcon from '@mui/icons-material/Error'
|
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
|
||||||
import Snackbar from '@mui/material/Snackbar'
|
import Snackbar from '@mui/material/Snackbar'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
|
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
|
||||||
import SessionExpired from '../session/session-expired.js'
|
import SessionExpired from '../session/session-expired.js'
|
||||||
import MarkdownRenderer from '../markdown-renderer/index.js'
|
|
||||||
import VersionDialog from './version-dialog.js'
|
import VersionDialog from './version-dialog.js'
|
||||||
import VoteButtons from './vote-buttons.js'
|
import VoteButtons from './vote-buttons.js'
|
||||||
import CopyButton from './copy-button.js'
|
import CopyButton from './copy-button.js'
|
||||||
import ShareButton from './share-button.js'
|
|
||||||
import ExportPdfButton from './export-pdf-button.js'
|
|
||||||
import PrintButton from './print-button.js'
|
|
||||||
import {formatDate} from '@/lib/format.js'
|
import {formatDate} from '@/lib/format.js'
|
||||||
import {compareVersion} from '@/lib/directus.js'
|
import {compareVersion} from '@/lib/directus.js'
|
||||||
|
|
||||||
function getVersionStatus(version, index, totalVersions, data) {
|
function getStatusColor(isOutdated, index) {
|
||||||
// Logic to determine version status based on position and data
|
if (isOutdated) {
|
||||||
// Find which version is the "main" (published) by checking if it matches current content
|
return '#D32F2F'
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's a more recent version after this one, this is outdated
|
if (index === 0) {
|
||||||
// unless this IS the main version (would need item content to determine)
|
return '#1976D2'
|
||||||
if (index > 0) {
|
|
||||||
return 'outdated' // Older versions are outdated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Most recent version is current (being edited/proposed)
|
return '#9E9E9E'
|
||||||
return 'current'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusConfig(status) {
|
function VersionItem({
|
||||||
switch (status) {
|
|
||||||
case 'current': {
|
|
||||||
return {
|
|
||||||
color: '#1976D2',
|
|
||||||
bgColor: '#E3F2FD',
|
|
||||||
icon: <EditIcon />,
|
|
||||||
label: 'En cours',
|
|
||||||
chipColor: 'primary'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'published': {
|
|
||||||
return {
|
|
||||||
color: '#2E7D32',
|
|
||||||
bgColor: '#E8F5E9',
|
|
||||||
icon: <CheckCircleIcon />,
|
|
||||||
label: 'Publié',
|
|
||||||
chipColor: 'success'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'archived': {
|
|
||||||
return {
|
|
||||||
color: '#757575',
|
|
||||||
bgColor: '#F5F5F5',
|
|
||||||
icon: <AccessTimeIcon />,
|
|
||||||
label: 'Archivé',
|
|
||||||
chipColor: 'default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'outdated': {
|
|
||||||
return {
|
|
||||||
color: '#D32F2F',
|
|
||||||
bgColor: '#F9E8E8',
|
|
||||||
icon: <ErrorIcon />,
|
|
||||||
label: 'Obsolète',
|
|
||||||
chipColor: 'error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return {
|
|
||||||
color: '#757575',
|
|
||||||
bgColor: '#F5F5F5',
|
|
||||||
icon: <AccessTimeIcon />,
|
|
||||||
label: 'Archivé',
|
|
||||||
chipColor: 'default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function VersionCard({
|
|
||||||
version,
|
version,
|
||||||
index,
|
index,
|
||||||
totalVersions,
|
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
userId,
|
||||||
countdownRef,
|
countdownRef,
|
||||||
@@ -122,13 +40,11 @@ function VersionCard({
|
|||||||
setVersionCompare,
|
setVersionCompare,
|
||||||
onVoteResult
|
onVoteResult
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
|
||||||
const [versionStatus, setVersionStatus] = useState(null)
|
|
||||||
const [isOutdated, setIsOutdated] = useState(false)
|
const [isOutdated, setIsOutdated] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
// Fetch real status from API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVersionStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
const comparisonData = await compareVersion({
|
const comparisonData = await compareVersion({
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -140,41 +56,23 @@ function VersionCard({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (comparisonData) {
|
if (comparisonData) {
|
||||||
// Store outdated flag for vote disabling
|
|
||||||
setIsOutdated(comparisonData.outdated)
|
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) {
|
} catch {
|
||||||
// Fallback to position-based status on error
|
setIsOutdated(false)
|
||||||
setVersionStatus(getVersionStatus(version, index, totalVersions, null))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchVersionStatus()
|
fetchStatus()
|
||||||
}, [version.id, index, totalVersions, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
|
}, [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 createdAt = new Date(version.date_created)
|
||||||
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
||||||
const isExpired = createdAt < threeDaysAgo
|
const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
|
||||||
const isVoteDisabled = isExpired || isOutdated
|
|
||||||
|
|
||||||
const handleCompareClick = async () => {
|
const handleCompare = async () => {
|
||||||
const comparisonData = await compareVersion({
|
const comparisonData = await compareVersion({
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
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 (
|
return (
|
||||||
<Card
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
borderLeft: `4px solid ${statusConfig.color}`,
|
display: 'flex',
|
||||||
backgroundColor: statusConfig.bgColor,
|
gap: 1.5,
|
||||||
mb: 2,
|
py: 1.5,
|
||||||
transition: 'all 0.2s ease-in-out',
|
borderBottom: '1px solid',
|
||||||
'&:hover': {
|
borderColor: 'divider',
|
||||||
transform: 'translateY(-2px)',
|
'&:last-child': {borderBottom: 'none'}
|
||||||
boxShadow: 3
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent>
|
{/* Status indicator */}
|
||||||
<Box sx={{
|
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 0.5}}>
|
||||||
display: 'flex',
|
<Box
|
||||||
justifyContent: 'space-between',
|
sx={{
|
||||||
alignItems: 'flex-start',
|
width: 12,
|
||||||
mb: 2
|
height: 12,
|
||||||
}}
|
borderRadius: '50%',
|
||||||
|
bgcolor: statusColor,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 2,
|
||||||
|
flex: 1,
|
||||||
|
bgcolor: 'divider',
|
||||||
|
mt: 0.5,
|
||||||
|
display: index === 0 ? 'none' : 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{flex: 1, minWidth: 0}}>
|
||||||
|
{/* Header row */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
<Box sx={{minWidth: 0, flex: 1}}>
|
||||||
<Avatar sx={{bgcolor: statusConfig.color, width: 32, height: 32}}>
|
<Typography
|
||||||
{statusConfig.icon}
|
variant='body2'
|
||||||
</Avatar>
|
sx={{
|
||||||
<Box>
|
fontWeight: 600,
|
||||||
<Typography variant='h6' sx={{fontWeight: 'bold', color: statusConfig.color}}>
|
color: statusColor,
|
||||||
{version.name}
|
overflow: 'hidden',
|
||||||
</Typography>
|
textOverflow: 'ellipsis',
|
||||||
<Typography variant='caption' color='text.secondary'>
|
whiteSpace: 'nowrap'
|
||||||
par @{userDisplayName}
|
}}
|
||||||
</Typography>
|
>
|
||||||
</Box>
|
{version.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant='caption' color='text.secondary'>
|
||||||
|
{formatDate(version.date_created, 'dd/MM/yy HH:mm')}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{display: 'flex', gap: 1}}>
|
|
||||||
<Chip
|
|
||||||
label={statusConfig.label}
|
|
||||||
color={statusConfig.chipColor}
|
|
||||||
size='small'
|
|
||||||
variant='outlined'
|
|
||||||
/>
|
|
||||||
{isExpired && !isOutdated && (
|
|
||||||
<Chip
|
|
||||||
label='Vote fermé'
|
|
||||||
color='error'
|
|
||||||
size='small'
|
|
||||||
variant='outlined'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{mb: 2, '& > div': {fontSize: '0.875rem', fontStyle: 'italic'}}}>
|
{/* Actions */}
|
||||||
<MarkdownRenderer
|
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
|
||||||
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'
|
|
||||||
/>
|
|
||||||
<VoteButtons
|
<VoteButtons
|
||||||
hasCountsVisible
|
hasCountsVisible
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
isDisabled={isVoteDisabled}
|
isDisabled={isVoteDisabled}
|
||||||
onVoteResult={onVoteResult}
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<Divider />
|
{/* Expanded content */}
|
||||||
<CardActions sx={{justifyContent: 'flex-end'}}>
|
<Collapse in={expanded}>
|
||||||
<Button
|
<Box sx={{mt: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
|
||||||
size='small'
|
{/* Preview */}
|
||||||
variant='outlined'
|
{version.delta?.contenu && (
|
||||||
color='primary'
|
<Typography
|
||||||
onClick={handleCompareClick}
|
variant='caption'
|
||||||
>
|
color='text.secondary'
|
||||||
Comparer
|
sx={{
|
||||||
</Button>
|
display: '-webkit-box',
|
||||||
</CardActions>
|
WebkitLineClamp: 3,
|
||||||
</Card>
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{version.delta.contenu}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions row */}
|
||||||
|
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap'}}>
|
||||||
|
<CopyButton
|
||||||
|
content={version.delta?.contenu || version.name || ''}
|
||||||
|
label='Copier'
|
||||||
|
hasSnackbarVisible={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VersionTimeline({
|
export default function VersionTimeline({
|
||||||
collection,
|
|
||||||
data,
|
data,
|
||||||
accessToken,
|
accessToken,
|
||||||
userId,
|
userId,
|
||||||
setError,
|
setError,
|
||||||
setIsErrorAlertOpen
|
setIsErrorAlertOpen,
|
||||||
|
onVoteSuccess
|
||||||
}) {
|
}) {
|
||||||
const countdownRef = useRef()
|
const countdownRef = useRef()
|
||||||
const [isOpenComparison, setIsOpenComparison] = useState(false)
|
const [isOpenComparison, setIsOpenComparison] = useState(false)
|
||||||
@@ -359,66 +217,36 @@ export default function VersionTimeline({
|
|||||||
|
|
||||||
const versionData = data.find(({id}) => id === versionCompare?.versionId)
|
const versionData = data.find(({id}) => id === versionCompare?.versionId)
|
||||||
|
|
||||||
const handleVoteResult = result => {
|
const handleVoteResult = (result, versionId) => {
|
||||||
setSnackbar({
|
setSnackbar({
|
||||||
open: true,
|
open: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
severity: result.success ? 'success' : 'error'
|
severity: result.success ? 'success' : 'error'
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseSnackbar = () => {
|
if (result.success && onVoteSuccess && versionId) {
|
||||||
setSnackbar(prev => ({...prev, open: false}))
|
onVoteSuccess(versionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box sx={{maxWidth: 500, mx: 'auto'}}>
|
||||||
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}>
|
{data.map((version, index) => (
|
||||||
Historique des versions - {collection}
|
<VersionItem
|
||||||
</Typography>
|
key={version.id}
|
||||||
|
version={version}
|
||||||
<Timeline position='right'>
|
index={index}
|
||||||
{data.map((version, index) => (
|
accessToken={accessToken}
|
||||||
<TimelineItem key={version.id}>
|
userId={userId}
|
||||||
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}>
|
countdownRef={countdownRef}
|
||||||
<Typography variant='caption' color='text.secondary'>
|
setError={setError}
|
||||||
{formatDate(version.date_created, 'dd/MM/yyyy')}
|
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
||||||
</Typography>
|
setIsOpenComparison={setIsOpenComparison}
|
||||||
<br />
|
setVersionCompare={setVersionCompare}
|
||||||
<Typography variant='caption' color='text.secondary'>
|
onVoteResult={result => handleVoteResult(result, version.id)}
|
||||||
{formatDate(version.date_created, 'HH:mm')}
|
/>
|
||||||
</Typography>
|
))}
|
||||||
</TimelineOppositeContent>
|
|
||||||
|
|
||||||
<TimelineSeparator>
|
|
||||||
<TimelineDot
|
|
||||||
color={index === 0 ? 'primary' : 'grey'}
|
|
||||||
variant={index === 0 ? 'filled' : 'outlined'}
|
|
||||||
>
|
|
||||||
{index === 0 ? <EditIcon /> : <AccessTimeIcon />}
|
|
||||||
</TimelineDot>
|
|
||||||
{index < data.length - 1 && <TimelineConnector />}
|
|
||||||
</TimelineSeparator>
|
|
||||||
|
|
||||||
<TimelineContent sx={{flex: 1}}>
|
|
||||||
<VersionCard
|
|
||||||
version={version}
|
|
||||||
index={index}
|
|
||||||
totalVersions={data.length}
|
|
||||||
accessToken={accessToken}
|
|
||||||
userId={userId}
|
|
||||||
countdownRef={countdownRef}
|
|
||||||
setError={setError}
|
|
||||||
setIsErrorAlertOpen={setIsErrorAlertOpen}
|
|
||||||
setIsOpenComparison={setIsOpenComparison}
|
|
||||||
setVersionCompare={setVersionCompare}
|
|
||||||
onVoteResult={handleVoteResult}
|
|
||||||
/>
|
|
||||||
</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
))}
|
|
||||||
</Timeline>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isOpenComparison && (
|
{isOpenComparison && (
|
||||||
@@ -440,9 +268,14 @@ export default function VersionTimeline({
|
|||||||
open={snackbar.open}
|
open={snackbar.open}
|
||||||
autoHideDuration={6000}
|
autoHideDuration={6000}
|
||||||
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
|
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
|
||||||
onClose={handleCloseSnackbar}
|
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
||||||
>
|
>
|
||||||
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}>
|
<Alert
|
||||||
|
variant='filled'
|
||||||
|
severity={snackbar.severity}
|
||||||
|
sx={{width: '100%'}}
|
||||||
|
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
|
||||||
|
>
|
||||||
{snackbar.message}
|
{snackbar.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
@@ -451,18 +284,17 @@ export default function VersionTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
VersionTimeline.propTypes = {
|
VersionTimeline.propTypes = {
|
||||||
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
|
|
||||||
data: PropTypes.array.isRequired,
|
data: PropTypes.array.isRequired,
|
||||||
accessToken: PropTypes.string.isRequired,
|
accessToken: PropTypes.string.isRequired,
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
setError: PropTypes.func.isRequired,
|
setError: PropTypes.func.isRequired,
|
||||||
setIsErrorAlertOpen: PropTypes.func.isRequired
|
setIsErrorAlertOpen: PropTypes.func.isRequired,
|
||||||
|
onVoteSuccess: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|
||||||
VersionCard.propTypes = {
|
VersionItem.propTypes = {
|
||||||
version: PropTypes.object.isRequired,
|
version: PropTypes.object.isRequired,
|
||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
totalVersions: PropTypes.number.isRequired,
|
|
||||||
accessToken: PropTypes.string.isRequired,
|
accessToken: PropTypes.string.isRequired,
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
countdownRef: PropTypes.object.isRequired,
|
countdownRef: PropTypes.object.isRequired,
|
||||||
|
|||||||
@@ -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
|
return versions
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('error', error)
|
|
||||||
|
if (error) {
|
||||||
if (error?.errors[0]?.message === 'Token expired.') {
|
|
||||||
countdownRef.current.startCountdown()
|
countdownRef.current.startCountdown()
|
||||||
} else {
|
} else {
|
||||||
console.log(error?.errors[0]?.message)
|
console.log(error?.errors[0]?.message)
|
||||||
@@ -370,3 +369,43 @@ export async function getUserVote({
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVoteCounts({
|
||||||
|
accessToken,
|
||||||
|
versionId
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const votes = await directusClient.request(
|
||||||
|
withToken(
|
||||||
|
accessToken,
|
||||||
|
readItems('votes', {
|
||||||
|
filter: {
|
||||||
|
content_version_id: {_eq: versionId}
|
||||||
|
},
|
||||||
|
fields: ['vote']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
positive: 0,
|
||||||
|
negative: 0,
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of votes) {
|
||||||
|
if (v.vote === 1) {
|
||||||
|
counts.positive++
|
||||||
|
} else if (v.vote === -1) {
|
||||||
|
counts.negative++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.total = counts.positive - counts.negative
|
||||||
|
|
||||||
|
return counts
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching vote counts:', error)
|
||||||
|
return {positive: 0, negative: 0, total: 0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-2
@@ -19,10 +19,17 @@ export function formatKonstitisyon(titres, articles) {
|
|||||||
return konstitisyon
|
return konstitisyon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date, formatStr = 'PP') {
|
export function formatDate(date, formatStr = 'PP', {withTimezone = false} = {}) {
|
||||||
return format(date, formatStr, {
|
const formatted = format(date, formatStr, {
|
||||||
locale: fr
|
locale: fr
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (withTimezone) {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
return `${formatted} (${timezone})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasRestrictedChar(text) {
|
export function hasRestrictedChar(text) {
|
||||||
|
|||||||
@@ -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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "xo"
|
"lint": "xo",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^20.3.0",
|
"@directus/sdk": "^20.3.0",
|
||||||
@@ -21,14 +24,16 @@
|
|||||||
"jspdf": "^4.0.0",
|
"jspdf": "^4.0.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"next": "^16.1.0",
|
"next": "^16.1.0",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"use-debounce": "^10.0.5"
|
"use-debounce": "^10.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"eslint-config-xo-nextjs": "^6.0.0",
|
"eslint-config-xo-nextjs": "^6.0.0",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"xo": "^0.58.0"
|
"xo": "^0.58.0"
|
||||||
},
|
},
|
||||||
"xo": {
|
"xo": {
|
||||||
@@ -49,6 +54,16 @@
|
|||||||
"n/prefer-global/process": "off",
|
"n/prefer-global/process": "off",
|
||||||
"comma-dangle": "off",
|
"comma-dangle": "off",
|
||||||
"unicorn/prevent-abbreviations": "off"
|
"unicorn/prevent-abbreviations": "off"
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "lib/__tests__/**/*.js",
|
||||||
|
"envs": ["node", "es2020"],
|
||||||
|
"rules": {
|
||||||
|
"camelcase": ["error", {"properties": "never"}],
|
||||||
|
"capitalized-comments": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
"@auth/core@0.37.2":
|
"@auth/core@0.41.0":
|
||||||
version "0.37.2"
|
version "0.41.0"
|
||||||
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.37.2.tgz#0db8a94a076846bd88eb7f9273618513e2285cb2"
|
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.41.0.tgz#6a57e18ab0dd0fc2606f9f0f7460a67190966161"
|
||||||
integrity sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==
|
integrity sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@panva/hkdf" "^1.2.1"
|
"@panva/hkdf" "^1.2.1"
|
||||||
"@types/cookie" "0.6.0"
|
jose "^6.0.6"
|
||||||
cookie "0.7.1"
|
oauth4webapi "^3.3.0"
|
||||||
jose "^5.9.3"
|
preact "10.24.3"
|
||||||
oauth4webapi "^3.0.0"
|
preact-render-to-string "6.5.11"
|
||||||
preact "10.11.3"
|
|
||||||
preact-render-to-string "5.2.3"
|
|
||||||
|
|
||||||
"@babel/code-frame@^7.0.0":
|
"@babel/code-frame@^7.0.0":
|
||||||
version "7.24.2"
|
version "7.24.2"
|
||||||
@@ -633,11 +631,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.8.0"
|
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":
|
"@types/debug@^4.0.0":
|
||||||
version "4.1.12"
|
version "4.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
|
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"
|
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
|
||||||
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
|
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:
|
core-js-compat@^3.34.0:
|
||||||
version "3.37.1"
|
version "3.37.1"
|
||||||
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee"
|
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:
|
optionalDependencies:
|
||||||
"@pkgjs/parseargs" "^0.11.0"
|
"@pkgjs/parseargs" "^0.11.0"
|
||||||
|
|
||||||
jose@^5.9.3:
|
jose@^6.0.6:
|
||||||
version "5.10.0"
|
version "6.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be"
|
resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5"
|
||||||
integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==
|
integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==
|
||||||
|
|
||||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
next-auth@5.0.0-beta.25:
|
next-auth@^5.0.0-beta.30:
|
||||||
version "5.0.0-beta.25"
|
version "5.0.0-beta.30"
|
||||||
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.25.tgz#3a9f9734e1d8fa5ced545360f1afc24862cb92d5"
|
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-5.0.0-beta.30.tgz#945af66d27d2e6defa34a4d96765df67fe4164cc"
|
||||||
integrity sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==
|
integrity sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@auth/core" "0.37.2"
|
"@auth/core" "0.41.0"
|
||||||
|
|
||||||
next@^16.1.0:
|
next@^16.1.0:
|
||||||
version "16.1.0"
|
version "16.1.0"
|
||||||
@@ -3867,10 +3855,10 @@ nth-check@^2.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
boolbase "^1.0.0"
|
boolbase "^1.0.0"
|
||||||
|
|
||||||
oauth4webapi@^3.0.0:
|
oauth4webapi@^3.3.0:
|
||||||
version "3.6.0"
|
version "3.8.3"
|
||||||
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.6.0.tgz#839a4520c59f82fdc84d129ce308562646aa3fbc"
|
resolved "https://registry.yarnpkg.com/oauth4webapi/-/oauth4webapi-3.8.3.tgz#8a3e36b88a52db5e619907f031bff3770b2ed1a4"
|
||||||
integrity sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==
|
integrity sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==
|
||||||
|
|
||||||
obj-props@^1.0.0:
|
obj-props@^1.0.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
@@ -4185,17 +4173,15 @@ postcss@8.4.31:
|
|||||||
picocolors "^1.0.0"
|
picocolors "^1.0.0"
|
||||||
source-map-js "^1.0.2"
|
source-map-js "^1.0.2"
|
||||||
|
|
||||||
preact-render-to-string@5.2.3:
|
preact-render-to-string@6.5.11:
|
||||||
version "5.2.3"
|
version "6.5.11"
|
||||||
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz#23d17376182af720b1060d5a4099843c7fe92fe4"
|
resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz#467e69908a453497bb93d4d1fc35fb749a78e027"
|
||||||
integrity sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==
|
integrity sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==
|
||||||
dependencies:
|
|
||||||
pretty-format "^3.8.0"
|
|
||||||
|
|
||||||
preact@10.11.3:
|
preact@10.24.3:
|
||||||
version "10.11.3"
|
version "10.24.3"
|
||||||
resolved "https://registry.yarnpkg.com/preact/-/preact-10.11.3.tgz#8a7e4ba19d3992c488b0785afcc0f8aa13c78d19"
|
resolved "https://registry.yarnpkg.com/preact/-/preact-10.24.3.tgz#086386bd47071e3b45410ef20844c21e23828f64"
|
||||||
integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==
|
integrity sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==
|
||||||
|
|
||||||
prelude-ls@^1.2.1:
|
prelude-ls@^1.2.1:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
|
||||||
integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
|
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:
|
prop-types@^15.6.2, prop-types@^15.8.1:
|
||||||
version "15.8.1"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
|
|||||||
Reference in New Issue
Block a user