- Create Event entity with fields: account, title, description (text), startAt, endAt, address, zipcode, city, eventMainPicture (VichUploader), isOnline, createdAt, updatedAt - Create EventRepository - Add migration for event table with all columns - Add "Creer un evenement" button on account events tab - Add create event page (/mon-compte/evenement/creer) with full form - Build custom web component <e-ticket-editor> WYSIWYG editor: - Toolbar: bold, italic, underline, paragraph, bullet list, remove formatting - contentEditable div with HTML sync to hidden textarea - HTML sanitizer (strips disallowed tags, XSS safe) - Neo-brutalist CSS styling - CSP compliant (no inline styles) - Register editor in app.js via customElements.define - Add editor CSS in app.scss - Add 16 Event entity tests (all fields + isOnline + picture upload + updatedAt) - Add 16 editor JS tests (sanitizer + custom element lifecycle) - Add 3 AccountController tests (create event page, submit, access control) - Update placeholders to generic examples (no association-specific data) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
145 lines
4.2 KiB
JavaScript
145 lines
4.2 KiB
JavaScript
const TOOLBAR_ACTIONS = [
|
|
{ command: 'bold', icon: '<b>B</b>', title: 'Gras' },
|
|
{ command: 'italic', icon: '<i>I</i>', title: 'Italique' },
|
|
{ command: 'underline', icon: '<u>S</u>', title: 'Souligne' },
|
|
{ separator: true },
|
|
{ command: 'formatBlock', value: 'P', icon: 'P', title: 'Paragraphe' },
|
|
{ separator: true },
|
|
{ command: 'insertUnorderedList', icon: '•', title: 'Liste a puces' },
|
|
{ separator: true },
|
|
{ command: 'removeFormat', icon: '❌', title: 'Supprimer le formatage' },
|
|
]
|
|
|
|
const ALLOWED_TAGS = [
|
|
'p', 'br', 'b', 'strong', 'i', 'em', 'u',
|
|
'ul', 'li',
|
|
]
|
|
|
|
export function sanitizeHtml(html) {
|
|
const container = document.createElement('div')
|
|
container.innerHTML = html
|
|
const fragment = sanitizeNode(container)
|
|
const wrapper = document.createElement('div')
|
|
wrapper.appendChild(fragment)
|
|
|
|
return wrapper.innerHTML.trim()
|
|
}
|
|
|
|
function sanitizeNode(node) {
|
|
const fragment = document.createDocumentFragment()
|
|
|
|
for (const child of Array.from(node.childNodes)) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
fragment.appendChild(document.createTextNode(child.textContent))
|
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
const tagName = child.tagName.toLowerCase()
|
|
if (ALLOWED_TAGS.includes(tagName)) {
|
|
const el = document.createElement(tagName)
|
|
el.appendChild(sanitizeNode(child))
|
|
fragment.appendChild(el)
|
|
} else {
|
|
fragment.appendChild(sanitizeNode(child))
|
|
}
|
|
}
|
|
}
|
|
|
|
return fragment
|
|
}
|
|
|
|
export class ETicketEditor extends HTMLElement {
|
|
connectedCallback() {
|
|
const textarea = this.querySelector('textarea')
|
|
if (!textarea) {
|
|
return
|
|
}
|
|
|
|
this._textarea = textarea
|
|
textarea.style.display = 'none'
|
|
|
|
this._buildToolbar()
|
|
this._buildContentArea()
|
|
|
|
if (textarea.value) {
|
|
this._content.innerHTML = textarea.value
|
|
}
|
|
|
|
this._content.addEventListener('input', () => this._sync())
|
|
this._content.addEventListener('keydown', (e) => this._onKeydown(e))
|
|
}
|
|
|
|
_buildToolbar() {
|
|
const toolbar = document.createElement('div')
|
|
toolbar.classList.add('ete-toolbar')
|
|
|
|
for (const action of TOOLBAR_ACTIONS) {
|
|
if (action.separator) {
|
|
const sep = document.createElement('span')
|
|
sep.classList.add('ete-separator')
|
|
toolbar.appendChild(sep)
|
|
continue
|
|
}
|
|
|
|
const btn = document.createElement('button')
|
|
btn.type = 'button'
|
|
btn.classList.add('ete-btn')
|
|
btn.innerHTML = action.icon
|
|
btn.title = action.title
|
|
btn.addEventListener('mousedown', (e) => {
|
|
e.preventDefault()
|
|
this._exec(action)
|
|
})
|
|
toolbar.appendChild(btn)
|
|
}
|
|
|
|
this.insertBefore(toolbar, this._textarea)
|
|
}
|
|
|
|
_buildContentArea() {
|
|
const content = document.createElement('div')
|
|
content.classList.add('ete-content')
|
|
content.setAttribute('contenteditable', 'true')
|
|
content.setAttribute('role', 'textbox')
|
|
content.setAttribute('aria-multiline', 'true')
|
|
|
|
const placeholder = this._textarea.getAttribute('placeholder')
|
|
if (placeholder) {
|
|
content.dataset.placeholder = placeholder
|
|
}
|
|
|
|
this.insertBefore(content, this._textarea)
|
|
this._content = content
|
|
}
|
|
|
|
_exec(action) {
|
|
if (action.value) {
|
|
document.execCommand(action.command, false, action.value)
|
|
} else {
|
|
document.execCommand(action.command, false, null)
|
|
}
|
|
|
|
this._sync()
|
|
this._content.focus()
|
|
}
|
|
|
|
_onKeydown(e) {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
_sync() {
|
|
const html = sanitizeHtml(this._content.innerHTML)
|
|
this._textarea.value = html
|
|
}
|
|
|
|
getHtml() {
|
|
return this._textarea.value
|
|
}
|
|
}
|
|
|
|
export function registerEditor() {
|
|
if (!globalThis.customElements.get('e-ticket-editor')) {
|
|
globalThis.customElements.define('e-ticket-editor', ETicketEditor)
|
|
}
|
|
}
|