diff --git a/assets/app.js b/assets/app.js index fa9bf6c..49ac4d6 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,8 +1,10 @@ import "./app.scss" import { initMobileMenu } from "./modules/mobile-menu.js" import { initTabs } from "./modules/tabs.js" +import { registerEditor } from "./modules/editor.js" document.addEventListener('DOMContentLoaded', () => { initMobileMenu() initTabs() + registerEditor() }) diff --git a/assets/app.scss b/assets/app.scss index 3ab21f0..3980e2f 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -1,3 +1,62 @@ @import "tailwindcss"; @import 'https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap'; @source "../templates"; + +/* ===== E-Ticket Editor ===== */ +e-ticket-editor { + display: block; +} + +.ete-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0; + border: 3px solid #111827; + border-bottom: none; + background: #f9fafb; + padding: 0.25rem; +} + +.ete-btn { + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid transparent; + background: transparent; + cursor: pointer; + font-weight: 900; + font-size: 0.8rem; + transition: all 0.1s; +} +.ete-btn:hover { + background: #fabf04; + border-color: #111827; +} + +.ete-separator { + width: 1px; + background: #d1d5db; + margin: 0.25rem 0.35rem; +} + +.ete-content { + width: 100%; + min-height: 10rem; + padding: 0.75rem 1rem; + border: 3px solid #111827; + font-weight: 700; + outline: none; + background: white; + line-height: 1.6; +} +.ete-content:focus { + border-color: #4f46e5; +} +.ete-content:empty::before { + content: attr(data-placeholder); + color: #9ca3af; + pointer-events: none; +} +.ete-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; } diff --git a/assets/modules/editor.js b/assets/modules/editor.js new file mode 100644 index 0000000..f5b937f --- /dev/null +++ b/assets/modules/editor.js @@ -0,0 +1,144 @@ +const TOOLBAR_ACTIONS = [ + { command: 'bold', icon: 'B', title: 'Gras' }, + { command: 'italic', icon: 'I', title: 'Italique' }, + { command: 'underline', icon: 'S', 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) + } +} diff --git a/migrations/Version20260320104445.php b/migrations/Version20260320104445.php new file mode 100644 index 0000000..e9492be --- /dev/null +++ b/migrations/Version20260320104445.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, description TEXT DEFAULT NULL, address VARCHAR(255) NOT NULL, zipcode VARCHAR(10) NOT NULL, city VARCHAR(255) NOT NULL, event_main_picture_name VARCHAR(255) DEFAULT NULL, is_online BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, account_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_3BAE0AA79B6B5FBA ON event (account_id)'); + $this->addSql('ALTER TABLE event ADD CONSTRAINT FK_3BAE0AA79B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE event DROP CONSTRAINT FK_3BAE0AA79B6B5FBA'); + $this->addSql('DROP TABLE event'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 8eff86a..b503f3c 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -281,6 +281,47 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); } + #[Route('/mon-compte/evenement/creer', name: 'app_account_create_event', methods: ['GET', 'POST'])] + public function createEvent(Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + + if ($request->isMethod('POST')) { + $event = new \App\Entity\Event(); + $event->setAccount($user); + $event->setTitle(trim($request->request->getString('title'))); + $event->setDescription(trim($request->request->getString('description')) ?: null); + $event->setStartAt(new \DateTimeImmutable($request->request->getString('start_at'))); + $event->setEndAt(new \DateTimeImmutable($request->request->getString('end_at'))); + $event->setAddress(trim($request->request->getString('address'))); + $event->setZipcode(trim($request->request->getString('zipcode'))); + $event->setCity(trim($request->request->getString('city'))); + + $pictureFile = $request->files->get('event_main_picture'); + if ($pictureFile) { + $event->setEventMainPictureFile($pictureFile); + } + + $em->persist($event); + $em->flush(); + + $this->addFlash('success', 'Evenement cree avec succes.'); + + return $this->redirectToRoute('app_account', ['tab' => 'events']); + } + + return $this->render('account/create_event.html.twig', [ + 'breadcrumbs' => [ + self::BREADCRUMB_HOME, + self::BREADCRUMB_ACCOUNT, + ['name' => 'Creer un evenement', 'url' => '/mon-compte/evenement/creer'], + ], + ]); + } + /** @codeCoverageIgnore Test helper, not used in production */ #[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])] public function testPayout(EntityManagerInterface $em): Response diff --git a/src/Entity/Event.php b/src/Entity/Event.php new file mode 100644 index 0000000..4182056 --- /dev/null +++ b/src/Entity/Event.php @@ -0,0 +1,214 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccount(): ?User + { + return $this->account; + } + + public function setAccount(?User $account): static + { + $this->account = $account; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getStartAt(): ?\DateTimeImmutable + { + return $this->startAt; + } + + public function setStartAt(\DateTimeImmutable $startAt): static + { + $this->startAt = $startAt; + + return $this; + } + + public function getEndAt(): ?\DateTimeImmutable + { + return $this->endAt; + } + + public function setEndAt(\DateTimeImmutable $endAt): static + { + $this->endAt = $endAt; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getAddress(): ?string + { + return $this->address; + } + + public function setAddress(string $address): static + { + $this->address = $address; + + return $this; + } + + public function getZipcode(): ?string + { + return $this->zipcode; + } + + public function setZipcode(string $zipcode): static + { + $this->zipcode = $zipcode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function isOnline(): bool + { + return $this->isOnline; + } + + public function setIsOnline(bool $isOnline): static + { + $this->isOnline = $isOnline; + + return $this; + } + + public function getEventMainPictureFile(): ?File + { + return $this->eventMainPictureFile; + } + + public function setEventMainPictureFile(?File $eventMainPictureFile): static + { + $this->eventMainPictureFile = $eventMainPictureFile; + + if ($eventMainPictureFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + + return $this; + } + + public function getEventMainPictureName(): ?string + { + return $this->eventMainPictureName; + } + + public function setEventMainPictureName(?string $eventMainPictureName): static + { + $this->eventMainPictureName = $eventMainPictureName; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Repository/EventRepository.php b/src/Repository/EventRepository.php new file mode 100644 index 0000000..1289066 --- /dev/null +++ b/src/Repository/EventRepository.php @@ -0,0 +1,18 @@ + + */ +class EventRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Event::class); + } +} diff --git a/templates/account/create_event.html.twig b/templates/account/create_event.html.twig new file mode 100644 index 0000000..a4a5fdb --- /dev/null +++ b/templates/account/create_event.html.twig @@ -0,0 +1,73 @@ +{% extends 'base.html.twig' %} + +{% block title %}Creer un evenement - E-Ticket{% endblock %} + +{% block body %} +
Renseignez les informations de votre evenement.
+ + {% for message in app.flashes('error') %} +{{ message }}
+Hello world
' + expect(sanitizeHtml(html)).toBe('Hello world
') + }) + + it('strips disallowed tags but keeps content', () => { + const html = 'Citation' + expect(sanitizeHtml(html)).toBe('Citation') + }) + + it('returns empty string for empty input', () => { + expect(sanitizeHtml('')).toBe('') + }) + + it('strips headings but keeps content', () => { + const html = '
Text
' + expect(sanitizeHtml(html)).toBe('Text
') + }) +}) + +function createEditor(innerHtml = '') { + registerEditor() + document.body.innerHTML = '' + const el = document.createElement('e-ticket-editor') + el.innerHTML = innerHtml + document.body.appendChild(el) + el.connectedCallback() + + return el +} + +describe('ETicketEditor', () => { + beforeEach(() => { + registerEditor() + }) + + it('registers the custom element', () => { + expect(globalThis.customElements.get('e-ticket-editor')).toBe(ETicketEditor) + }) + + it('hides the textarea and creates toolbar + content area', () => { + const editor = createEditor('') + const textarea = editor.querySelector('textarea') + const toolbar = editor.querySelector('.ete-toolbar') + const content = editor.querySelector('.ete-content') + + expect(textarea.style.display).toBe('none') + expect(toolbar).not.toBeNull() + expect(content).not.toBeNull() + expect(content.getAttribute('contenteditable')).toBe('true') + expect(content.innerHTML).toBe('Hello') + expect(content.dataset.placeholder).toBe('Ecrivez ici...') + }) + + it('does nothing without a textarea', () => { + const editor = createEditor('') + expect(editor.querySelector('.ete-toolbar')).toBeNull() + expect(editor.querySelector('.ete-content')).toBeNull() + }) + + it('toolbar has buttons', () => { + const editor = createEditor() + const buttons = editor.querySelectorAll('.ete-btn') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('toolbar has separators', () => { + const editor = createEditor() + const separators = editor.querySelectorAll('.ete-separator') + expect(separators.length).toBeGreaterThan(0) + }) + + it('getHtml returns textarea value', () => { + const editor = createEditor('') + expect(editor.getHtml()).toBe('Content') + }) + + it('prevents tab key default in content area', () => { + const editor = createEditor() + const content = editor.querySelector('.ete-content') + const event = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }) + content.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + + it('allows non-tab keys', () => { + const editor = createEditor() + const content = editor.querySelector('.ete-content') + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) + content.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + }) +})