Initial release
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# mummert-media/newssubmission-bundle
|
||||||
|
|
||||||
|
Contao 5.7 bundle for frontend news submissions with Symfony Forms, FilePond uploads and Altcha for anonymous users.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Bundle als Composer-Paket in das Contao-Projekt einbinden.
|
||||||
|
2. `composer update mummert-media/newssubmission-bundle`
|
||||||
|
3. `ddev exec vendor/bin/contao-console contao:migrate --no-interaction --no-ansi`
|
||||||
|
4. `ddev exec vendor/bin/contao-console cache:clear --no-ansi`
|
||||||
|
|
||||||
|
Hinweis: Die Datenbankfelder werden über die DCA-SQL-Definitionen des Bundles von `contao:migrate` angelegt.
|
||||||
|
|
||||||
|
## Backend-Konfiguration
|
||||||
|
|
||||||
|
1. Frontend-Modul **News-Einreichung** anlegen.
|
||||||
|
2. Felder setzen: `author`, `newsArchive`, `uploadFolder`, `thankYouPage`.
|
||||||
|
3. Tags einschränken über `allowedTags`.
|
||||||
|
4. Modul auf einer internen oder öffentlichen Seite einbinden.
|
||||||
|
|
||||||
|
## Verhalten
|
||||||
|
|
||||||
|
- Eingeloggt: Speicherung in `tl_news.submittedByMember` (UUID aus `tl_member.uuid`).
|
||||||
|
- Öffentlich: `submittedByMember` bleibt leer, optional `submittedByName`/`submittedByEmail`.
|
||||||
|
- Immer als Entwurf (`published=0`).
|
||||||
|
- Bild-Uploads laufen über FilePond-Endpoint mit temp storage und finalem Move beim Submit.
|
||||||
|
- Fotograf wird in `tl_news.caption` gespeichert (`Foto: <Name>`).
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "mummert-media/newssubmission-bundle",
|
||||||
|
"description": "Frontend news submission bundle for Contao 5.7.",
|
||||||
|
"type": "contao-bundle",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.4",
|
||||||
|
"contao/core-bundle": "^5.7",
|
||||||
|
"contao/news-bundle": "5.7.*",
|
||||||
|
"contao/manager-plugin": "^2.0",
|
||||||
|
"numero2/contao-tags": "^0.5",
|
||||||
|
"symfony/html-sanitizer": "^7.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"MummertMedia\\NewsSubmissionBundle\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"contao-manager-plugin": "MummertMedia\\NewsSubmissionBundle\\Contao\\Manager\\Plugin"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"contao-components/installer": true,
|
||||||
|
"contao/manager-plugin": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
|
||||||
|
Contao\CoreBundle\Altcha\Altcha: '@contao.altcha'
|
||||||
|
|
||||||
|
MummertMedia\NewsSubmissionBundle\:
|
||||||
|
resource: ../../src/
|
||||||
|
exclude:
|
||||||
|
- ../../src/DependencyInjection/
|
||||||
|
- ../../src/Contao/Manager/
|
||||||
|
- ../../src/NewsSubmissionBundle.php
|
||||||
|
|
||||||
|
MummertMedia\NewsSubmissionBundle\Service\UploadManager:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Contao\Database;
|
||||||
|
use Contao\StringUtil;
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['palettes']['news_submission'] = '{title_legend},name,headline,type;{news_submission_legend},author,newsArchive,uploadFolder,thankYouPage,allowedTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_submission_confirmation'] = '{title_legend},name,headline,type;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['fields']['author'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_module']['author'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'select',
|
||||||
|
'foreignKey' => 'tl_user.name',
|
||||||
|
'eval' => ['mandatory' => true, 'chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||||
|
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||||
|
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['fields']['newsArchive'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_module']['newsArchive'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'select',
|
||||||
|
'foreignKey' => 'tl_news_archive.title',
|
||||||
|
'eval' => ['mandatory' => true, 'chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'],
|
||||||
|
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||||
|
'relation' => ['type' => 'hasOne', 'load' => 'lazy'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['fields']['uploadFolder'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_module']['uploadFolder'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'fileTree',
|
||||||
|
'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'],
|
||||||
|
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['fields']['thankYouPage'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_module']['thankYouPage'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'pageTree',
|
||||||
|
'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'],
|
||||||
|
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_module']['fields']['allowedTags'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_module']['allowedTags'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'checkbox',
|
||||||
|
'options_callback' => static function (): array {
|
||||||
|
$rows = Database::getInstance()
|
||||||
|
->prepare("SELECT id, tag FROM tl_tags WHERE invisible='' ORDER BY tag ASC")
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssoc();
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$options[(int) $row['id']] = (string) $row['tag'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
},
|
||||||
|
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
|
||||||
|
'sql' => ['type' => 'blob', 'notnull' => false],
|
||||||
|
'save_callback' => [
|
||||||
|
static function ($value): array {
|
||||||
|
return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Contao\CoreBundle\DataContainer\PaletteManipulator;
|
||||||
|
use Contao\DataContainer;
|
||||||
|
use Contao\Database;
|
||||||
|
|
||||||
|
PaletteManipulator::create()
|
||||||
|
->addLegend('submission_legend', 'publish_legend', 'before')
|
||||||
|
->addField('submittedByMember', 'submission_legend', 'append')
|
||||||
|
->addField('submittedByName', 'submission_legend', 'append')
|
||||||
|
->addField('submittedByEmail', 'submission_legend', 'append')
|
||||||
|
->applyToPalette('default', 'tl_news')
|
||||||
|
->applyToPalette('internal', 'tl_news')
|
||||||
|
->applyToPalette('article', 'tl_news')
|
||||||
|
->applyToPalette('external', 'tl_news');
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_news']['config']['onsubmit_callback'][] = static function (DataContainer $dc): void {
|
||||||
|
$newsId = (int) ($dc->id ?? 0);
|
||||||
|
|
||||||
|
if ($newsId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$database = Database::getInstance();
|
||||||
|
|
||||||
|
$memberId = (int) $database
|
||||||
|
->prepare('SELECT submittedByMember FROM tl_news WHERE id=?')
|
||||||
|
->limit(1)
|
||||||
|
->execute($newsId)
|
||||||
|
->submittedByMember;
|
||||||
|
|
||||||
|
if ($memberId <= 0) {
|
||||||
|
$database
|
||||||
|
->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?')
|
||||||
|
->execute('', '', $newsId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = $database
|
||||||
|
->prepare('SELECT firstname, lastname, email FROM tl_member WHERE id=?')
|
||||||
|
->limit(1)
|
||||||
|
->execute($memberId)
|
||||||
|
->fetchAssoc();
|
||||||
|
|
||||||
|
if (false === $member || null === $member) {
|
||||||
|
$database
|
||||||
|
->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?')
|
||||||
|
->execute('', '', $newsId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) (($member['firstname'] ?? '').' '.($member['lastname'] ?? '')));
|
||||||
|
$email = trim((string) ($member['email'] ?? ''));
|
||||||
|
|
||||||
|
$database
|
||||||
|
->prepare('UPDATE tl_news SET submittedByName=?, submittedByEmail=? WHERE id=?')
|
||||||
|
->execute($name, $email, $newsId);
|
||||||
|
};
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByMember'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByMember'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'select',
|
||||||
|
'options_callback' => static function (): array {
|
||||||
|
$rows = Database::getInstance()
|
||||||
|
->prepare('SELECT id, firstname, lastname FROM tl_member ORDER BY lastname ASC, firstname ASC')
|
||||||
|
->execute()
|
||||||
|
->fetchAllAssoc();
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastName = trim((string) ($row['lastname'] ?? ''));
|
||||||
|
$firstName = trim((string) ($row['firstname'] ?? ''));
|
||||||
|
$label = trim($lastName.', '.$firstName, ' ,');
|
||||||
|
|
||||||
|
$options[$id] = '' !== $label ? $label : 'Mitglied #'.$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
},
|
||||||
|
'save_callback' => [
|
||||||
|
static function ($value): int {
|
||||||
|
return max(0, (int) $value);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'eval' => ['chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'clr w50'],
|
||||||
|
'sql' => ['type' => 'integer', 'unsigned' => true, 'notnull' => false],
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByName'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByName'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'text',
|
||||||
|
'eval' => ['readonly' => true, 'disabled' => true, 'tl_class' => 'w50'],
|
||||||
|
'sql' => "varchar(255) NOT NULL default ''",
|
||||||
|
];
|
||||||
|
|
||||||
|
$GLOBALS['TL_DCA']['tl_news']['fields']['submittedByEmail'] = [
|
||||||
|
'label' => &$GLOBALS['TL_LANG']['tl_news']['submittedByEmail'],
|
||||||
|
'exclude' => true,
|
||||||
|
'inputType' => 'text',
|
||||||
|
'eval' => ['readonly' => true, 'disabled' => true, 'rgxp' => 'email', 'tl_class' => 'w50'],
|
||||||
|
'sql' => "varchar(255) NOT NULL default ''",
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$GLOBALS['TL_LANG']['FMD']['news_submission'] = ['News-Einreichung', 'Frontend-Formular zur Einreichung von News.'];
|
||||||
|
$GLOBALS['TL_LANG']['FMD']['event_submission_confirmation'] = ['News-Einreichung Bestätigung', 'Zeigt die zuletzt eingereichten Inhalte auf der Danke-Seite erneut an.'];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['news_submission_legend'] = 'News-Einreichung';
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['author'] = ['Autor', 'Backend-Benutzer, der als Autor im News-Datensatz gesetzt wird.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['newsArchive'] = ['News-Archiv', 'Archiv, in dem neue Entwürfe angelegt werden.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['uploadFolder'] = ['Upload-Ordner', 'Basisordner für Bild-Uploads. Der Jahresordner wird automatisch darunter erstellt.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['thankYouPage'] = ['Danke-Seite', 'Seite, auf die nach erfolgreicher Einreichung weitergeleitet wird.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_module']['allowedTags'] = ['Erlaubte Tags', 'Diese vorhandenen Tags stehen im Frontend-Formular zur Auswahl bereit.'];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$GLOBALS['TL_LANG']['tl_news']['submission_legend'] = 'Einreichungsdaten';
|
||||||
|
$GLOBALS['TL_LANG']['tl_news']['submittedByMember'] = ['Eingereicht von (Mitglied)', 'Mitgliedszuordnung (UUID) bei eingeloggter Einreichung.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_news']['submittedByName'] = ['Öffentlicher Name', 'Optionaler Name bei anonymer Einreichung.'];
|
||||||
|
$GLOBALS['TL_LANG']['tl_news']['submittedByEmail'] = ['Öffentliche E-Mail', 'Optionale E-Mail bei anonymer Einreichung.'];
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "@Contao/frontend_module/_base.html.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not entry %}
|
||||||
|
<p>Es liegt aktuell keine Einreichung zur Bestätigung vor.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-headline">Überschrift</label>
|
||||||
|
<input id="ns-confirm-headline" type="text" value="{{ entry.headline }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-subheadline">Unterüberschrift</label>
|
||||||
|
<input id="ns-confirm-subheadline" type="text" value="{{ entry.subheadline }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-link">Externer Link</label>
|
||||||
|
<input id="ns-confirm-link" type="text" value="{{ entry.externalLink }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-tags">Tags</label>
|
||||||
|
<input id="ns-confirm-tags" type="text" value="{{ entry.tagList }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-text">Artikeltext</label>
|
||||||
|
<div id="ns-confirm-text">{{ entry.articleText|raw }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-add-image">Bild hochgeladen</label>
|
||||||
|
<input id="ns-confirm-add-image" type="text" value="{{ entry.addImage ? 'Ja' : 'Nein' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-photographer">Fotograf/Urheber</label>
|
||||||
|
<input id="ns-confirm-photographer" type="text" value="{{ entry.photographer }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-alt">Alternative Bildbeschreibung</label>
|
||||||
|
<input id="ns-confirm-alt" type="text" value="{{ entry.altText }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-name">submittedByName</label>
|
||||||
|
<input id="ns-confirm-name" type="text" value="{{ entry.submittedByName }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-confirm-email">submittedByEmail</label>
|
||||||
|
<input id="ns-confirm-email" type="text" value="{{ entry.submittedByEmail }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
{% extends "@Contao/frontend_module/_base.html.twig" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css">
|
||||||
|
<style>
|
||||||
|
.news-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
margin: .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.2rem;
|
||||||
|
padding: .4rem .65rem;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: .375rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button img {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: block;
|
||||||
|
filter: brightness(0) saturate(100%) invert(18%) sepia(18%) saturate(522%) hue-rotate(177deg) brightness(94%) contrast(91%);
|
||||||
|
transition: filter .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button:hover {
|
||||||
|
background: #eef2ff;
|
||||||
|
border-color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button:hover img {
|
||||||
|
filter: brightness(0) saturate(100%) invert(24%) sepia(57%) saturate(2496%) hue-rotate(231deg) brightness(87%) contrast(105%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button.is-active {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button.is-active img {
|
||||||
|
filter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(7486%) hue-rotate(63deg) brightness(106%) contrast(101%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button:disabled {
|
||||||
|
opacity: .45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-toolbar button:disabled img {
|
||||||
|
filter: brightness(0) saturate(100%) invert(49%) sepia(8%) saturate(616%) hue-rotate(175deg) brightness(91%) contrast(88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: .375rem;
|
||||||
|
padding: .75rem;
|
||||||
|
min-height: 14rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor .ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-counter {
|
||||||
|
margin-top: .5rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-counter.is-over-limit {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-shortcuts {
|
||||||
|
margin-top: .6rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-editor-shortcuts ul {
|
||||||
|
margin: .25rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-error-summary {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
border-radius: .375rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-error-summary ul {
|
||||||
|
margin: .4rem 0 0;
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-form-hint {
|
||||||
|
margin: 0 0 .85rem;
|
||||||
|
color: #334155;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switches {
|
||||||
|
display: grid;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-item label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-input {
|
||||||
|
appearance: none;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #94a3b8;
|
||||||
|
background: #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-input::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: .1rem;
|
||||||
|
left: .12rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, .2);
|
||||||
|
transition: transform .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-input:checked {
|
||||||
|
background: #4f46e5;
|
||||||
|
border-color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-input:checked::after {
|
||||||
|
transform: translateX(1.05rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ns-tag-switch-input:focus-visible {
|
||||||
|
outline: 2px solid #1d4ed8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% if errorMessage %}
|
||||||
|
<p role="alert">{{ errorMessage }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ form_start(form, {
|
||||||
|
action: app.request.uri,
|
||||||
|
attr: {
|
||||||
|
enctype: 'multipart/form-data',
|
||||||
|
id: 'news-submission-form',
|
||||||
|
'aria-describedby': 'news-form-hint'
|
||||||
|
}
|
||||||
|
}) }}
|
||||||
|
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
|
||||||
|
|
||||||
|
<p id="news-form-hint" class="news-form-hint">Pflichtfelder sind mit einem Stern markiert.</p>
|
||||||
|
|
||||||
|
{% if form.vars.submitted and not form.vars.valid %}
|
||||||
|
<div class="news-error-summary" role="alert" aria-labelledby="news-error-summary-title" tabindex="-1">
|
||||||
|
<strong id="news-error-summary-title">Bitte korrigieren Sie die markierten Felder:</strong>
|
||||||
|
<ul>
|
||||||
|
{% for field in form %}
|
||||||
|
{% if field.vars.errors|length > 0 and field.vars.id is not empty %}
|
||||||
|
<li>
|
||||||
|
<a href="#{{ field.vars.id }}">{{ field.vars.label|default(field.vars.name) }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ form_row(form.headline, { attr: { autocomplete: 'off' } }) }}
|
||||||
|
{{ form_row(form.subheadline, { attr: { autocomplete: 'off' } }) }}
|
||||||
|
{{ form_row(form.externalLink, { attr: { autocomplete: 'url', inputmode: 'url' } }) }}
|
||||||
|
|
||||||
|
<fieldset class="widget" aria-describedby="news-tags-help news-tags-errors">
|
||||||
|
<legend>{{ form.tags.vars.label }}</legend>
|
||||||
|
<p id="news-tags-help" class="news-form-hint">Mehrfachauswahl möglich. Mit Leertaste ein- und ausschalten.</p>
|
||||||
|
<div class="ns-tag-switches">
|
||||||
|
{% for tagField in form.tags %}
|
||||||
|
{% set tagLabelId = tagField.vars.id ~ '-label' %}
|
||||||
|
<div class="ns-tag-switch-item">
|
||||||
|
{{ form_widget(tagField, { attr: { 'aria-describedby': tagField.vars.id ~ '-state', 'aria-labelledby': tagLabelId } }) }}
|
||||||
|
{{ form_label(tagField, null, { label_attr: { id: tagLabelId } }) }}
|
||||||
|
<span
|
||||||
|
id="{{ tagField.vars.id }}-state"
|
||||||
|
class="visually-hidden"
|
||||||
|
data-switch-state-for="{{ tagField.vars.id }}"
|
||||||
|
>
|
||||||
|
{{ tagField.vars.checked ? 'Ein' : 'Aus' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div id="news-tags-errors">{{ form_errors(form.tags) }}</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="widget">
|
||||||
|
{{ form_label(form.articleText, null, { label_attr: { id: form.articleText.vars.id ~ '-label' } }) }}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="news-editor-toolbar"
|
||||||
|
data-news-editor-toolbar="{{ form.articleText.vars.id }}"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Formatierungswerkzeuge Artikeltext"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-describedby="{{ form.articleText.vars.id }}-shortcuts"
|
||||||
|
aria-controls="{{ form.articleText.vars.id }}-editor"
|
||||||
|
>
|
||||||
|
<button type="button" data-action="paragraph" title="Absatz">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/paragraph.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Absatz</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="h2" title="Überschrift H2">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/h2.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">H2</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="h3" title="Überschrift H3">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/h3.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">H3</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="bold" title="Fett (Strg/Cmd+B)" aria-keyshortcuts="Control+B Meta+B">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/bold.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Fett</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="italic" title="Kursiv (Strg/Cmd+I)" aria-keyshortcuts="Control+I Meta+I">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/italic.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Kursiv</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="underline" title="Unterstrichen (Strg/Cmd+U)" aria-keyshortcuts="Control+U Meta+U">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/underline.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Unterstrichen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="bulletList" title="Liste">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/ul.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Liste</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="orderedList" title="Nummerierte Liste">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/ol.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Nummerierte Liste</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="indent" title="Einzug vergrößern">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/indent.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Einzug vergrößern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="outdent" title="Einzug verkleinern">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/outdent.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Einzug verkleinern</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="undo" title="Rückgängig (Strg/Cmd+Z)" aria-keyshortcuts="Control+Z Meta+Z">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/undo.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Rückgängig</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="redo" title="Wiederholen (Strg/Cmd+Shift+Z)" aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z">
|
||||||
|
<img src="{{ asset('bundles/newssubmission/icons/redo.svg') }}" alt="" aria-hidden="true">
|
||||||
|
<span class="visually-hidden">Wiederholen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="{{ form.articleText.vars.id }}-shortcuts" class="news-editor-shortcuts">
|
||||||
|
<strong>Tastaturkürzel:</strong>
|
||||||
|
<ul>
|
||||||
|
<li><span aria-hidden="true">Strg/Cmd+B</span> <span class="visually-hidden">Tastenkürzel Strg oder Command plus B</span> – Fett</li>
|
||||||
|
<li><span aria-hidden="true">Strg/Cmd+I</span> <span class="visually-hidden">Tastenkürzel Strg oder Command plus I</span> – Kursiv</li>
|
||||||
|
<li><span aria-hidden="true">Strg/Cmd+U</span> <span class="visually-hidden">Tastenkürzel Strg oder Command plus U</span> – Unterstrichen</li>
|
||||||
|
<li><span aria-hidden="true">Strg/Cmd+Z</span> <span class="visually-hidden">Tastenkürzel Strg oder Command plus Z</span> – Rückgängig</li>
|
||||||
|
<li><span aria-hidden="true">Strg/Cmd+Shift+Z</span> <span class="visually-hidden">Tastenkürzel Strg oder Command plus Umschalt plus Z</span> – Wiederholen</li>
|
||||||
|
<li>Pfeiltasten links/rechts in der Toolbar – zwischen Buttons wechseln</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="{{ form.articleText.vars.id }}-editor"
|
||||||
|
class="news-editor"
|
||||||
|
data-news-editor="tiptap"
|
||||||
|
data-textarea-id="{{ form.articleText.vars.id }}"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-labelledby="{{ form.articleText.vars.id }}-label"
|
||||||
|
aria-describedby="{{ form.articleText.vars.id }}-shortcuts {{ form.articleText.vars.id }}-counter {{ form.articleText.vars.id }}-errors"
|
||||||
|
></div>
|
||||||
|
<div id="{{ form.articleText.vars.id }}-counter" class="news-editor-counter" data-news-editor-counter-for="{{ form.articleText.vars.id }}" role="status" aria-live="polite"></div>
|
||||||
|
{{ form_widget(form.articleText, { attr: { class: 'js-news-article-source', rows: 8, 'aria-hidden': 'true', tabindex: '-1' } }) }}
|
||||||
|
<div id="{{ form.articleText.vars.id }}-errors">{{ form_errors(form.articleText) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="widget" aria-describedby="news-image-toggle-help {{ form.addImage.vars.id }}-state">
|
||||||
|
<div class="ns-tag-switch-item">
|
||||||
|
{{ form_widget(form.addImage, { attr: {
|
||||||
|
'aria-describedby': form.addImage.vars.id ~ '-state',
|
||||||
|
'aria-controls': 'news-image-fields',
|
||||||
|
'aria-expanded': form.addImage.vars.checked ? 'true' : 'false',
|
||||||
|
'aria-labelledby': form.addImage.vars.id ~ '-label'
|
||||||
|
} }) }}
|
||||||
|
{{ form_label(form.addImage, null, { label_attr: { id: form.addImage.vars.id ~ '-label' } }) }}
|
||||||
|
<span
|
||||||
|
id="{{ form.addImage.vars.id }}-state"
|
||||||
|
class="visually-hidden"
|
||||||
|
data-switch-state-for="{{ form.addImage.vars.id }}"
|
||||||
|
>
|
||||||
|
{{ form.addImage.vars.checked ? 'Ein' : 'Aus' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p id="news-image-toggle-help" class="news-form-hint">Wenn aktiviert, werden Felder für Bild, Fotograf und Alternativtext eingeblendet.</p>
|
||||||
|
{{ form_errors(form.addImage) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="news-image-fields" style="display:none;" hidden aria-hidden="true" role="group" aria-label="Bildangaben" aria-live="polite">
|
||||||
|
{{ form_row(form.imageUpload) }}
|
||||||
|
{{ form_row(form.imageToken) }}
|
||||||
|
{{ form_row(form.photographer) }}
|
||||||
|
{{ form_row(form.altText) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.publicName is defined %}
|
||||||
|
{{ form_row(form.publicName) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if form.publicEmail is defined %}
|
||||||
|
{{ form_row(form.publicEmail) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ form_row(form.honeypot) }}
|
||||||
|
|
||||||
|
{% if showAltcha %}
|
||||||
|
<div class="widget">
|
||||||
|
<label for="ns-altcha" id="ns-altcha-label">Sicherheitsprüfung</label>
|
||||||
|
<altcha-widget
|
||||||
|
id="ns-altcha"
|
||||||
|
name="altcha"
|
||||||
|
challengeurl="{{ altchaChallengeUrl }}"
|
||||||
|
maxnumber="{{ altchaMaxNumber }}"
|
||||||
|
aria-labelledby="ns-altcha-label"
|
||||||
|
></altcha-widget>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="https://cdn.jsdelivr.net/npm/altcha/dist/altcha.min.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit">News einreichen</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/filepond/dist/filepond.min.js"></script>
|
||||||
|
<script type="module" src="{{ asset('bundles/newssubmission/editor.js') }}?v=9"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const errorSummary = document.querySelector('.news-error-summary');
|
||||||
|
const addImage = document.getElementById('{{ form.addImage.vars.id }}');
|
||||||
|
const imageWrap = document.getElementById('news-image-fields');
|
||||||
|
const photographer = document.getElementById('{{ form.photographer.vars.id }}');
|
||||||
|
const fileInput = document.querySelector('input.js-news-filepond');
|
||||||
|
const tokenInput = document.getElementById('{{ form.imageToken.vars.id }}');
|
||||||
|
const tagSwitches = document.querySelectorAll('input.ns-tag-switch-input[role="switch"]');
|
||||||
|
|
||||||
|
if (errorSummary) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
errorSummary.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncSwitchState = (input) => {
|
||||||
|
const isChecked = !!input.checked;
|
||||||
|
input.setAttribute('aria-checked', isChecked ? 'true' : 'false');
|
||||||
|
|
||||||
|
const stateNode = document.querySelector(`[data-switch-state-for="${input.id}"]`);
|
||||||
|
|
||||||
|
if (stateNode) {
|
||||||
|
stateNode.textContent = isChecked ? 'Ein' : 'Aus';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tagSwitches.forEach((input) => {
|
||||||
|
syncSwitchState(input);
|
||||||
|
input.addEventListener('change', () => syncSwitchState(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateImageState = () => {
|
||||||
|
if (!addImage || !imageWrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = addImage.checked;
|
||||||
|
addImage.setAttribute('aria-checked', active ? 'true' : 'false');
|
||||||
|
addImage.setAttribute('aria-expanded', active ? 'true' : 'false');
|
||||||
|
|
||||||
|
const addImageStateNode = document.querySelector(`[data-switch-state-for="${addImage.id}"]`);
|
||||||
|
|
||||||
|
if (addImageStateNode) {
|
||||||
|
addImageStateNode.textContent = active ? 'Ein' : 'Aus';
|
||||||
|
}
|
||||||
|
|
||||||
|
imageWrap.style.display = active ? '' : 'none';
|
||||||
|
imageWrap.hidden = !active;
|
||||||
|
imageWrap.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||||
|
|
||||||
|
if (!active && imageWrap.contains(document.activeElement)) {
|
||||||
|
addImage.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photographer) {
|
||||||
|
photographer.required = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active && tokenInput) {
|
||||||
|
tokenInput.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (addImage) {
|
||||||
|
addImage.addEventListener('change', updateImageState);
|
||||||
|
updateImageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInput && tokenInput && typeof FilePond !== 'undefined') {
|
||||||
|
const pond = FilePond.create(fileInput, {
|
||||||
|
allowMultiple: false,
|
||||||
|
instantUpload: true,
|
||||||
|
acceptedFileTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||||
|
maxFileSize: '6MB',
|
||||||
|
credits: false,
|
||||||
|
server: {
|
||||||
|
process: {
|
||||||
|
url: '{{ uploadProcessUrl }}',
|
||||||
|
method: 'POST',
|
||||||
|
ondata: (formData) => {
|
||||||
|
formData.append('REQUEST_TOKEN', '{{ requestToken }}');
|
||||||
|
return formData;
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': '{{ requestToken }}'
|
||||||
|
},
|
||||||
|
onload: (response) => {
|
||||||
|
tokenInput.value = response;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revert: {
|
||||||
|
url: '{{ uploadRevertUrl }}',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': '{{ requestToken }}'
|
||||||
|
},
|
||||||
|
onload: () => {
|
||||||
|
tokenInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (addImage) {
|
||||||
|
addImage.addEventListener('change', () => {
|
||||||
|
if (!addImage.checked) {
|
||||||
|
pond.removeFiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { Editor } from 'https://esm.sh/@tiptap/core@3';
|
||||||
|
import Document from 'https://esm.sh/@tiptap/extension-document@3';
|
||||||
|
import Paragraph from 'https://esm.sh/@tiptap/extension-paragraph@3';
|
||||||
|
import Text from 'https://esm.sh/@tiptap/extension-text@3';
|
||||||
|
import Heading from 'https://esm.sh/@tiptap/extension-heading@3';
|
||||||
|
import Bold from 'https://esm.sh/@tiptap/extension-bold@3';
|
||||||
|
import Italic from 'https://esm.sh/@tiptap/extension-italic@3';
|
||||||
|
import Underline from 'https://esm.sh/@tiptap/extension-underline@3';
|
||||||
|
import BulletList from 'https://esm.sh/@tiptap/extension-bullet-list@3';
|
||||||
|
import OrderedList from 'https://esm.sh/@tiptap/extension-ordered-list@3';
|
||||||
|
import ListItem from 'https://esm.sh/@tiptap/extension-list-item@3';
|
||||||
|
import CharacterCount from 'https://esm.sh/@tiptap/extension-character-count@3';
|
||||||
|
import History from 'https://esm.sh/@tiptap/extension-history@3';
|
||||||
|
|
||||||
|
const ARTICLE_TEXT_CHARACTER_LIMIT = 30000;
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const editorMounts = document.querySelectorAll('[data-news-editor="tiptap"]');
|
||||||
|
|
||||||
|
if (!editorMounts.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncState = (editor, textarea) => {
|
||||||
|
textarea.value = editor.getHTML();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditorA11yState = (mount, textareaId, editor) => {
|
||||||
|
if (!mount || !textareaId || !editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorNode = document.getElementById(`${textareaId}-errors`);
|
||||||
|
const hasError = !!(errorNode && errorNode.textContent && errorNode.textContent.trim().length > 0);
|
||||||
|
const plainTextLength = editor.storage?.characterCount?.characters?.() ?? 0;
|
||||||
|
const isEmpty = plainTextLength === 0;
|
||||||
|
|
||||||
|
mount.setAttribute('aria-invalid', hasError ? 'true' : 'false');
|
||||||
|
mount.setAttribute('aria-required', 'true');
|
||||||
|
|
||||||
|
const proseMirror = mount.querySelector('.ProseMirror');
|
||||||
|
|
||||||
|
if (!proseMirror) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proseMirror.setAttribute('role', 'textbox');
|
||||||
|
proseMirror.setAttribute('aria-multiline', 'true');
|
||||||
|
proseMirror.setAttribute('aria-invalid', hasError ? 'true' : 'false');
|
||||||
|
proseMirror.setAttribute('aria-required', 'true');
|
||||||
|
proseMirror.setAttribute('aria-label', 'Artikeltext');
|
||||||
|
proseMirror.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCharacterCounter = (counterNode, editor) => {
|
||||||
|
if (!counterNode || !editor?.storage?.characterCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = editor.storage.characterCount.characters();
|
||||||
|
const words = editor.storage.characterCount.words();
|
||||||
|
const remaining = ARTICLE_TEXT_CHARACTER_LIMIT - used;
|
||||||
|
|
||||||
|
counterNode.textContent = `${words.toLocaleString('de-DE')} Wörter · ${used.toLocaleString('de-DE')} / ${ARTICLE_TEXT_CHARACTER_LIMIT.toLocaleString('de-DE')} Zeichen`;
|
||||||
|
counterNode.classList.toggle('is-over-limit', remaining < 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupToolbarKeyboardNavigation = (toolbar) => {
|
||||||
|
if (!toolbar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnabledButtons = () => Array.from(toolbar.querySelectorAll('button[data-action]:not(:disabled)'));
|
||||||
|
|
||||||
|
toolbar.addEventListener('keydown', (event) => {
|
||||||
|
const activeElement = event.target.closest('button[data-action]');
|
||||||
|
|
||||||
|
if (!activeElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = getEnabledButtons();
|
||||||
|
|
||||||
|
if (!buttons.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = buttons.indexOf(activeElement);
|
||||||
|
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = currentIndex;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
nextIndex = (currentIndex + 1) % buttons.length;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
nextIndex = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
nextIndex = buttons.length - 1;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault();
|
||||||
|
activeElement.click();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
buttons[nextIndex].focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateToolbarState = (toolbar, editor) => {
|
||||||
|
if (!toolbar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canIndent = editor.can().chain().focus().sinkListItem('listItem').run();
|
||||||
|
const canOutdent = editor.can().chain().focus().liftListItem('listItem').run();
|
||||||
|
const canUndo = editor.can().chain().focus().undo().run();
|
||||||
|
const canRedo = editor.can().chain().focus().redo().run();
|
||||||
|
|
||||||
|
toolbar.querySelectorAll('button[data-action]').forEach((button) => {
|
||||||
|
const action = button.getAttribute('data-action');
|
||||||
|
let isActive = false;
|
||||||
|
let isDisabled = false;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'paragraph':
|
||||||
|
isActive = editor.isActive('paragraph');
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
isActive = editor.isActive('heading', { level: 2 });
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
isActive = editor.isActive('heading', { level: 3 });
|
||||||
|
break;
|
||||||
|
case 'bold':
|
||||||
|
isActive = editor.isActive('bold');
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
isActive = editor.isActive('italic');
|
||||||
|
break;
|
||||||
|
case 'underline':
|
||||||
|
isActive = editor.isActive('underline');
|
||||||
|
break;
|
||||||
|
case 'bulletList':
|
||||||
|
isActive = editor.isActive('bulletList');
|
||||||
|
break;
|
||||||
|
case 'orderedList':
|
||||||
|
isActive = editor.isActive('orderedList');
|
||||||
|
break;
|
||||||
|
case 'indent':
|
||||||
|
isDisabled = !canIndent;
|
||||||
|
break;
|
||||||
|
case 'outdent':
|
||||||
|
isDisabled = !canOutdent;
|
||||||
|
break;
|
||||||
|
case 'undo':
|
||||||
|
isDisabled = !canUndo;
|
||||||
|
break;
|
||||||
|
case 'redo':
|
||||||
|
isDisabled = !canRedo;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
isActive = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.classList.toggle('is-active', isActive);
|
||||||
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||||
|
button.disabled = isDisabled;
|
||||||
|
button.setAttribute('aria-disabled', isDisabled ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
editorMounts.forEach((mount) => {
|
||||||
|
const textareaId = mount.getAttribute('data-textarea-id');
|
||||||
|
|
||||||
|
if (!textareaId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.display = 'none';
|
||||||
|
|
||||||
|
const form = textarea.closest('form');
|
||||||
|
const toolbar = document.querySelector(`[data-news-editor-toolbar="${textareaId}"]`);
|
||||||
|
const counterNode = document.querySelector(`[data-news-editor-counter-for="${textareaId}"]`);
|
||||||
|
|
||||||
|
setupToolbarKeyboardNavigation(toolbar);
|
||||||
|
|
||||||
|
const editor = new Editor({
|
||||||
|
element: mount,
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Heading.configure({ levels: [2, 3] }),
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
BulletList,
|
||||||
|
OrderedList,
|
||||||
|
ListItem,
|
||||||
|
History,
|
||||||
|
CharacterCount.configure({
|
||||||
|
limit: ARTICLE_TEXT_CHARACTER_LIMIT,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: textarea.value || '<p></p>',
|
||||||
|
onCreate({ editor: currentEditor }) {
|
||||||
|
updateToolbarState(toolbar, currentEditor);
|
||||||
|
updateCharacterCounter(counterNode, currentEditor);
|
||||||
|
updateEditorA11yState(mount, textareaId, currentEditor);
|
||||||
|
},
|
||||||
|
onUpdate({ editor: currentEditor }) {
|
||||||
|
syncState(currentEditor, textarea);
|
||||||
|
updateToolbarState(toolbar, currentEditor);
|
||||||
|
updateCharacterCounter(counterNode, currentEditor);
|
||||||
|
updateEditorA11yState(mount, textareaId, currentEditor);
|
||||||
|
},
|
||||||
|
onSelectionUpdate({ editor: currentEditor }) {
|
||||||
|
updateToolbarState(toolbar, currentEditor);
|
||||||
|
updateEditorA11yState(mount, textareaId, currentEditor);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.on('transaction', () => {
|
||||||
|
updateToolbarState(toolbar, editor);
|
||||||
|
updateCharacterCounter(counterNode, editor);
|
||||||
|
updateEditorA11yState(mount, textareaId, editor);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.on('focus', () => {
|
||||||
|
updateToolbarState(toolbar, editor);
|
||||||
|
updateCharacterCounter(counterNode, editor);
|
||||||
|
updateEditorA11yState(mount, textareaId, editor);
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.on('blur', () => {
|
||||||
|
updateToolbarState(toolbar, editor);
|
||||||
|
updateCharacterCounter(counterNode, editor);
|
||||||
|
updateEditorA11yState(mount, textareaId, editor);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.addEventListener('click', (event) => {
|
||||||
|
const target = event.target.closest('button[data-action]');
|
||||||
|
|
||||||
|
if (!target || target.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const action = target.getAttribute('data-action');
|
||||||
|
const chain = editor.chain().focus();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'paragraph':
|
||||||
|
chain.setParagraph().run();
|
||||||
|
break;
|
||||||
|
case 'h2':
|
||||||
|
chain.toggleHeading({ level: 2 }).run();
|
||||||
|
break;
|
||||||
|
case 'h3':
|
||||||
|
chain.toggleHeading({ level: 3 }).run();
|
||||||
|
break;
|
||||||
|
case 'bold':
|
||||||
|
chain.toggleBold().run();
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
chain.toggleItalic().run();
|
||||||
|
break;
|
||||||
|
case 'underline':
|
||||||
|
chain.toggleUnderline().run();
|
||||||
|
break;
|
||||||
|
case 'bulletList':
|
||||||
|
chain.toggleBulletList().run();
|
||||||
|
break;
|
||||||
|
case 'orderedList':
|
||||||
|
chain.toggleOrderedList().run();
|
||||||
|
break;
|
||||||
|
case 'indent':
|
||||||
|
chain.sinkListItem('listItem').run();
|
||||||
|
break;
|
||||||
|
case 'outdent':
|
||||||
|
chain.liftListItem('listItem').run();
|
||||||
|
break;
|
||||||
|
case 'undo':
|
||||||
|
chain.undo().run();
|
||||||
|
break;
|
||||||
|
case 'redo':
|
||||||
|
chain.redo().run();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncState(editor, textarea);
|
||||||
|
updateToolbarState(toolbar, editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
syncState(editor, textarea);
|
||||||
|
updateEditorA11yState(mount, textareaId, editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEditorA11yState(mount, textareaId, editor);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M5,14 C7.76005315,14.0033061 9.99669388,16.2399468 10,19 C10,21.7614237 7.76142375,24 5,24 C2.23857625,24 1.77635684e-15,21.7614237 1.77635684e-15,19 C1.77635684e-15,16.2385763 2.23857625,14 5,14 Z M7.5,19.9375 C8.01776695,19.9375 8.4375,19.517767 8.4375,19 C8.4375,18.482233 8.01776695,18.0625 7.5,18.0625 L6.25,18.0625 C6.07741102,18.0625 5.9375,17.922589 5.9375,17.75 L5.9375,16.5 C5.9375,15.982233 5.51776695,15.5625 5,15.5625 C4.48223305,15.5625 4.0625,15.982233 4.0625,16.5 L4.0625,17.75 C4.0625,17.922589 3.92258898,18.0625 3.75,18.0625 L2.5,18.0625 C1.98223305,18.0625 1.5625,18.482233 1.5625,19 C1.5625,19.517767 1.98223305,19.9375 2.5,19.9375 L3.75,19.9375 C3.92258898,19.9375 4.0625,20.077411 4.0625,20.25 L4.0625,21.5 C4.0625,22.017767 4.48223305,22.4375 5,22.4375 C5.51776695,22.4375 5.9375,22.017767 5.9375,21.5 L5.9375,20.25 C5.9375,20.077411 6.07741102,19.9375 6.25,19.9375 L7.5,19.9375 Z M16,19 C16,20.6568542 17.3431458,22 19,22 C20.6568542,22 22,20.6568542 22,19 L22,5 C22,3.34314575 20.6568542,2 19,2 C17.3431458,2 16,3.34314575 16,5 L16,19 Z M14,19 L14,5 C14,2.23857625 16.2385763,0 19,0 C21.7614237,0 24,2.23857625 24,5 L24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M19,14 C21.7600532,14.0033061 23.9966939,16.2399468 24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 C14,16.2385763 16.2385763,14 19,14 Z M21.5,19.9375 C22.017767,19.9375 22.4375,19.517767 22.4375,19 C22.4375,18.482233 22.017767,18.0625 21.5,18.0625 L20.25,18.0625 C20.077411,18.0625 19.9375,17.922589 19.9375,17.75 L19.9375,16.5 C19.9375,15.982233 19.517767,15.5625 19,15.5625 C18.482233,15.5625 18.0625,15.982233 18.0625,16.5 L18.0625,17.75 C18.0625,17.922589 17.922589,18.0625 17.75,18.0625 L16.5,18.0625 C15.982233,18.0625 15.5625,18.482233 15.5625,19 C15.5625,19.517767 15.982233,19.9375 16.5,19.9375 L17.75,19.9375 C17.922589,19.9375 18.0625,20.077411 18.0625,20.25 L18.0625,21.5 C18.0625,22.017767 18.482233,22.4375 19,22.4375 C19.517767,22.4375 19.9375,22.017767 19.9375,21.5 L19.9375,20.25 C19.9375,20.077411 20.077411,19.9375 20.25,19.9375 L21.5,19.9375 Z M2,19 C2,20.6568542 3.34314575,22 5,22 C6.65685425,22 8,20.6568542 8,19 L8,5 C8,3.34314575 6.65685425,2 5,2 C3.34314575,2 2,3.34314575 2,5 L2,19 Z M-2.7585502e-16,19 L5.81397739e-16,5 C-1.37692243e-16,2.23857625 2.23857625,0 5,0 C7.76142375,0 10,2.23857625 10,5 L10,19 C10,21.7614237 7.76142375,24 5,24 C2.23857625,24 4.43234962e-16,21.7614237 -2.7585502e-16,19 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M19,0 C21.7600532,0.00330611633 23.9966939,2.23994685 24,5 C24,7.76142375 21.7614237,10 19,10 C16.2385763,10 14,7.76142375 14,5 C14,2.23857625 16.2385763,0 19,0 Z M21.5,5.9375 C22.017767,5.9375 22.4375,5.51776695 22.4375,5 C22.4375,4.48223305 22.017767,4.0625 21.5,4.0625 L20.25,4.0625 C20.077411,4.0625 19.9375,3.92258898 19.9375,3.75 L19.9375,2.5 C19.9375,1.98223305 19.517767,1.5625 19,1.5625 C18.482233,1.5625 18.0625,1.98223305 18.0625,2.5 L18.0625,3.75 C18.0625,3.92258898 17.922589,4.0625 17.75,4.0625 L16.5,4.0625 C15.982233,4.0625 15.5625,4.48223305 15.5625,5 C15.5625,5.51776695 15.982233,5.9375 16.5,5.9375 L17.75,5.9375 C17.922589,5.9375 18.0625,6.07741102 18.0625,6.25 L18.0625,7.5 C18.0625,8.01776695 18.482233,8.4375 19,8.4375 C19.517767,8.4375 19.9375,8.01776695 19.9375,7.5 L19.9375,6.25 C19.9375,6.07741102 20.077411,5.9375 20.25,5.9375 L21.5,5.9375 Z M5,16 C3.34314575,16 2,17.3431458 2,19 C2,20.6568542 3.34314575,22 5,22 L19,22 C20.6568542,22 22,20.6568542 22,19 C22,17.3431458 20.6568542,16 19,16 L5,16 Z M5,14 L19,14 C21.7614237,14 24,16.2385763 24,19 C24,21.7614237 21.7614237,24 19,24 L5,24 C2.23857625,24 3.38176876e-16,21.7614237 0,19 C-1.2263553e-15,16.2385763 2.23857625,14 5,14 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M19,14 C21.7600532,14.0033061 23.9966939,16.2399468 24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 C14,16.2385763 16.2385763,14 19,14 Z M21.5,19.9375 C22.017767,19.9375 22.4375,19.517767 22.4375,19 C22.4375,18.482233 22.017767,18.0625 21.5,18.0625 L20.25,18.0625 C20.077411,18.0625 19.9375,17.922589 19.9375,17.75 L19.9375,16.5 C19.9375,15.982233 19.517767,15.5625 19,15.5625 C18.482233,15.5625 18.0625,15.982233 18.0625,16.5 L18.0625,17.75 C18.0625,17.922589 17.922589,18.0625 17.75,18.0625 L16.5,18.0625 C15.982233,18.0625 15.5625,18.482233 15.5625,19 C15.5625,19.517767 15.982233,19.9375 16.5,19.9375 L17.75,19.9375 C17.922589,19.9375 18.0625,20.077411 18.0625,20.25 L18.0625,21.5 C18.0625,22.017767 18.482233,22.4375 19,22.4375 C19.517767,22.4375 19.9375,22.017767 19.9375,21.5 L19.9375,20.25 C19.9375,20.077411 20.077411,19.9375 20.25,19.9375 L21.5,19.9375 Z M5,2 C3.34314575,2 2,3.34314575 2,5 C2,6.65685425 3.34314575,8 5,8 L19,8 C20.6568542,8 22,6.65685425 22,5 C22,3.34314575 20.6568542,2 19,2 L5,2 Z M5,0 L19,0 C21.7614237,-5.07265313e-16 24,2.23857625 24,5 C24,7.76142375 21.7614237,10 19,10 L5,10 C2.23857625,10 3.38176876e-16,7.76142375 0,5 C-1.2263553e-15,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-bold</title><path d="M17.194,10.962A6.271,6.271,0,0,0,12.844.248H4.3a1.25,1.25,0,0,0,0,2.5H5.313a.25.25,0,0,1,.25.25V21a.25.25,0,0,1-.25.25H4.3a1.25,1.25,0,1,0,0,2.5h9.963a6.742,6.742,0,0,0,2.93-12.786Zm-4.35-8.214a3.762,3.762,0,0,1,0,7.523H8.313a.25.25,0,0,1-.25-.25V3a.25.25,0,0,1,.25-.25Zm1.42,18.5H8.313a.25.25,0,0,1-.25-.25V13.021a.25.25,0,0,1,.25-.25h4.531c.017,0,.033,0,.049,0l.013,0h1.358a4.239,4.239,0,0,1,0,8.477Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 505 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>checklist-alternate</title><path d="M21,0H3A3,3,0,0,0,0,3V21a3,3,0,0,0,3,3H21a3,3,0,0,0,3-3V3A3,3,0,0,0,21,0Zm1,21a1,1,0,0,1-1,1H3a1,1,0,0,1-1-1V3A1,1,0,0,1,3,2H21a1,1,0,0,1,1,1Z"/><path d="M11.249,4.5a1.251,1.251,0,0,0-1.75.25L7.365,7.6l-.482-.481A1.25,1.25,0,0,0,5.116,8.883l1.5,1.5A1.262,1.262,0,0,0,8.5,10.249l3-4A1.25,1.25,0,0,0,11.249,4.5Z"/><path d="M11.249,13.5a1.251,1.251,0,0,0-1.75.25L7.365,16.6l-.482-.481a1.25,1.25,0,1,0-1.767,1.768l1.5,1.5A1.265,1.265,0,0,0,8.5,19.249l3-4A1.25,1.25,0,0,0,11.249,13.5Z"/><path d="M18.5,7.749H14a1.25,1.25,0,0,0,0,2.5h4.5a1.25,1.25,0,0,0,0-2.5Z"/><path d="M18.5,15.749H14a1.25,1.25,0,0,0,0,2.5h4.5a1.25,1.25,0,1,0,0-2.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>angle-brackets</title><path d="M9.147,21.552a1.244,1.244,0,0,1-.895-.378L.84,13.561a2.257,2.257,0,0,1,0-3.125L8.252,2.823a1.25,1.25,0,0,1,1.791,1.744l-6.9,7.083a.5.5,0,0,0,0,.7l6.9,7.082a1.25,1.25,0,0,1-.9,2.122Z"/><path d="M14.854,21.552a1.25,1.25,0,0,1-.9-2.122l6.9-7.083a.5.5,0,0,0,0-.7l-6.9-7.082a1.25,1.25,0,0,1,1.791-1.744l7.411,7.612a2.257,2.257,0,0,1,0,3.125l-7.412,7.614A1.244,1.244,0,0,1,14.854,21.552Zm6.514-9.373h0Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M2,19 C2,20.6568542 3.34314575,22 5,22 L19,22 C20.6568542,22 22,20.6568542 22,19 L22,5 C22,3.34314575 20.6568542,2 19,2 L5,2 C3.34314575,2 2,3.34314575 2,5 L2,19 Z M-1.16403344e-15,19 L-3.0678068e-16,5 C-6.44957556e-16,2.23857625 2.23857625,0 5,0 L19,0 C21.7614237,0 24,2.23857625 24,5 L24,19 C24,21.7614237 21.7614237,24 19,24 L5,24 C2.23857625,24 9.50500275e-16,21.7614237 -1.16403344e-15,19 Z M12,10 C12.5522847,10 13,10.4477153 13,11 L13,13 C13,13.5522847 12.5522847,14 12,14 C11.4477153,14 11,13.5522847 11,13 L11,11 C11,10.4477153 11.4477153,10 12,10 Z M12,16 C12.5522847,16 13,16.4477153 13,17 L13,20 C13,20.5522847 12.5522847,21 12,21 C11.4477153,21 11,20.5522847 11,20 L11,17 C11,16.4477153 11.4477153,16 12,16 Z M12,3 C12.5522847,3 13,3.44771525 13,4 L13,7 C13,7.55228475 12.5522847,8 12,8 C11.4477153,8 11,7.55228475 11,7 L11,4 C11,3.44771525 11.4477153,3 12,3 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 956 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M12.6414391,21.9312708 C12.9358807,22.5689168 13.3234155,23.1547532 13.7866134,23.6713497 C13.2317936,23.8836754 12.6294813,24 12,24 C9.23857625,24 7,21.7614237 7,19 L7,5 C7,2.23857625 9.23857625,0 12,0 C14.7614237,0 17,2.23857625 17,5 L17,12.2898787 C16.2775651,12.5048858 15.6040072,12.8333806 15,13.2546893 L15,5 C15,3.34314575 13.6568542,2 12,2 C10.3431458,2 9,3.34314575 9,5 L9,19 C9,20.6568542 10.3431458,22 12,22 C12.220157,22 12.4347751,21.9762852 12.6414391,21.9312708 Z M19,14 C21.7600532,14.0033061 23.9966939,16.2399468 24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 C14,16.2385763 16.2385763,14 19,14 Z M16.5,19.9375 L21.5,19.9375 C22.017767,19.9375 22.4375,19.517767 22.4375,19 C22.4375,18.482233 22.017767,18.0625 21.5,18.0625 L16.5,18.0625 C15.982233,18.0625 15.5625,18.482233 15.5625,19 C15.5625,19.517767 15.982233,19.9375 16.5,19.9375 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 967 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M13.2546893,15 C12.8333806,15.6040072 12.5048858,16.2775651 12.2898787,17 L5,17 C2.23857625,17 3.38176876e-16,14.7614237 0,12 C-1.2263553e-15,9.23857625 2.23857625,7 5,7 L19,7 C21.7614237,7 24,9.23857625 24,12 C24,12.6294813 23.8836754,13.2317936 23.6713497,13.7866134 C23.1547532,13.3234155 22.5689168,12.9358807 21.9312708,12.6414391 C21.9762852,12.4347751 22,12.220157 22,12 C22,10.3431458 20.6568542,9 19,9 L5,9 C3.34314575,9 2,10.3431458 2,12 C2,13.6568542 3.34314575,15 5,15 L13.2546893,15 Z M19,14 C21.7600532,14.0033061 23.9966939,16.2399468 24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 C14,16.2385763 16.2385763,14 19,14 Z M16.5,19.9375 L21.5,19.9375 C22.017767,19.9375 22.4375,19.517767 22.4375,19 C22.4375,18.482233 22.017767,18.0625 21.5,18.0625 L16.5,18.0625 C15.982233,18.0625 15.5625,18.482233 15.5625,19 C15.5625,19.517767 15.982233,19.9375 16.5,19.9375 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 985 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M19,14 C21.7600532,14.0033061 23.9966939,16.2399468 24,19 C24,21.7614237 21.7614237,24 19,24 C16.2385763,24 14,21.7614237 14,19 C14,16.2385763 16.2385763,14 19,14 Z M16.5,19.9375 L21.5,19.9375 C22.017767,19.9375 22.4375,19.517767 22.4375,19 C22.4375,18.482233 22.017767,18.0625 21.5,18.0625 L16.5,18.0625 C15.982233,18.0625 15.5625,18.482233 15.5625,19 C15.5625,19.517767 15.982233,19.9375 16.5,19.9375 Z M12.2898787,17 L9,17 L9,22 L12.6736312,22 C13.0297295,22.7496048 13.515133,23.4258795 14.1010173,24 L5,24 C2.23857625,24 -1.43817996e-15,21.7614237 -1.77635684e-15,19 L-3.55271368e-15,5 C-3.89089055e-15,2.23857625 2.23857625,5.07265313e-16 5,-1.77635684e-15 L19,-1.77635684e-15 C21.7614237,-2.28362215e-15 24,2.23857625 24,5 L24,7.82313285 C24.0122947,7.88054124 24.0187107,7.93964623 24.0187107,8 C24.0187107,8.06035377 24.0122947,8.11945876 24,8.17686715 L24,14.1010173 C23.4258795,13.515133 22.7496048,13.0297295 22,12.6736312 L22,9 L17,9 L17,12.2898787 C16.2775651,12.5048858 15.6040072,12.8333806 15,13.2546893 L15,9 L9,9 L9,15 L13.2546893,15 C12.8333806,15.6040072 12.5048858,16.2775651 12.2898787,17 Z M17,7 L22,7 L22,5 C22,3.34314575 20.6568542,2 19,2 L17,2 L17,7 Z M15,7 L15,2 L9,2 L9,7 L15,7 Z M7,2 L5,2 C3.34314575,2 2,3.34314575 2,5 L2,7 L7,7 L7,2 Z M2,9 L2,15 L7,15 L7,9 L2,9 Z M2,17 L2,19 C2,20.6568542 3.34314575,22 5,22 L7,22 L7,17 L2,17 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.999,0.5 C5.649,0.5 0.5,5.648 0.5,12 C0.5,17.082 3.794,21.392 8.365,22.914 C8.939,23.017 9.121,22.678 9.121,22.373 C9.121,22.099 9.127,21.336 9.121,20.376 C5.923,21.07 5.26,18.861 5.26,18.861 C4.737,17.532 3.985,17.179 3.985,17.179 C2.94,16.465 4.062,16.48 4.062,16.48 C5.215,16.56 5.824,17.664 5.824,17.664 C6.85,19.422 8.515,18.914 9.17,18.62 C9.276,17.878 9.572,17.369 9.901,17.084 C7.347,16.792 4.663,15.807 4.663,11.398 C4.663,10.143 5.111,9.117 5.847,8.312 C5.729,8.023 5.333,6.852 5.959,5.269 C5.959,5.269 6.926,4.96 9.121,6.449 C10.039,6.193 11.023,6.066 12.001,6.061 C12.977,6.066 13.961,6.193 14.881,6.449 C17.076,4.961 18.04,5.269 18.04,5.269 C18.667,6.852 18.272,8.023 18.154,8.312 C18.89,9.117 19.337,10.143 19.337,11.398 C19.337,15.818 16.648,16.789 14.086,17.072 C14.498,17.429 14.873,18.119 14.873,19.192 C14.873,20.63 14.873,21.998 14.873,22.376 C14.873,22.684 15.059,23.023 15.643,22.912 C20.209,21.389 23.5,17.08 23.5,12 C23.5,5.648 18.352,0.5 11.999,0.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 6V18M10 6V18M4 12H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M14 8.5C14 7.12 15.12 6 16.5 6H17.5C18.88 6 20 7.12 20 8.5C20 9.39 19.53 10.22 18.76 10.67L14 13.5H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 6V18M10 6V18M4 12H10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M16.5 6H17.5C18.88 6 20 7.12 20 8.5C20 9.88 18.88 11 17.5 11C18.88 11 20 12.12 20 13.5C20 14.88 18.88 16 17.5 16H16.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>horizontal-rule</title>
|
||||||
|
<path d="M5,13 C4.44771525,13 4,12.5522847 4,12 C4,11.4477153 4.44771525,11 5,11 L19,11 C19.5522847,11 20,11.4477153 20,12 C20,12.5522847 19.5522847,13 19,13 L5,13 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>paginate-filter-picture-alternate</title><circle cx="9.75" cy="6.247" r="2.25"/><path d="M16.916,8.71A1.027,1.027,0,0,0,16,8.158a1.007,1.007,0,0,0-.892.586L13.55,12.178a.249.249,0,0,1-.422.053l-.82-1.024a1,1,0,0,0-.813-.376,1.007,1.007,0,0,0-.787.426L7.59,15.71A.5.5,0,0,0,8,16.5H20a.5.5,0,0,0,.425-.237.5.5,0,0,0,.022-.486Z"/><path d="M22,0H5.5a2,2,0,0,0-2,2V18.5a2,2,0,0,0,2,2H22a2,2,0,0,0,2-2V2A2,2,0,0,0,22,0Zm-.145,18.354a.5.5,0,0,1-.354.146H6a.5.5,0,0,1-.5-.5V2.5A.5.5,0,0,1,6,2H21.5a.5.5,0,0,1,.5.5V18A.5.5,0,0,1,21.855,18.351Z"/><path d="M19.5,22H2.5a.5.5,0,0,1-.5-.5V4.5a1,1,0,0,0-2,0V22a2,2,0,0,0,2,2H19.5a1,1,0,0,0,0-2Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 707 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 7H20M4 12H14M4 17H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M10 9L14 12L10 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-italic</title><path d="M22.5.248H14.863a1.25,1.25,0,0,0,0,2.5h1.086a.25.25,0,0,1,.211.384L4.78,21.017a.5.5,0,0,1-.422.231H1.5a1.25,1.25,0,0,0,0,2.5H9.137a1.25,1.25,0,0,0,0-2.5H8.051a.25.25,0,0,1-.211-.384L19.22,2.98a.5.5,0,0,1,.422-.232H22.5a1.25,1.25,0,0,0,0-2.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>hyperlink-2</title><path d="M12.406,14.905a1,1,0,0,0-.543,1.307,1,1,0,0,1-.217,1.09L8.818,20.131a2,2,0,0,1-2.828,0L3.868,18.01a2,2,0,0,1,0-2.829L6.7,12.353a1.013,1.013,0,0,1,1.091-.217,1,1,0,0,0,.763-1.849,3.034,3.034,0,0,0-3.268.652L2.454,13.767a4.006,4.006,0,0,0,0,5.657l2.122,2.121a4,4,0,0,0,5.656,0l2.829-2.828a3.008,3.008,0,0,0,.651-3.27A1,1,0,0,0,12.406,14.905Z"/><path d="M7.757,16.241a1.011,1.011,0,0,0,1.414,0L16.95,8.463a1,1,0,0,0-1.414-1.414L7.757,14.827A1,1,0,0,0,7.757,16.241Z"/><path d="M21.546,4.574,19.425,2.453a4.006,4.006,0,0,0-5.657,0L10.939,5.281a3.006,3.006,0,0,0-.651,3.269,1,1,0,1,0,1.849-.764A1,1,0,0,1,12.354,6.7l2.828-2.828a2,2,0,0,1,2.829,0l2.121,2.121a2,2,0,0,1,0,2.829L17.3,11.645a1.015,1.015,0,0,1-1.091.217,1,1,0,0,0-.765,1.849,3.026,3.026,0,0,0,3.27-.651l2.828-2.828A4.007,4.007,0,0,0,21.546,4.574Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>read-email-at-alternate</title><path d="M12,.5A11.634,11.634,0,0,0,.262,12,11.634,11.634,0,0,0,12,23.5a11.836,11.836,0,0,0,6.624-2,1.25,1.25,0,1,0-1.393-2.076A9.34,9.34,0,0,1,12,21a9.132,9.132,0,0,1-9.238-9A9.132,9.132,0,0,1,12,3a9.132,9.132,0,0,1,9.238,9v.891a1.943,1.943,0,0,1-3.884,0V12A5.355,5.355,0,1,0,12,17.261a5.376,5.376,0,0,0,3.861-1.634,4.438,4.438,0,0,0,7.877-2.736V12A11.634,11.634,0,0,0,12,.5Zm0,14.261A2.763,2.763,0,1,1,14.854,12,2.812,2.812,0,0,1,12,14.761Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>list-numbers</title><path d="M7.75,4.5h15a1,1,0,0,0,0-2h-15a1,1,0,0,0,0,2Z"/><path d="M22.75,11h-15a1,1,0,1,0,0,2h15a1,1,0,0,0,0-2Z"/><path d="M22.75,19.5h-15a1,1,0,0,0,0,2h15a1,1,0,0,0,0-2Z"/><path d="M2.212,17.248A2,2,0,0,0,.279,18.732a.75.75,0,1,0,1.45.386.5.5,0,1,1,.483.63.75.75,0,1,0,0,1.5.5.5,0,1,1-.482.635.75.75,0,1,0-1.445.4,2,2,0,1,0,3.589-1.648.251.251,0,0,1,0-.278,2,2,0,0,0-1.662-3.111Z"/><path d="M4.25,10.748a2,2,0,0,0-4,0,.75.75,0,0,0,1.5,0,.5.5,0,0,1,1,0,1.031,1.031,0,0,1-.227.645L.414,14.029A.75.75,0,0,0,1,15.248H3.5a.75.75,0,0,0,0-1.5H3.081a.249.249,0,0,1-.195-.406L3.7,12.33A2.544,2.544,0,0,0,4.25,10.748Z"/><path d="M4,5.248H3.75A.25.25,0,0,1,3.5,5V1.623A1.377,1.377,0,0,0,2.125.248H1.5a.75.75,0,0,0,0,1.5h.25A.25.25,0,0,1,2,2V5a.25.25,0,0,1-.25.25H1.5a.75.75,0,0,0,0,1.5H4a.75.75,0,0,0,0-1.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 894 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 7H20M10 12H20M4 17H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M14 9L10 12L14 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>paragraph</title><path d="M22.5.248H7.228a6.977,6.977,0,1,0,0,13.954H9.546a.25.25,0,0,1,.25.25V22.5a1.25,1.25,0,0,0,2.5,0V3a.25.25,0,0,1,.25-.25h3.682a.25.25,0,0,1,.25.25V22.5a1.25,1.25,0,0,0,2.5,0V3a.249.249,0,0,1,.25-.25H22.5a1.25,1.25,0,0,0,0-2.5ZM9.8,11.452a.25.25,0,0,1-.25.25H7.228a4.477,4.477,0,1,1,0-8.954H9.546A.25.25,0,0,1,9.8,3Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>close-quote</title><path d="M18.559,3.932a4.942,4.942,0,1,0,0,9.883,4.609,4.609,0,0,0,1.115-.141.25.25,0,0,1,.276.368,6.83,6.83,0,0,1-5.878,3.523,1.25,1.25,0,0,0,0,2.5,9.71,9.71,0,0,0,9.428-9.95V8.873A4.947,4.947,0,0,0,18.559,3.932Z"/><path d="M6.236,3.932a4.942,4.942,0,0,0,0,9.883,4.6,4.6,0,0,0,1.115-.141.25.25,0,0,1,.277.368A6.83,6.83,0,0,1,1.75,17.565a1.25,1.25,0,0,0,0,2.5,9.711,9.711,0,0,0,9.428-9.95V8.873A4.947,4.947,0,0,0,6.236,3.932Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>redo</title><path d="M22.608.161a.5.5,0,0,0-.545.108L19.472,2.86a.25.25,0,0,1-.292.045A12.537,12.537,0,0,0,6.214,3.77,12.259,12.259,0,0,0,6.1,23.632a1.25,1.25,0,0,0,1.476-2.018A9.759,9.759,0,0,1,7.667,5.805a10,10,0,0,1,9.466-1.1.25.25,0,0,1,.084.409l-1.85,1.85a.5.5,0,0,0,.354.853h6.7a.5.5,0,0,0,.5-.5V.623A.5.5,0,0,0,22.608.161Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 406 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>delete-2-alternate</title><path d="M20.485,3.511A12.01,12.01,0,1,0,24,12,12.009,12.009,0,0,0,20.485,3.511Zm-1.767,15.21A9.51,9.51,0,1,1,21.5,12,9.508,9.508,0,0,1,18.718,18.721Z"/><path d="M16.987,7.01a1.275,1.275,0,0,0-1.8,0l-3.177,3.177L8.829,7.01A1.277,1.277,0,0,0,7.024,8.816L10.2,11.993,7.024,15.171a1.277,1.277,0,0,0,1.805,1.806L12.005,13.8l3.177,3.178a1.277,1.277,0,0,0,1.8-1.806l-3.176-3.178,3.176-3.177A1.278,1.278,0,0,0,16.987,7.01Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-strike-through</title><path d="M23.75,12.952A1.25,1.25,0,0,0,22.5,11.7H13.564a.492.492,0,0,1-.282-.09c-.722-.513-1.482-.981-2.218-1.432-2.8-1.715-4.5-2.9-4.5-4.863,0-2.235,2.207-2.569,3.523-2.569a4.54,4.54,0,0,1,3.081.764A2.662,2.662,0,0,1,13.615,5.5l0,.3a1.25,1.25,0,1,0,2.5,0l0-.268A4.887,4.887,0,0,0,14.95,1.755C13.949.741,12.359.248,10.091.248c-3.658,0-6.023,1.989-6.023,5.069,0,2.773,1.892,4.512,4,5.927a.25.25,0,0,1-.139.458H1.5a1.25,1.25,0,0,0,0,2.5H12.477a.251.251,0,0,1,.159.058,4.339,4.339,0,0,1,1.932,3.466c0,3.268-3.426,3.522-4.477,3.522-1.814,0-3.139-.405-3.834-1.173a3.394,3.394,0,0,1-.65-2.7,1.25,1.25,0,0,0-2.488-.246A5.76,5.76,0,0,0,4.4,21.753c1.2,1.324,3.114,2,5.688,2,4.174,0,6.977-2.42,6.977-6.022a6.059,6.059,0,0,0-.849-3.147.25.25,0,0,1,.216-.377H22.5A1.25,1.25,0,0,0,23.75,12.952Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 885 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path fill-rule="evenodd" d="M17,17 L17,22 L19,22 C20.6568542,22 22,20.6568542 22,19 L22,17 L17,17 Z M15,17 L9,17 L9,22 L15,22 L15,17 Z M17,15 L22,15 L22,9 L17,9 L17,15 Z M15,15 L15,9 L9,9 L9,15 L15,15 Z M17,7 L22,7 L22,5 C22,3.34314575 20.6568542,2 19,2 L17,2 L17,7 Z M15,7 L15,2 L9,2 L9,7 L15,7 Z M24,16.1768671 L24,19 C24,21.7614237 21.7614237,24 19,24 L5,24 C2.23857625,24 2.11453371e-15,21.7614237 1.77635684e-15,19 L0,5 C-3.38176876e-16,2.23857625 2.23857625,2.28362215e-15 5,0 L19,0 C21.7614237,-5.07265313e-16 24,2.23857625 24,5 L24,7.82313285 C24.0122947,7.88054124 24.0187107,7.93964623 24.0187107,8 C24.0187107,8.06035377 24.0122947,8.11945876 24,8.17686715 L24,15.8231329 C24.0122947,15.8805412 24.0187107,15.9396462 24.0187107,16 C24.0187107,16.0603538 24.0122947,16.1194588 24,16.1768671 Z M7,2 L5,2 C3.34314575,2 2,3.34314575 2,5 L2,7 L7,7 L7,2 Z M2,9 L2,15 L7,15 L7,9 L2,9 Z M2,17 L2,19 C2,20.6568542 3.34314575,22 5,22 L7,22 L7,17 L2,17 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>list-bullets</title><circle cx="2.5" cy="3.998" r="2.5"/><path d="M8.5,5H23a1,1,0,0,0,0-2H8.5a1,1,0,0,0,0,2Z"/><circle cx="2.5" cy="11.998" r="2.5"/><path d="M23,11H8.5a1,1,0,0,0,0,2H23a1,1,0,0,0,0-2Z"/><circle cx="2.5" cy="19.998" r="2.5"/><path d="M23,19H8.5a1,1,0,0,0,0,2H23a1,1,0,0,0,0-2Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>text-underline</title><path d="M22.5,21.248H1.5a1.25,1.25,0,0,0,0,2.5h21a1.25,1.25,0,0,0,0-2.5Z"/><path d="M1.978,2.748H3.341a.25.25,0,0,1,.25.25v8.523a8.409,8.409,0,0,0,16.818,0V3a.25.25,0,0,1,.25-.25h1.363a1.25,1.25,0,0,0,0-2.5H16.3a1.25,1.25,0,0,0,0,2.5h1.363a.25.25,0,0,1,.25.25v8.523a5.909,5.909,0,0,1-11.818,0V3a.25.25,0,0,1,.25-.25H7.7a1.25,1.25,0,1,0,0-2.5H1.978a1.25,1.25,0,0,0,0,2.5Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>undo</title><path d="M17.786,3.77A12.542,12.542,0,0,0,4.821,2.905a.249.249,0,0,1-.292-.045L1.937.269A.507.507,0,0,0,1.392.16a.5.5,0,0,0-.308.462v6.7a.5.5,0,0,0,.5.5h6.7a.5.5,0,0,0,.354-.854L6.783,5.115a.253.253,0,0,1-.068-.228.249.249,0,0,1,.152-.181,10,10,0,0,1,9.466,1.1,9.759,9.759,0,0,1,.094,15.809A1.25,1.25,0,0,0,17.9,23.631a12.122,12.122,0,0,0,5.013-9.961A12.125,12.125,0,0,0,17.786,3.77Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Contao\Manager;
|
||||||
|
|
||||||
|
use Contao\CoreBundle\ContaoCoreBundle;
|
||||||
|
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
|
||||||
|
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
|
||||||
|
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
|
||||||
|
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
|
||||||
|
use Contao\NewsBundle\ContaoNewsBundle;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\NewsSubmissionBundle;
|
||||||
|
use Symfony\Component\Config\Loader\LoaderResolverInterface;
|
||||||
|
use Symfony\Component\HttpKernel\KernelInterface;
|
||||||
|
use Symfony\Component\Routing\RouteCollection;
|
||||||
|
|
||||||
|
class Plugin implements BundlePluginInterface, RoutingPluginInterface
|
||||||
|
{
|
||||||
|
public function getBundles(ParserInterface $parser): iterable
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BundleConfig::create(NewsSubmissionBundle::class)
|
||||||
|
->setLoadAfter([ContaoCoreBundle::class, ContaoNewsBundle::class]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): RouteCollection|null
|
||||||
|
{
|
||||||
|
return $resolver
|
||||||
|
->resolve(__DIR__.'/../../Resources/config/routes.yaml')
|
||||||
|
->load(__DIR__.'/../../Resources/config/routes.yaml')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Controller;
|
||||||
|
|
||||||
|
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||||
|
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||||
|
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||||
|
use Contao\ModuleModel;
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Service\NewsContentSanitizer;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
#[AsFrontendModule(type: 'event_submission_confirmation', category: 'news', template: 'frontend/event_submission_confirmation')]
|
||||||
|
class EventSubmissionConfirmationModuleController extends AbstractFrontendModuleController
|
||||||
|
{
|
||||||
|
private const SESSION_KEY_LAST_SUBMISSION = 'mummert_media_news_submission.last_submission';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly NewsContentSanitizer $newsContentSanitizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
|
||||||
|
{
|
||||||
|
$entry = $request->getSession()?->get(self::SESSION_KEY_LAST_SUBMISSION);
|
||||||
|
|
||||||
|
if (!\is_array($entry) || [] === $entry) {
|
||||||
|
$template->set('entry', null);
|
||||||
|
|
||||||
|
return $template->getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = array_values(array_unique(array_filter(array_map('intval', (array) ($entry['tags'] ?? [])), static fn (int $id): bool => $id > 0)));
|
||||||
|
$safeArticleHtml = $this->newsContentSanitizer->sanitize((string) ($entry['articleText'] ?? ''));
|
||||||
|
|
||||||
|
$template->set('entry', [
|
||||||
|
'newsId' => (int) ($entry['newsId'] ?? 0),
|
||||||
|
'headline' => (string) ($entry['headline'] ?? ''),
|
||||||
|
'subheadline' => (string) ($entry['subheadline'] ?? ''),
|
||||||
|
'externalLink' => (string) ($entry['externalLink'] ?? ''),
|
||||||
|
'tagList' => implode(', ', $this->resolveTagLabels($tagIds)),
|
||||||
|
'articleText' => $safeArticleHtml,
|
||||||
|
'addImage' => (bool) ($entry['addImage'] ?? false),
|
||||||
|
'photographer' => (string) ($entry['photographer'] ?? ''),
|
||||||
|
'altText' => (string) ($entry['altText'] ?? ''),
|
||||||
|
'submittedByName' => (string) ($entry['submittedByName'] ?? ''),
|
||||||
|
'submittedByEmail' => (string) ($entry['submittedByEmail'] ?? ''),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $template->getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $tagIds
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function resolveTagLabels(array $tagIds): array
|
||||||
|
{
|
||||||
|
if ([] === $tagIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->connection->createQueryBuilder()
|
||||||
|
->select('id', 'tag')
|
||||||
|
->from('tl_tags')
|
||||||
|
->where('id IN (:ids)')
|
||||||
|
->setParameter('ids', $tagIds, ArrayParameterType::INTEGER)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$labelsById = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$labelsById[(int) ($row['id'] ?? 0)] = (string) ($row['tag'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
|
||||||
|
foreach ($tagIds as $id) {
|
||||||
|
if (isset($labelsById[$id]) && '' !== $labelsById[$id]) {
|
||||||
|
$labels[] = $labelsById[$id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Controller;
|
||||||
|
|
||||||
|
use Contao\CoreBundle\Csrf\ContaoCsrfTokenManager;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Service\UploadManager;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||||
|
|
||||||
|
class FilePondUploadController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UploadManager $uploadManager,
|
||||||
|
private readonly ContaoCsrfTokenManager $csrfTokenManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/_news-submission/upload', name: 'mummert_media_news_submission_upload', methods: ['POST'])]
|
||||||
|
public function process(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->hasValidCsrfToken($request)) {
|
||||||
|
return new JsonResponse(['error' => 'Ungültiger CSRF-Token.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->extractUploadedFile($request);
|
||||||
|
|
||||||
|
if (!$file instanceof UploadedFile) {
|
||||||
|
return new JsonResponse(['error' => 'Es wurde keine Datei hochgeladen.'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$token = $this->uploadManager->storeTemporaryUpload($file, $request->getClientIp());
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response($token, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/_news-submission/revert', name: 'mummert_media_news_submission_revert', methods: ['POST'])]
|
||||||
|
public function revert(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$this->hasValidCsrfToken($request)) {
|
||||||
|
return new JsonResponse(['error' => 'Ungültiger CSRF-Token.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim($request->getContent());
|
||||||
|
|
||||||
|
if ('' === $token) {
|
||||||
|
return new Response('', Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->uploadManager->revertTemporaryUpload($token);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return new Response('', Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('', Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasValidCsrfToken(Request $request): bool
|
||||||
|
{
|
||||||
|
$submittedToken = (string) ($request->headers->get('X-CSRF-Token') ?: $request->request->get('REQUEST_TOKEN', ''));
|
||||||
|
|
||||||
|
if ('' === $submittedToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenName = (string) $this->getParameter('contao.csrf_token_name');
|
||||||
|
|
||||||
|
return $this->csrfTokenManager->isTokenValid(new CsrfToken($tokenName, $submittedToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractUploadedFile(Request $request): ?UploadedFile
|
||||||
|
{
|
||||||
|
$directFile = $request->files->get('file');
|
||||||
|
|
||||||
|
if ($directFile instanceof UploadedFile) {
|
||||||
|
return $directFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findUploadedFile($request->files->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUploadedFile(mixed $value): ?UploadedFile
|
||||||
|
{
|
||||||
|
if ($value instanceof UploadedFile) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\is_array($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($value as $item) {
|
||||||
|
$file = $this->findUploadedFile($item);
|
||||||
|
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Controller;
|
||||||
|
|
||||||
|
use Contao\CoreBundle\Altcha\Altcha;
|
||||||
|
use Contao\CoreBundle\Controller\AltchaController;
|
||||||
|
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
|
||||||
|
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
|
||||||
|
use Contao\CoreBundle\Twig\FragmentTemplate;
|
||||||
|
use Contao\CoreBundle\Csrf\ContaoCsrfTokenManager;
|
||||||
|
use Contao\FrontendUser;
|
||||||
|
use Contao\ModuleModel;
|
||||||
|
use Contao\PageModel;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Form\Model\NewsSubmissionData;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Form\NewsSubmissionType;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Service\NewsCreator;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Service\TagProvider;
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Service\UploadManager;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
#[AsFrontendModule(type: 'news_submission', category: 'news', template: 'frontend/news_submission')]
|
||||||
|
class NewsSubmissionModuleController extends AbstractFrontendModuleController
|
||||||
|
{
|
||||||
|
private const SESSION_KEY_LAST_SUBMISSION = 'mummert_media_news_submission.last_submission';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagProvider $tagProvider,
|
||||||
|
private readonly UploadManager $uploadManager,
|
||||||
|
private readonly NewsCreator $newsCreator,
|
||||||
|
private readonly Altcha $altcha,
|
||||||
|
private readonly ContaoCsrfTokenManager $csrfTokenManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
$isLoggedIn = $user instanceof FrontendUser;
|
||||||
|
$errorMessage = null;
|
||||||
|
|
||||||
|
$data = new NewsSubmissionData();
|
||||||
|
$data->anonymousSubmission = !$isLoggedIn;
|
||||||
|
|
||||||
|
$form = $this->createForm(NewsSubmissionType::class, $data, [
|
||||||
|
'show_public_fields' => !$isLoggedIn,
|
||||||
|
'tag_choices' => $this->tagProvider->getTagChoices($model),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
if ('' !== trim((string) $form->get('honeypot')->getData())) {
|
||||||
|
$errorMessage = 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isLoggedIn && !$this->validateAltcha($request)) {
|
||||||
|
$errorMessage = 'Die Captcha-Validierung ist fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $errorMessage && $form->isValid()) {
|
||||||
|
try {
|
||||||
|
$imageData = null;
|
||||||
|
|
||||||
|
if ($data->addImage) {
|
||||||
|
$imageData = $this->uploadManager->moveTemporaryUploadToNewsFolder(
|
||||||
|
(string) $data->imageToken,
|
||||||
|
isset($model->uploadFolder) ? (string) $model->uploadFolder : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$submittedByMember = $isLoggedIn && $user instanceof FrontendUser
|
||||||
|
? $this->resolveSubmittedByMember($user)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$submittedByName = !$isLoggedIn
|
||||||
|
? $data->publicName
|
||||||
|
: trim((string) (($user->firstname ?? '').' '.($user->lastname ?? '')));
|
||||||
|
$submittedByEmail = !$isLoggedIn ? $data->publicEmail : (string) ($user->email ?? '');
|
||||||
|
|
||||||
|
$tagIds = $this->tagProvider->filterSubmittedTagIds($model, $data->tags);
|
||||||
|
|
||||||
|
$newsId = $this->newsCreator->createDraft(
|
||||||
|
(int) ($model->newsArchive ?? 0),
|
||||||
|
(int) ($model->author ?? 0),
|
||||||
|
$data->headline,
|
||||||
|
$data->subheadline,
|
||||||
|
$data->articleText,
|
||||||
|
$data->externalLink,
|
||||||
|
$tagIds,
|
||||||
|
$submittedByMember,
|
||||||
|
$submittedByName,
|
||||||
|
$submittedByEmail,
|
||||||
|
$imageData,
|
||||||
|
$data->addImage ? $data->photographer : null,
|
||||||
|
$data->addImage ? $data->altText : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$request->getSession()?->set(self::SESSION_KEY_LAST_SUBMISSION, [
|
||||||
|
'newsId' => $newsId,
|
||||||
|
'headline' => $data->headline,
|
||||||
|
'subheadline' => $data->subheadline,
|
||||||
|
'externalLink' => $data->externalLink,
|
||||||
|
'tags' => $tagIds,
|
||||||
|
'articleText' => $data->articleText,
|
||||||
|
'addImage' => $data->addImage,
|
||||||
|
'photographer' => $data->addImage ? $data->photographer : null,
|
||||||
|
'altText' => $data->addImage ? $data->altText : null,
|
||||||
|
'submittedByMember' => $submittedByMember,
|
||||||
|
'submittedByName' => $submittedByName,
|
||||||
|
'submittedByEmail' => $submittedByEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new RedirectResponse($this->resolveThankYouUrl($model));
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$errorMessage = $exception->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$template->set('form', $form->createView());
|
||||||
|
$template->set('errorMessage', $errorMessage);
|
||||||
|
$template->set('isLoggedIn', $isLoggedIn);
|
||||||
|
$template->set('requestToken', $this->csrfTokenManager->getDefaultTokenValue());
|
||||||
|
$template->set('uploadProcessUrl', $this->generateUrl('mummert_media_news_submission_upload'));
|
||||||
|
$template->set('uploadRevertUrl', $this->generateUrl('mummert_media_news_submission_revert'));
|
||||||
|
$template->set('showAltcha', !$isLoggedIn);
|
||||||
|
$template->set('altchaChallengeUrl', $this->generateUrl(AltchaController::class));
|
||||||
|
$template->set('altchaMaxNumber', $this->altcha->getRangeMax());
|
||||||
|
|
||||||
|
return $template->getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateAltcha(Request $request): bool
|
||||||
|
{
|
||||||
|
$payload = trim((string) $request->request->get('altcha', ''));
|
||||||
|
|
||||||
|
if ('' === $payload) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->altcha->validate($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveThankYouUrl(ModuleModel $model): string
|
||||||
|
{
|
||||||
|
$pageId = (int) ($model->thankYouPage ?? 0);
|
||||||
|
|
||||||
|
if ($pageId > 0) {
|
||||||
|
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
|
||||||
|
|
||||||
|
if ($page instanceof PageModel) {
|
||||||
|
return $this->generateContentUrl($page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentPage = $this->getPageModel();
|
||||||
|
|
||||||
|
return $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSubmittedByMember(FrontendUser $user): ?int
|
||||||
|
{
|
||||||
|
$memberId = (int) ($user->id ?? 0);
|
||||||
|
|
||||||
|
if ($memberId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $memberId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\DependencyInjection;
|
||||||
|
|
||||||
|
use Symfony\Component\Config\FileLocator;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||||
|
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||||
|
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
|
||||||
|
|
||||||
|
class NewsSubmissionExtension extends Extension
|
||||||
|
{
|
||||||
|
public function load(array $configs, ContainerBuilder $container): void
|
||||||
|
{
|
||||||
|
$loader = new YamlFileLoader($container, new FileLocator(dirname(__DIR__, 2).'/contao/config'));
|
||||||
|
$loader->load('services.yaml');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Form\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
|
class NewsSubmissionData
|
||||||
|
{
|
||||||
|
private const ARTICLE_TEXT_MAX_CHARACTERS = 30000;
|
||||||
|
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public string $headline = '';
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $subheadline = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 2048)]
|
||||||
|
public ?string $externalLink = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int>
|
||||||
|
*/
|
||||||
|
public array $tags = [];
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte einen Artikeltext eingeben.')]
|
||||||
|
public ?string $articleText = null;
|
||||||
|
|
||||||
|
public bool $addImage = false;
|
||||||
|
|
||||||
|
public ?string $imageToken = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $photographer = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $altText = null;
|
||||||
|
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $publicName = null;
|
||||||
|
|
||||||
|
#[Assert\Email]
|
||||||
|
#[Assert\Length(max: 255)]
|
||||||
|
public ?string $publicEmail = null;
|
||||||
|
|
||||||
|
public bool $anonymousSubmission = false;
|
||||||
|
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validate(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if ($this->addImage) {
|
||||||
|
if ('' === trim((string) $this->photographer)) {
|
||||||
|
$context->buildViolation('Bitte den Fotografen/Urheber angeben.')
|
||||||
|
->atPath('photographer')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' === trim((string) $this->imageToken)) {
|
||||||
|
$context->buildViolation('Bitte laden Sie ein Bild hoch.')
|
||||||
|
->atPath('imageToken')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->anonymousSubmission && '' !== trim((string) $this->publicEmail) && '' === trim((string) $this->publicName)) {
|
||||||
|
$context->buildViolation('Wenn eine öffentliche E-Mail angegeben wird, bitte auch einen Namen angeben.')
|
||||||
|
->atPath('publicName')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalLink = trim((string) $this->externalLink);
|
||||||
|
|
||||||
|
if ('' !== $externalLink) {
|
||||||
|
if (!preg_match('#^[a-z][a-z0-9+\-.]*://#i', $externalLink)) {
|
||||||
|
$externalLink = 'https://'.$externalLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === filter_var($externalLink, FILTER_VALIDATE_URL)) {
|
||||||
|
$context->buildViolation('Bitte eine gültige URL angeben (z. B. example.org oder https://example.org).')
|
||||||
|
->atPath('externalLink')
|
||||||
|
->addViolation();
|
||||||
|
} else {
|
||||||
|
$this->externalLink = $externalLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$plainArticleText = trim(preg_replace('/\s+/u', ' ', strip_tags((string) $this->articleText)) ?? '');
|
||||||
|
|
||||||
|
if (mb_strlen($plainArticleText) > self::ARTICLE_TEXT_MAX_CHARACTERS) {
|
||||||
|
$context->buildViolation(sprintf('Der Artikeltext darf maximal %d Zeichen enthalten.', self::ARTICLE_TEXT_MAX_CHARACTERS))
|
||||||
|
->atPath('articleText')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Form;
|
||||||
|
|
||||||
|
use MummertMedia\NewsSubmissionBundle\Form\Model\NewsSubmissionData;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class NewsSubmissionType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('headline', TextType::class, [
|
||||||
|
'label' => 'Überschrift',
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'off',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('subheadline', TextType::class, [
|
||||||
|
'label' => 'Unterüberschrift',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'off',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('externalLink', TextType::class, [
|
||||||
|
'label' => 'Externer Link',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'url',
|
||||||
|
'inputmode' => 'url',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('tags', ChoiceType::class, [
|
||||||
|
'label' => 'Tags',
|
||||||
|
'required' => false,
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'choices' => $options['tag_choices'],
|
||||||
|
'choice_attr' => static function (): array {
|
||||||
|
return [
|
||||||
|
'role' => 'switch',
|
||||||
|
'class' => 'ns-tag-switch-input',
|
||||||
|
];
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->add('articleText', TextareaType::class, [
|
||||||
|
'label' => 'Artikeltext',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'rows' => 7,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('addImage', CheckboxType::class, [
|
||||||
|
'label' => 'Bild hochladen',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'class' => 'js-add-image-toggle ns-tag-switch-input',
|
||||||
|
'role' => 'switch',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('imageUpload', FileType::class, [
|
||||||
|
'label' => 'Bilddatei',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'class' => 'js-news-filepond',
|
||||||
|
'accept' => 'image/png,image/jpeg,image/webp',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('imageToken', HiddenType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'class' => 'js-image-token',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('photographer', TextType::class, [
|
||||||
|
'label' => 'Fotograf/Urheber',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'class' => 'js-photographer',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('altText', TextType::class, [
|
||||||
|
'label' => 'Alternative Bildbeschreibung',
|
||||||
|
'required' => false,
|
||||||
|
'help' => 'Wird für barrierefreie Umsetzung der Webseite empfohlen.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($options['show_public_fields']) {
|
||||||
|
$builder
|
||||||
|
->add('publicName', TextType::class, [
|
||||||
|
'label' => 'Öffentlicher Name',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'name',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('publicEmail', EmailType::class, [
|
||||||
|
'label' => 'Öffentliche E-Mail',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'email',
|
||||||
|
'inputmode' => 'email',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->add('honeypot', HiddenType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'autocomplete' => 'off',
|
||||||
|
'tabindex' => '-1',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => NewsSubmissionData::class,
|
||||||
|
'show_public_fields' => false,
|
||||||
|
'tag_choices' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver->setAllowedTypes('show_public_fields', 'bool');
|
||||||
|
$resolver->setAllowedTypes('tag_choices', 'array');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||||
|
|
||||||
|
class NewsSubmissionBundle extends Bundle
|
||||||
|
{
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mummert_media_news_submission_controllers:
|
||||||
|
resource: ../../Controller/
|
||||||
|
type: attribute
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||||
|
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||||
|
|
||||||
|
class NewsContentSanitizer
|
||||||
|
{
|
||||||
|
private readonly HtmlSanitizer $sanitizer;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$config = new HtmlSanitizerConfig();
|
||||||
|
|
||||||
|
foreach (['p', 'h2', 'h3', 'strong', 'em', 'u', 'ul', 'ol', 'li'] as $tag) {
|
||||||
|
$config = $config->allowElement($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sanitizer = new HtmlSanitizer($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
return trim($this->sanitizer->sanitize($html));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Service;
|
||||||
|
|
||||||
|
use Contao\StringUtil;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
|
||||||
|
class NewsCreator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly NewsContentSanitizer $newsContentSanitizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $tagIds
|
||||||
|
* @param array{uuid:string,path:string}|null $imageData
|
||||||
|
*/
|
||||||
|
public function createDraft(
|
||||||
|
int $archiveId,
|
||||||
|
int $authorId,
|
||||||
|
string $headline,
|
||||||
|
?string $subheadline,
|
||||||
|
?string $teaser,
|
||||||
|
?string $externalLink,
|
||||||
|
array $tagIds,
|
||||||
|
?int $submittedByMember,
|
||||||
|
?string $submittedByName,
|
||||||
|
?string $submittedByEmail,
|
||||||
|
?array $imageData,
|
||||||
|
?string $photographer,
|
||||||
|
?string $altText,
|
||||||
|
): int {
|
||||||
|
if ($archiveId <= 0) {
|
||||||
|
throw new \RuntimeException('Im Modul ist kein gültiges News-Archiv konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($authorId <= 0) {
|
||||||
|
throw new \RuntimeException('Im Modul ist kein gültiger Autor konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$alias = $this->generateUniqueAlias($headline);
|
||||||
|
$tagIds = array_values(array_unique(array_filter(array_map('intval', $tagIds), static fn (int $id): bool => $id > 0)));
|
||||||
|
$normalizedExternalLink = $this->normalizeExternalLink($externalLink);
|
||||||
|
$sanitizedTeaser = $this->newsContentSanitizer->sanitize((string) $teaser);
|
||||||
|
|
||||||
|
if (!$this->hasMeaningfulText($sanitizedTeaser)) {
|
||||||
|
throw new \RuntimeException('Der Artikeltext enthält nach der Bereinigung keinen verwertbaren Inhalt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasExternalLink = null !== $normalizedExternalLink;
|
||||||
|
$hasImage = null !== $imageData;
|
||||||
|
$caption = '' !== trim((string) $photographer) ? 'Foto: '.trim((string) $photographer) : null;
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'tstamp' => $now,
|
||||||
|
'pid' => $archiveId,
|
||||||
|
'headline' => trim($headline),
|
||||||
|
'alias' => $alias,
|
||||||
|
'author' => $authorId,
|
||||||
|
'date' => $now,
|
||||||
|
'time' => $now,
|
||||||
|
'subheadline' => trim((string) $subheadline),
|
||||||
|
'teaser' => $sanitizedTeaser,
|
||||||
|
'source' => $hasExternalLink ? 'external' : 'default',
|
||||||
|
'url' => (string) ($normalizedExternalLink ?? ''),
|
||||||
|
'target' => 0,
|
||||||
|
'published' => 0,
|
||||||
|
'featured' => 0,
|
||||||
|
'addImage' => $hasImage ? 1 : 0,
|
||||||
|
'singleSRC' => $hasImage ? $imageData['uuid'] : null,
|
||||||
|
'floating' => $hasImage ? 'above' : 'above',
|
||||||
|
'overwriteMeta' => $hasImage && (null !== $caption || '' !== trim((string) $altText)) ? 1 : 0,
|
||||||
|
'caption' => $hasImage ? $caption : null,
|
||||||
|
'alt' => $hasImage ? trim((string) $altText) : null,
|
||||||
|
'submittedByMember' => (int) ($submittedByMember ?? 0),
|
||||||
|
'submittedByName' => trim((string) $submittedByName),
|
||||||
|
'submittedByEmail' => trim((string) $submittedByEmail),
|
||||||
|
'tags' => serialize(array_map('strval', $tagIds)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$types = [
|
||||||
|
'pid' => ParameterType::INTEGER,
|
||||||
|
'author' => ParameterType::INTEGER,
|
||||||
|
'date' => ParameterType::INTEGER,
|
||||||
|
'time' => ParameterType::INTEGER,
|
||||||
|
'published' => ParameterType::INTEGER,
|
||||||
|
'featured' => ParameterType::INTEGER,
|
||||||
|
'target' => ParameterType::INTEGER,
|
||||||
|
'addImage' => ParameterType::INTEGER,
|
||||||
|
'overwriteMeta' => ParameterType::INTEGER,
|
||||||
|
'submittedByMember' => ParameterType::INTEGER,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($hasImage) {
|
||||||
|
$types['singleSRC'] = ParameterType::BINARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->insert('tl_news', $data, $types);
|
||||||
|
|
||||||
|
$newsId = (int) $this->connection->lastInsertId();
|
||||||
|
|
||||||
|
if ($newsId <= 0) {
|
||||||
|
throw new \RuntimeException('News-Datensatz konnte nicht erzeugt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assignTags($newsId, $tagIds);
|
||||||
|
|
||||||
|
return $newsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int> $tagIds
|
||||||
|
*/
|
||||||
|
private function assignTags(int $newsId, array $tagIds): void
|
||||||
|
{
|
||||||
|
$this->connection->delete(
|
||||||
|
'tl_tags_rel',
|
||||||
|
['ptable' => 'tl_news', 'field' => 'tags', 'pid' => $newsId],
|
||||||
|
['pid' => ParameterType::INTEGER],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($tagIds as $tagId) {
|
||||||
|
if ($tagId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->connection->insert(
|
||||||
|
'tl_tags_rel',
|
||||||
|
[
|
||||||
|
'tag_id' => $tagId,
|
||||||
|
'pid' => $newsId,
|
||||||
|
'ptable' => 'tl_news',
|
||||||
|
'field' => 'tags',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'tag_id' => ParameterType::INTEGER,
|
||||||
|
'pid' => ParameterType::INTEGER,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUniqueAlias(string $headline): string
|
||||||
|
{
|
||||||
|
$baseAlias = StringUtil::generateAlias($headline);
|
||||||
|
|
||||||
|
if ('' === $baseAlias) {
|
||||||
|
$baseAlias = 'news-'.date('YmdHis');
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $baseAlias;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while ($this->aliasExists($candidate)) {
|
||||||
|
$candidate = sprintf('%s-%d', $baseAlias, $counter);
|
||||||
|
++$counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasMeaningfulText(string $html): bool
|
||||||
|
{
|
||||||
|
$text = trim(str_replace("\xc2\xa0", ' ', strip_tags($html)));
|
||||||
|
|
||||||
|
return '' !== $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function aliasExists(string $alias): bool
|
||||||
|
{
|
||||||
|
$exists = $this->connection->createQueryBuilder()
|
||||||
|
->select('id')
|
||||||
|
->from('tl_news')
|
||||||
|
->where('alias = :alias')
|
||||||
|
->setParameter('alias', $alias)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchOne();
|
||||||
|
|
||||||
|
return false !== $exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeExternalLink(?string $externalLink): ?string
|
||||||
|
{
|
||||||
|
$externalLink = trim((string) $externalLink);
|
||||||
|
|
||||||
|
if ('' === $externalLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('#^[a-z][a-z0-9+\-.]*://#i', $externalLink)) {
|
||||||
|
$externalLink = 'https://'.$externalLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === filter_var($externalLink, FILTER_VALIDATE_URL)) {
|
||||||
|
throw new \RuntimeException('Der externe Link ist ungültig. Bitte eine gültige URL eingeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $externalLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Service;
|
||||||
|
|
||||||
|
use Contao\ModuleModel;
|
||||||
|
use Contao\StringUtil;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
class TagProvider
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function getTagChoices(ModuleModel $moduleModel): array
|
||||||
|
{
|
||||||
|
$allowedIds = $this->normalizeIds(StringUtil::deserialize($moduleModel->allowedTags ?? null, true));
|
||||||
|
|
||||||
|
$rows = $this->connection->createQueryBuilder()
|
||||||
|
->select('id', 'tag')
|
||||||
|
->from('tl_tags')
|
||||||
|
->where('invisible = :invisible')
|
||||||
|
->setParameter('invisible', '')
|
||||||
|
->orderBy('tag', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$choices = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
$label = trim((string) ($row['tag'] ?? ''));
|
||||||
|
|
||||||
|
if ($id <= 0 || '' === $label) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $allowedIds && !in_array($id, $allowedIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$choices[$label] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string> $submittedIds
|
||||||
|
*
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
public function filterSubmittedTagIds(ModuleModel $moduleModel, array $submittedIds): array
|
||||||
|
{
|
||||||
|
$allowedChoiceIds = array_values($this->getTagChoices($moduleModel));
|
||||||
|
$submittedIds = $this->normalizeIds($submittedIds);
|
||||||
|
|
||||||
|
return array_values(array_intersect($submittedIds, $allowedChoiceIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string> $values
|
||||||
|
*
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function normalizeIds(array $values): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_filter(array_map('intval', $values), static fn (int $id): bool => $id > 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MummertMedia\NewsSubmissionBundle\Service;
|
||||||
|
|
||||||
|
use Contao\Dbafs;
|
||||||
|
use Contao\FilesModel;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
class UploadManager
|
||||||
|
{
|
||||||
|
private const MAX_FILE_SIZE = 6291456;
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
private const ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeTemporaryUpload(UploadedFile $file, ?string $clientIp): string
|
||||||
|
{
|
||||||
|
$this->assertRateLimit($clientIp);
|
||||||
|
$this->assertValidUpload($file);
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(20));
|
||||||
|
$tmpDir = $this->getTemporaryDirectory();
|
||||||
|
$extension = strtolower((string) ($file->guessExtension() ?: $file->getClientOriginalExtension() ?: 'bin'));
|
||||||
|
$extension = preg_replace('/[^a-z0-9]+/', '', $extension) ?: 'bin';
|
||||||
|
|
||||||
|
$filePath = $tmpDir.'/'.$token.'.'.$extension;
|
||||||
|
$metaPath = $tmpDir.'/'.$token.'.json';
|
||||||
|
|
||||||
|
$file->move($tmpDir, basename($filePath));
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'ip' => (string) $clientIp,
|
||||||
|
'createdAt' => time(),
|
||||||
|
'originalName' => $file->getClientOriginalName(),
|
||||||
|
'storedPath' => $filePath,
|
||||||
|
];
|
||||||
|
|
||||||
|
file_put_contents($metaPath, json_encode($metadata, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{uuid:string,path:string}
|
||||||
|
*/
|
||||||
|
public function moveTemporaryUploadToNewsFolder(string $token, string|null $uploadFolderUuid): array
|
||||||
|
{
|
||||||
|
if ('' === trim((string) $uploadFolderUuid)) {
|
||||||
|
throw new RuntimeException('Kein Upload-Ordner im Modul konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$filePath, $metaPath, $metadata] = $this->getValidTemporaryUpload($token);
|
||||||
|
|
||||||
|
$folderModel = FilesModel::findByUuid((string) $uploadFolderUuid);
|
||||||
|
|
||||||
|
if (null === $folderModel) {
|
||||||
|
throw new RuntimeException('Der konfigurierte Upload-Ordner wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseRelativeFolder = trim((string) $folderModel->path, '/');
|
||||||
|
|
||||||
|
if ('' === $baseRelativeFolder) {
|
||||||
|
throw new RuntimeException('Ungültiger Upload-Ordner.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$yearFolder = $baseRelativeFolder.'/'.date('Y');
|
||||||
|
$absoluteYearFolder = rtrim($this->projectDir, '/').'/'.$yearFolder;
|
||||||
|
|
||||||
|
if (!is_dir($absoluteYearFolder) && !mkdir($absoluteYearFolder, 0775, true) && !is_dir($absoluteYearFolder)) {
|
||||||
|
throw new RuntimeException('Jahresordner konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalName = (string) ($metadata['originalName'] ?? 'image');
|
||||||
|
$pathInfo = pathinfo($originalName);
|
||||||
|
$baseName = $this->slugifyFilename((string) ($pathInfo['filename'] ?? 'image'));
|
||||||
|
$extension = strtolower((string) ($pathInfo['extension'] ?? pathinfo($filePath, PATHINFO_EXTENSION) ?? 'bin'));
|
||||||
|
$extension = preg_replace('/[^a-z0-9]+/', '', $extension) ?: 'bin';
|
||||||
|
|
||||||
|
$targetFilename = $this->buildUniqueFilename($absoluteYearFolder, $baseName, $extension);
|
||||||
|
$targetAbsolutePath = $absoluteYearFolder.'/'.$targetFilename;
|
||||||
|
|
||||||
|
if (!rename($filePath, $targetAbsolutePath)) {
|
||||||
|
throw new RuntimeException('Temporäre Upload-Datei konnte nicht verschoben werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetRelativePath = $yearFolder.'/'.$targetFilename;
|
||||||
|
Dbafs::addResource($yearFolder);
|
||||||
|
$model = Dbafs::addResource($targetRelativePath);
|
||||||
|
|
||||||
|
if (null === $model) {
|
||||||
|
$model = FilesModel::findByPath($targetRelativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($metaPath);
|
||||||
|
|
||||||
|
if (null === $model) {
|
||||||
|
throw new RuntimeException('Datei konnte nicht in der Dateiverwaltung registriert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uuid' => (string) $model->uuid,
|
||||||
|
'path' => $targetRelativePath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revertTemporaryUpload(string $token): void
|
||||||
|
{
|
||||||
|
[$filePath, $metaPath] = $this->getValidTemporaryUpload($token);
|
||||||
|
|
||||||
|
@unlink($filePath);
|
||||||
|
@unlink($metaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertRateLimit(?string $clientIp): void
|
||||||
|
{
|
||||||
|
$clientIp = trim((string) $clientIp);
|
||||||
|
|
||||||
|
if ('' === $clientIp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpDir = $this->getTemporaryDirectory();
|
||||||
|
$metaFiles = glob($tmpDir.'/*.json') ?: [];
|
||||||
|
$now = time();
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($metaFiles as $metaFile) {
|
||||||
|
$data = json_decode((string) file_get_contents($metaFile), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($data['ip'] ?? null) !== $clientIp) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdAt = (int) ($data['createdAt'] ?? 0);
|
||||||
|
|
||||||
|
if ($createdAt >= $now - 60) {
|
||||||
|
++$count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count >= 20) {
|
||||||
|
throw new RuntimeException('Zu viele Upload-Anfragen. Bitte kurz warten.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertValidUpload(UploadedFile $file): void
|
||||||
|
{
|
||||||
|
if (!$file->isValid()) {
|
||||||
|
throw new RuntimeException('Der Upload ist fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||||
|
throw new RuntimeException('Die Datei ist zu groß. Maximal 6 MB erlaubt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = strtolower((string) $file->getMimeType());
|
||||||
|
|
||||||
|
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
throw new RuntimeException('Nur JPG, PNG und WEBP sind erlaubt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:string,1:string,2?:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
private function getValidTemporaryUpload(string $token): array
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[a-f0-9]{40}$/', $token)) {
|
||||||
|
throw new RuntimeException('Ungültiger Upload-Token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpDir = $this->getTemporaryDirectory();
|
||||||
|
$metaPath = $tmpDir.'/'.$token.'.json';
|
||||||
|
|
||||||
|
if (!is_file($metaPath)) {
|
||||||
|
throw new RuntimeException('Upload wurde nicht gefunden oder ist abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = json_decode((string) file_get_contents($metaPath), true);
|
||||||
|
|
||||||
|
if (!is_array($metadata)) {
|
||||||
|
throw new RuntimeException('Upload-Metadaten sind ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = (string) ($metadata['storedPath'] ?? '');
|
||||||
|
|
||||||
|
if ('' === $filePath || !is_file($filePath)) {
|
||||||
|
throw new RuntimeException('Upload-Datei wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($metadata['createdAt'] ?? 0) < time() - 86400) {
|
||||||
|
@unlink($filePath);
|
||||||
|
@unlink($metaPath);
|
||||||
|
throw new RuntimeException('Upload ist abgelaufen. Bitte erneut hochladen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$filePath, $metaPath, $metadata];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTemporaryDirectory(): string
|
||||||
|
{
|
||||||
|
$directory = rtrim($this->projectDir, '/').'/var/news_submission_uploads/tmp';
|
||||||
|
|
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||||
|
throw new RuntimeException('Temporäres Upload-Verzeichnis konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugifyFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$filename = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $filename) ?? '';
|
||||||
|
$filename = trim($filename, '-_');
|
||||||
|
|
||||||
|
return '' !== $filename ? strtolower($filename) : 'bild';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUniqueFilename(string $absoluteFolder, string $baseName, string $extension): string
|
||||||
|
{
|
||||||
|
$candidate = $baseName.'.'.$extension;
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while (is_file($absoluteFolder.'/'.$candidate)) {
|
||||||
|
$candidate = sprintf('%s-%d.%s', $baseName, $counter, $extension);
|
||||||
|
++$counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||