Spaces:
Sleeping
Sleeping
| {% extends "base.html" %} | |
| {% block title %}Éditer Article{% endblock %} | |
| {% block extra_head %} | |
| <link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css"> | |
| <style> | |
| /* Admin Editor Styles */ | |
| .admin-editor { | |
| max-width: 1000px; | |
| margin: 0 auto; | |
| padding: var(--spacing-lg); | |
| background: var(--bg-card); | |
| border-radius: 8px; | |
| box-shadow: var(--shadow-md); | |
| } | |
| .editor-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: var(--spacing-lg); | |
| flex-wrap: wrap; | |
| gap: var(--spacing-md); | |
| } | |
| .editor-title { | |
| margin: 0; | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| background: linear-gradient(135deg, var(--primary-1), var(--accent-1)); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .form-group { | |
| margin-bottom: var(--spacing-md); | |
| } | |
| .form-group label { | |
| display: block; | |
| margin-bottom: var(--spacing-xs); | |
| color: var(--text); | |
| font-weight: 500; | |
| } | |
| .form-control { | |
| width: 100%; | |
| padding: 0.75rem 1rem; | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| background: var(--bg); | |
| color: var(--text); | |
| transition: all var(--transition-fast); | |
| } | |
| .form-control:focus { | |
| outline: none; | |
| border-color: var(--primary-1); | |
| box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); | |
| } | |
| trix-editor { | |
| min-height: 300px; | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| background: var(--bg); | |
| color: var(--text); | |
| padding: var(--spacing-md); | |
| margin-bottom: var(--spacing-lg); | |
| } | |
| trix-editor:focus { | |
| outline: none; | |
| border-color: var(--primary-1); | |
| box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); | |
| } | |
| .preview-section { | |
| margin-top: var(--spacing-xl); | |
| padding: var(--spacing-lg); | |
| background: var(--bg-alt); | |
| border-radius: 8px; | |
| } | |
| .preview-header { | |
| margin-bottom: var(--spacing-md); | |
| padding-bottom: var(--spacing-sm); | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .preview-content { | |
| background: var(--bg); | |
| padding: var(--spacing-lg); | |
| border-radius: 8px; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| /* Custom file input */ | |
| .file-input-wrapper { | |
| position: relative; | |
| } | |
| .file-input-wrapper input[type="file"] { | |
| opacity: 0; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| cursor: pointer; | |
| } | |
| .file-input-trigger { | |
| display: block; | |
| padding: 0.75rem 1rem; | |
| border: 2px dashed var(--border-color); | |
| border-radius: 8px; | |
| text-align: center; | |
| color: var(--text-light); | |
| transition: all var(--transition-fast); | |
| } | |
| .file-input-wrapper:hover .file-input-trigger { | |
| border-color: var(--primary-1); | |
| color: var(--primary-1); | |
| } | |
| </style> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="admin-editor"> | |
| <div class="editor-header"> | |
| <h1 class="editor-title">Éditer Article</h1> | |
| <a href="{{ url_for('admin_articles') }}" class="btn btn-secondary">← Retour aux articles</a> | |
| </div> | |
| <form method="post" enctype="multipart/form-data"> | |
| <div class="form-group"> | |
| <label for="title">Titre:</label> | |
| <input type="text" class="form-control" id="title" name="title" value="{{ article.title }}" required> | |
| </div> | |
| <div class="form-group"> | |
| <label for="category_id">Catégorie:</label> | |
| <select class="form-control" id="category_id" name="category_id" required> | |
| {% for category in article.category.subject.categories %} | |
| <option value="{{ category.id }}" {% if category.id == article.category_id %}selected{% endif %}>{{ category.name }}</option> | |
| {% endfor %} | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="icon">Image:</label> | |
| <div class="file-input-wrapper"> | |
| <div class="file-input-trigger"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="upload-icon"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="17 8 12 3 7 8"/> | |
| <line x1="12" y1="3" x2="12" y2="15"/> | |
| </svg> | |
| <span>Glissez une image ou cliquez pour sélectionner</span> | |
| </div> | |
| <input type="file" id="icon" name="icon" accept="image/*"> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="youtube_url">Lien YouTube (optionnel) :</label> | |
| <input type="url" class="form-control" id="youtube_url" name="youtube_url" | |
| placeholder="https://www.youtube.com/watch?v=..." | |
| value="{{ article.youtube_url or '' }}"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="content">Contenu:</label> | |
| <input id="content" type="hidden" name="content" value="{{ article.content | safe }}"> | |
| <trix-editor input="content"></trix-editor> | |
| </div> | |
| <div class="form-group"> | |
| <button type="submit" class="btn btn-primary">Enregistrer les modifications</button> | |
| </div> | |
| </form> | |
| <div class="preview-section"> | |
| <div class="preview-header"> | |
| <h3>Aperçu en direct</h3> | |
| </div> | |
| <div id="editor-preview" class="preview-content"></div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block extra_scripts %} | |
| <script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js"></script> | |
| <script> | |
| // Configure Trix before init | |
| document.addEventListener('trix-before-initialize', function() { | |
| if (window.Trix && Trix.config && Trix.config.dompurify) { | |
| Trix.config.dompurify.ADD_TAGS = ["iframe", "video", "source", "table", "tr", "td", "th", "img"]; | |
| Trix.config.dompurify.ADD_ATTR = ["src", "controls", "width", "height", "class", "style", "allowfullscreen"]; | |
| } | |
| Trix.config.toolbar.getDefaultHTML = function() { | |
| return ` | |
| <div class="trix-button-row"> | |
| <span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools"> | |
| <button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" title="Gras">Gras</button> | |
| <button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" title="Italique">Italique</button> | |
| <button type="button" class="trix-button" data-trix-action="x-heading" title="Titre">H</button> | |
| <button type="button" class="trix-button" data-trix-action="x-quote" title="Citation">"</button> | |
| <button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" title="Lien">Lien</button> | |
| </span> | |
| <span class="trix-button-group"> | |
| <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="left" title="Aligner à gauche">←</button> | |
| <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="center" title="Centrer">⫸</button> | |
| <button type="button" class="trix-button" data-trix-attribute="textAlign" data-trix-value="right" title="Aligner à droite">→</button> | |
| </span> | |
| </div>`; | |
| }; | |
| }); | |
| function renderShortcodesForPreview(html) { | |
| if (!html) return ''; | |
| html = html.replace(/\[youtube:([^\]]+)\]/g, function(_, url) { | |
| try { | |
| var parsed = new URL(url.trim()); | |
| var id = null; | |
| if (parsed.hostname.includes('youtu.be')) id = parsed.pathname.slice(1); | |
| else if (parsed.hostname.includes('youtube')) id = parsed.searchParams.get('v') || parsed.pathname.split('/').pop(); | |
| if (!id) return '<pre>' + url + '</pre>'; | |
| return '<div class="video-player"><iframe src="https://www.youtube.com/embed/' + id + '" allowfullscreen></iframe></div>'; | |
| } catch (e) { | |
| return '<pre>' + url + '</pre>' | |
| } | |
| }); | |
| html = html.replace(/\[image:([^\]|]+)(?:\|([^\]]+))?\]/g, function(_, url, opts) { | |
| var classes = ''; | |
| var style = ''; | |
| if (opts) { | |
| opts.split(/[|,]/).forEach(function(o) { | |
| o = o.trim(); | |
| if (/^(left|right|center)$/.test(o)) { | |
| if (o === 'left') classes = 'float-left'; | |
| if (o === 'right') classes = 'float-right'; | |
| if (o === 'center') style = 'display:block;margin-left:auto;margin-right:auto'; | |
| } else if (/^width=/.test(o)) { | |
| style += (style ? ';' : '') + 'width:' + o.split('=')[1]; | |
| } else if (/^height=/.test(o)) { | |
| style += (style ? ';' : '') + 'height:' + o.split('=')[1]; | |
| } | |
| }); | |
| } | |
| return '<img src="' + url.trim() + '" class="' + classes + '" style="' + style + '"/>'; | |
| }); | |
| return html; | |
| } | |
| function uploadFileToServer(fileBlob, filename, attachment) { | |
| var formData = new FormData(); | |
| formData.append('file', fileBlob, filename); | |
| fetch('/upload', { | |
| method: 'POST', | |
| body: formData | |
| }).then(function(r) { | |
| return r.json() | |
| }).then(function(data) { | |
| if (data.url) attachment.setAttributes({ | |
| url: data.url | |
| }); | |
| }).catch(function(e) { | |
| console.error('Upload failed', e) | |
| }); | |
| } | |
| // File upload and image resize handling | |
| document.addEventListener('trix-attachment-add', function(event) { | |
| var attachment = event.attachment; | |
| var file = attachment.file; | |
| if (!file) return; | |
| if (!file.type.startsWith('image/')) { | |
| uploadFileToServer(file, file.name, attachment); | |
| return; | |
| } | |
| // Create modal for image resize | |
| const modal = document.createElement('div'); | |
| modal.style.cssText = ` | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0,0,0,0.5); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 9999; | |
| `; | |
| const modalContent = document.createElement('div'); | |
| modalContent.style.cssText = ` | |
| background: var(--bg); | |
| padding: 2rem; | |
| border-radius: var(--card-radius); | |
| box-shadow: var(--shadow-lg); | |
| max-width: 400px; | |
| width: 90%; | |
| `; | |
| modalContent.innerHTML = ` | |
| <h3 style="margin-top:0">Redimensionner l'image ?</h3> | |
| <p>Entrez la largeur souhaitée :</p> | |
| <input type="text" placeholder="800px ou 80%" style="width:100%;padding:0.5rem;margin:1rem 0;border-radius:var(--btn-radius);border:1px solid var(--border-color)"> | |
| <div style="display:flex;gap:1rem;justify-content:flex-end"> | |
| <button class="btn btn-secondary cancel">Annuler</button> | |
| <button class="btn btn-primary confirm">Confirmer</button> | |
| </div> | |
| `; | |
| modal.appendChild(modalContent); | |
| document.body.appendChild(modal); | |
| const input = modalContent.querySelector('input'); | |
| const cancelBtn = modalContent.querySelector('.cancel'); | |
| const confirmBtn = modalContent.querySelector('.confirm'); | |
| cancelBtn.onclick = () => { | |
| document.body.removeChild(modal); | |
| }; | |
| confirmBtn.onclick = () => { | |
| const choice = input.value; | |
| document.body.removeChild(modal); | |
| if (!choice) { | |
| uploadFileToServer(file, file.name, attachment); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const img = new Image(); | |
| img.onload = function() { | |
| const origW = img.naturalWidth, | |
| origH = img.naturalHeight; | |
| let targetW = origW; | |
| const c = choice.toString().trim(); | |
| if (c.endsWith('%')) { | |
| const p = parseFloat(c.replace('%', '')); | |
| if (!isNaN(p)) targetW = Math.round(origW * p / 100); | |
| } else { | |
| const px = parseInt(c, 10); | |
| if (!isNaN(px)) targetW = px; | |
| } | |
| if (targetW <= 0 || targetW === origW) { | |
| uploadFileToServer(file, file.name, attachment); | |
| return; | |
| } | |
| const scale = targetW / origW, | |
| targetH = Math.round(origH * scale); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = targetW; | |
| canvas.height = targetH; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, targetW, targetH); | |
| canvas.toBlob(function(blob) { | |
| uploadFileToServer(blob, file.name, attachment); | |
| }, file.type || 'image/jpeg', 0.92); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| }); | |
| // Live preview updates | |
| function updatePreview() { | |
| var contentInput = document.querySelector('input[name="content"]'); | |
| var preview = document.getElementById('editor-preview'); | |
| if (preview && contentInput) { | |
| preview.innerHTML = renderShortcodesForPreview(contentInput.value); | |
| } | |
| } | |
| document.addEventListener('trix-change', updatePreview); | |
| document.addEventListener('DOMContentLoaded', updatePreview); | |
| // File input visual feedback | |
| document.querySelector('.file-input-wrapper input[type="file"]').addEventListener('change', function(e) { | |
| const trigger = this.parentElement.querySelector('.file-input-trigger'); | |
| if (this.files.length > 0) { | |
| trigger.textContent = `Fichier sélectionné : ${this.files[0].name}`; | |
| } | |
| }); | |
| // Keyboard shortcuts for shortcodes | |
| document.addEventListener('keydown', function(e) { | |
| // Ctrl+Y for YouTube shortcode | |
| if (e.ctrlKey && e.key === 'y') { | |
| e.preventDefault(); | |
| const editor = document.querySelector('trix-editor'); | |
| if (editor && editor.editor) { | |
| const position = editor.editor.getSelectedRange(); | |
| editor.editor.setSelectedRange(position); | |
| editor.editor.insertString('[youtube:]'); | |
| // Position cursor inside the brackets | |
| const newPosition = position[0] + '[youtube:'.length; | |
| editor.editor.setSelectedRange([newPosition, newPosition]); | |
| } | |
| } | |
| // Ctrl+I for responsive image shortcode | |
| if (e.ctrlKey && e.key === 'i') { | |
| e.preventDefault(); | |
| const editor = document.querySelector('trix-editor'); | |
| if (editor && editor.editor) { | |
| const position = editor.editor.getSelectedRange(); | |
| editor.editor.setSelectedRange(position); | |
| editor.editor.insertString('[image:URL|width=100%]'); | |
| // Select "URL" for easy replacement | |
| const urlStart = position[0] + '[image:'.length; | |
| const urlEnd = urlStart + 3; // "URL".length | |
| editor.editor.setSelectedRange([urlStart, urlEnd]); | |
| } | |
| } | |
| }); | |
| </script> | |
| {% endblock %} | |