dc1f115bd6
export-pdf-button et print-button injectaient marked(content) directement dans innerHTML / document.write. Un lien Markdown javascript: passait le filtre hasRestrictedChar et pouvait s'exécuter. Ajout de DOMPurify.sanitize() via import dynamique (déjà présent en dep transitive de jspdf) sur les deux composants, avec whitelist de tags et d'attributs stricte. markdown-renderer n'est pas touché car react-markdown-preview utilise rehype-sanitize en interne. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
'use client'
|
|
|
|
import {useState} from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import Button from '@mui/material/Button'
|
|
import CircularProgress from '@mui/material/CircularProgress'
|
|
import Snackbar from '@mui/material/Snackbar'
|
|
import Alert from '@mui/material/Alert'
|
|
import PrintIcon from '@mui/icons-material/Print'
|
|
import {formatDate} from '@/lib/format.js'
|
|
|
|
// Helper function to render markdown to HTML (reused from PDF export)
|
|
const renderMarkdownToHtml = async content => {
|
|
if (!content) {
|
|
return 'Aucun contenu disponible'
|
|
}
|
|
|
|
// Check if content contains markdown syntax
|
|
const hasMarkdown = content.includes('**')
|
|
|| content.includes('*')
|
|
|| content.includes('#')
|
|
|| content.includes('[')
|
|
|| content.includes('`')
|
|
|| content.includes('> ')
|
|
|| content.includes('- ')
|
|
|| content.includes('1. ')
|
|
|
|
if (!hasMarkdown) {
|
|
// Simple text with line breaks
|
|
return content.replaceAll('\n', '<br>')
|
|
}
|
|
|
|
try {
|
|
// Dynamic import of markdown parser and sanitizer
|
|
const [{marked}, {default: DOMPurify}] = await Promise.all([
|
|
import('marked'),
|
|
import('dompurify')
|
|
])
|
|
|
|
// Configure marked for better print rendering
|
|
marked.setOptions({
|
|
breaks: true, // Convert \n to <br>
|
|
gfm: true, // GitHub flavored markdown
|
|
headerIds: false, // No header IDs needed for print
|
|
mangle: false // Don't mangle email addresses
|
|
})
|
|
|
|
return DOMPurify.sanitize(marked(content), {
|
|
ALLOWED_TAGS: [
|
|
'p',
|
|
'strong',
|
|
'em',
|
|
'b',
|
|
'i',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'blockquote',
|
|
'code',
|
|
'pre',
|
|
'br',
|
|
'hr',
|
|
'a',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tr',
|
|
'th',
|
|
'td',
|
|
],
|
|
ALLOWED_ATTR: [
|
|
'href',
|
|
'target',
|
|
'rel',
|
|
],
|
|
ALLOW_DATA_ATTR: false,
|
|
})
|
|
} catch (error) {
|
|
console.warn('Failed to parse markdown, falling back to plain text:', error)
|
|
return content.replaceAll('\n', '<br>')
|
|
}
|
|
}
|
|
|
|
export default function PrintButton({versionData, isOutdated = false, voteCounts = null, size = 'medium', variant = 'outlined'}) {
|
|
const [isPrinting, setIsPrinting] = useState(false)
|
|
const [snackbar, setSnackbar] = useState({open: false, message: '', severity: 'success'})
|
|
|
|
const handlePrint = async () => {
|
|
setIsPrinting(true)
|
|
|
|
try {
|
|
// Calculate vote status
|
|
const authorName = versionData.user_created?.split('-')[0] || 'Système'
|
|
const createdAt = new Date(versionData.date_created)
|
|
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
|
|
const isExpired = createdAt < threeDaysAgo
|
|
const voteStatus = (isExpired || isOutdated) ? 'fermé' : 'ouvert'
|
|
const voteColor = voteStatus === 'ouvert' ? '#2e7d32' : '#d32f2f'
|
|
|
|
// Vote counts display
|
|
const voteTotal = voteCounts ? voteCounts.total : 0
|
|
const voteTotalColor = voteTotal > 0 ? '#2e7d32' : (voteTotal < 0 ? '#d32f2f' : '#666')
|
|
const voteTotalSign = voteTotal >= 0 ? '+' : ''
|
|
|
|
// Render markdown content to HTML
|
|
const renderedContent = await renderMarkdownToHtml(versionData.delta?.contenu)
|
|
|
|
// Create print window
|
|
const printWindow = window.open('', '_blank', 'width=800,height=600')
|
|
|
|
if (!printWindow) {
|
|
throw new Error('Impossible d\'ouvrir la fenêtre d\'impression. Vérifiez que les popups ne sont pas bloqués.')
|
|
}
|
|
|
|
// Build print-optimized HTML
|
|
const printHtml = `
|
|
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${versionData.name} - Konstitisyon.nu</title>
|
|
<style>
|
|
/* Print-optimized styles */
|
|
@media print {
|
|
* {
|
|
-webkit-print-color-adjust: exact !important;
|
|
color-adjust: exact !important;
|
|
}
|
|
|
|
@page {
|
|
margin: 2cm;
|
|
size: A4;
|
|
}
|
|
}
|
|
|
|
body {
|
|
font-family: 'Times New Roman', serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background: white;
|
|
}
|
|
|
|
.header {
|
|
border-bottom: 3px solid #1976d2;
|
|
padding-bottom: 20px;
|
|
margin-bottom: 30px;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
.title {
|
|
color: #1976d2;
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
margin: 0 0 15px 0;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
.metadata {
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin: 5px 0;
|
|
}
|
|
|
|
.vote-status {
|
|
font-weight: bold;
|
|
color: ${voteColor};
|
|
}
|
|
|
|
.content-section {
|
|
margin: 30px 0;
|
|
}
|
|
|
|
.content-title {
|
|
color: #333;
|
|
font-size: 22px;
|
|
font-weight: bold;
|
|
margin-bottom: 20px;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
.content {
|
|
font-size: 16px;
|
|
line-height: 1.8;
|
|
text-align: justify;
|
|
hyphens: auto;
|
|
}
|
|
|
|
/* Markdown elements styles */
|
|
.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 {
|
|
margin: 25px 0 15px 0;
|
|
font-weight: bold;
|
|
color: #333;
|
|
page-break-after: avoid;
|
|
}
|
|
|
|
.content h1 { font-size: 24px; color: #1976d2; }
|
|
.content h2 { font-size: 20px; }
|
|
.content h3 { font-size: 18px; }
|
|
.content h4 { font-size: 16px; }
|
|
|
|
.content p { margin: 15px 0; }
|
|
.content strong, .content b { font-weight: bold; }
|
|
.content em, .content i { font-style: italic; }
|
|
|
|
.content ul, .content ol {
|
|
margin: 15px 0;
|
|
padding-left: 30px;
|
|
}
|
|
.content li {
|
|
margin: 8px 0;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
.content blockquote {
|
|
margin: 20px 0;
|
|
padding: 15px 25px;
|
|
border-left: 4px solid #1976d2;
|
|
background-color: #f8f9fa;
|
|
font-style: italic;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
.content code {
|
|
background-color: #f8f9fa;
|
|
padding: 3px 6px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.content pre {
|
|
background-color: #f8f9fa;
|
|
padding: 20px;
|
|
border-radius: 5px;
|
|
overflow-x: auto;
|
|
margin: 20px 0;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
.content pre code {
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.content a {
|
|
color: #1976d2;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.content hr {
|
|
border: none;
|
|
border-top: 1px solid #ccc;
|
|
margin: 30px 0;
|
|
}
|
|
|
|
.content table {
|
|
border-collapse: collapse;
|
|
margin: 20px 0;
|
|
width: 100%;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
.content th, .content td {
|
|
border: 1px solid #ccc;
|
|
padding: 12px;
|
|
text-align: left;
|
|
}
|
|
|
|
.content th {
|
|
background-color: #f8f9fa;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 50px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #eee;
|
|
font-size: 12px;
|
|
color: #888;
|
|
text-align: center;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
/* Screen-only styles */
|
|
@media screen {
|
|
.print-actions {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.print-button {
|
|
background: #1976d2;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.close-button {
|
|
background: #666;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.print-button:hover { background: #1565c0; }
|
|
.close-button:hover { background: #555; }
|
|
}
|
|
|
|
@media print {
|
|
.print-actions { display: none !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="print-actions">
|
|
<button class="print-button" onclick="window.print()">🖨️ Imprimer</button>
|
|
<button class="close-button" onclick="window.close()">✕ Fermer</button>
|
|
</div>
|
|
|
|
<div class="header">
|
|
<h1 class="title">${versionData.name}</h1>
|
|
<div class="metadata">
|
|
<strong>Auteur :</strong> @${authorName}
|
|
</div>
|
|
<div class="metadata">
|
|
<strong>Date de création :</strong> ${formatDate(versionData.date_created, 'PPpp', {withTimezone: true})}
|
|
</div>
|
|
<div class="metadata">
|
|
<strong>Statut du vote :</strong>
|
|
<span class="vote-status">${voteStatus}</span>
|
|
</div>
|
|
${voteCounts ? `
|
|
<div style="margin-top: 15px; padding: 15px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #e0e0e0;">
|
|
<p style="margin: 0 0 10px 0; font-size: 16px; font-weight: bold; color: #333;">
|
|
📊 Résultats des votes
|
|
</p>
|
|
<p style="margin: 5px 0; font-size: 14px;">
|
|
👍 Votes positifs : <strong style="color: #2e7d32;">${voteCounts.positive}</strong>
|
|
</p>
|
|
<p style="margin: 5px 0; font-size: 14px;">
|
|
👎 Votes négatifs : <strong style="color: #d32f2f;">${voteCounts.negative}</strong>
|
|
</p>
|
|
<p style="margin: 10px 0 0 0; font-size: 16px; font-weight: bold;">
|
|
🏆 Total : <span style="color: ${voteTotalColor};">${voteTotalSign}${voteTotal}</span>
|
|
</p>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
<div class="content-section">
|
|
<h2 class="content-title">Contenu</h2>
|
|
<div class="content">
|
|
${renderedContent}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
Imprimé depuis Konstitisyon.nu le ${formatDate(new Date(), 'PPpp', {withTimezone: true})}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
printWindow.document.write(printHtml)
|
|
printWindow.document.close()
|
|
|
|
// Auto-focus the print window
|
|
printWindow.focus()
|
|
|
|
setSnackbar({
|
|
open: true,
|
|
message: 'Fenêtre d\'impression ouverte',
|
|
severity: 'success'
|
|
})
|
|
} catch (error) {
|
|
console.error('Erreur lors de l\'impression:', error)
|
|
setSnackbar({
|
|
open: true,
|
|
message: error.message || 'Une erreur est survenue lors de l\'impression',
|
|
severity: 'error'
|
|
})
|
|
} finally {
|
|
setIsPrinting(false)
|
|
}
|
|
}
|
|
|
|
const handleCloseSnackbar = () => {
|
|
setSnackbar(prev => ({...prev, open: false}))
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant={variant}
|
|
size={size}
|
|
startIcon={isPrinting ? <CircularProgress size={16} /> : <PrintIcon />}
|
|
disabled={isPrinting}
|
|
sx={{
|
|
minWidth: size === 'small' ? 'auto' : undefined,
|
|
'&:disabled': {
|
|
opacity: 0.6
|
|
}
|
|
}}
|
|
onClick={handlePrint}
|
|
>
|
|
{isPrinting ? 'Préparation...' : (size === 'small' ? 'Print' : 'Imprimer')}
|
|
</Button>
|
|
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={4000}
|
|
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
|
|
onClose={handleCloseSnackbar}
|
|
>
|
|
<Alert
|
|
variant='filled'
|
|
severity={snackbar.severity}
|
|
sx={{width: '100%'}}
|
|
onClose={handleCloseSnackbar}
|
|
>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
</>
|
|
)
|
|
}
|
|
|
|
PrintButton.propTypes = {
|
|
versionData: PropTypes.object.isRequired,
|
|
isOutdated: PropTypes.bool,
|
|
voteCounts: PropTypes.shape({
|
|
positive: PropTypes.number,
|
|
negative: PropTypes.number,
|
|
total: PropTypes.number
|
|
}),
|
|
size: PropTypes.oneOf(['small', 'medium', 'large']),
|
|
variant: PropTypes.oneOf(['text', 'outlined', 'contained'])
|
|
}
|