Compare commits

...

69 Commits

Author SHA1 Message Date
Jürgen Mummert 7541c12f66 Add subtle hidden-based event filter animation 2026-04-05 14:01:30 +02:00
Jürgen Mummert e8fd218c74 Remove view transitions and restore hidden-only event filtering 2026-04-05 13:58:54 +02:00
Jürgen Mummert 5f652530ed Scope view transitions to event list and prevent page shift 2026-04-05 13:55:44 +02:00
Jürgen Mummert 11b927b91f Fix event filter transition jank and restore layout animation 2026-04-05 13:46:00 +02:00
Jürgen Mummert e1a426bde4 Handle skipped view transition promises in event filter 2026-04-05 13:40:57 +02:00
Jürgen Mummert 8a422aa4ee Add view transitions for event filter layout animation 2026-04-05 13:36:58 +02:00
Jürgen Mummert f014595bc5 Fix eventmanager asset paths and remove legacy bundle class 2026-03-08 18:22:59 +01:00
Jürgen Mummert 6186a0d8bd Clean namespace to Mummert\EventManagerBundle 2026-03-08 18:01:50 +01:00
Jürgen Mummert b28c19cb46 Expose Mummert manager-plugin compatibility 2026-03-08 17:49:53 +01:00
Jürgen Mummert 9b2b87d6ba Rename package to mummert/eventmanager-bundle 2026-03-08 17:37:56 +01:00
Jürgen Mummert dbce287834 Add contao/calendar-bundle dependency 2026-03-02 21:34:29 +01:00
Jürgen Mummert 78c44d8de6 fix: default event list to grid on first load 2026-03-02 17:28:21 +01:00
Jürgen Mummert d0916e7b39 Use class-only grid activation in event filter 2026-03-01 21:57:31 +01:00
Jürgen Mummert a55df0be90 Force default grid layout in event filter initialization 2026-03-01 21:51:35 +01:00
Jürgen Mummert fc0d1fa1cc Render event checkboxes as switch-first fullwidth rows 2026-03-01 14:46:58 +01:00
Jürgen Mummert 5facca2566 Move editor icons to eventmanager and remove inline styling 2026-03-01 13:02:42 +01:00
Jürgen Mummert 141e310320 Add TipTap editor and switch tags to event/organization edit 2026-03-01 12:54:36 +01:00
Jürgen Mummert 76be3bcd09 Fix textarea HTML rendering and enforce light FilePond panels 2026-03-01 12:11:08 +01:00
Jürgen Mummert 84a6f118dc Adjust organization address fields and brighten FilePond preview 2026-03-01 12:00:46 +01:00
Jürgen Mummert 3176e6e414 Fix FilePond label interaction CSS conflicts 2026-03-01 11:52:43 +01:00
Jürgen Mummert 83c632f2dc Tune FilePond panel aspect ratios 2026-03-01 11:34:24 +01:00
Jürgen Mummert 15a93466b6 Adjust FilePond sizing and upload resize rules 2026-03-01 10:56:38 +01:00
Jürgen Mummert 3942569fb3 fix(member-events): confirm actions reliably and normalize duplicate titles 2026-03-01 10:22:53 +01:00
Jürgen Mummert ccf60a31e7 refactor(member-events): group editor actions in dedicated wrapper 2026-03-01 10:04:36 +01:00
Jürgen Mummert 000fdb8e79 fix(filter): adjust clear button spacing and hide select arrow when active 2026-02-27 21:15:09 +01:00
Jürgen Mummert 8b213ec619 fix(map): harden protomaps flavor loading with base-style fallback 2026-02-27 21:10:26 +01:00
Jürgen Mummert b29aaaa1a3 Event filter: replace reset button with per-select clear icons 2026-02-27 20:30:56 +01:00
Jürgen Mummert 7cb1097899 Refactor map cleanup: central popup manager and dead code removal 2026-02-27 19:58:24 +01:00
Jürgen Mummert 088bc58102 Map: satellite hybrid overlays for labels and roads 2026-02-27 19:31:36 +01:00
Jürgen Mummert 229f99ea19 Map: organization popup links and backend page selector 2026-02-26 22:11:55 +01:00
Jürgen Mummert 51a92ea45e Map tags: load live organization tags and sanitize selections 2026-02-26 22:00:12 +01:00
Jürgen Mummert 1a4811cb02 Fix backend pitch visibility and tag marker mapping 2026-02-26 21:46:27 +01:00
Jürgen Mummert fc2508af22 Map: pitch option, marker ordering and event popup improvements 2026-02-26 21:32:20 +01:00
Jürgen Mummert ba321fdc23 Map: show event markers only for future events 2026-02-26 21:16:36 +01:00
Jürgen Mummert d5bfb66eee Fix spiderfy reliability after event toggle 2026-02-26 21:08:37 +01:00
Jürgen Mummert dedf8868b5 Map filters: conditional visibility + initial display handling 2026-02-26 20:31:45 +01:00
Jürgen Mummert fb58c50f18 feat(map): allow backend-selected organization tags 2026-02-26 18:36:35 +01:00
Jürgen Mummert 621ce8dc8b fix(map): simplify filters and restore style toggle behavior 2026-02-26 18:26:20 +01:00
Jürgen Mummert 40aaa747d9 feat(map): add organization color scheme and improve map controls 2026-02-25 21:02:33 +01:00
Jürgen Mummert 68e991c053 feat: add frontend map module with maplibre and spiderfy 2026-02-24 18:24:10 +01:00
Jürgen Mummert 8aa09e893b Switch event filter selects back to native controls 2026-02-24 17:46:25 +01:00
Jürgen Mummert ac203b37ce Persist event filter state across reload and navigation 2026-02-23 21:54:27 +01:00
Jürgen Mummert 4b62e72382 Add svg class support for organization logo figures 2026-02-23 21:40:30 +01:00
Jürgen Mummert 97e4f639a0 Use Tom Select CDN major version 2 2026-02-23 21:28:15 +01:00
Jürgen Mummert b714bbeb1b Blur Tom Select only for mouse and touch selection 2026-02-23 21:22:59 +01:00
Jürgen Mummert 21c18f94a0 Improve Tom Select usability and accessibility defaults 2026-02-23 20:54:25 +01:00
Jürgen Mummert 0402c74824 Fix Tom Select input wrapping and restore arrow icon 2026-02-23 20:49:26 +01:00
Jürgen Mummert e3c13c989b Keep Tom Select setup core-only 2026-02-23 20:43:43 +01:00
Jürgen Mummert 15db77186b Add Tom Select arrow and disable input autocomplete 2026-02-23 20:38:21 +01:00
Jürgen Mummert 573d5c12e5 Configure Tom Select placeholders and sortable options 2026-02-23 20:28:50 +01:00
Jürgen Mummert 2bd9f6849c Use Tom Select defaults for event filter 2026-02-23 20:23:23 +01:00
Jürgen Mummert 0e0a888fa9 Replace SlimSelect with Tom Select in event filter 2026-02-23 20:16:40 +01:00
Jürgen Mummert 5d3905632d Fix SlimSelect dropdown scrolling regression 2026-02-23 20:03:08 +01:00
Jürgen Mummert fce467fa87 Harden SlimSelect scroll lock on macOS browsers 2026-02-23 20:00:36 +01:00
Jürgen Mummert f08406ec12 Fix SlimSelect scroll chaining in sticky event filter 2026-02-23 19:57:54 +01:00
Jürgen Mummert f3d4c857e0 Harden organization listing tag enrichment and simplify listener 2026-02-22 17:53:17 +01:00
Jürgen Mummert ff608f7833 Fix organization listing numeric tag rendering and template mapping 2026-02-22 17:41:36 +01:00
Jürgen Mummert 00f65ffd45 Fix nullable logger guard in organization listing listener 2026-02-22 17:12:29 +01:00
Jürgen Mummert 142cab2203 Broaden organization tag enrichment fallbacks for production data variants 2026-02-22 17:10:39 +01:00
Jürgen Mummert acf3d02d13 Add diagnostics for unresolved org tag enrichment rows 2026-02-22 17:06:08 +01:00
Jürgen Mummert add43674cf Add title-based tag enrichment fallback for listing rows 2026-02-22 17:04:01 +01:00
Jürgen Mummert a04c6de362 Add fallback tag relation lookup for organization listing 2026-02-22 17:02:56 +01:00
Jürgen Mummert f8cd256348 Fix org listing data-tags and keep template project-specific 2026-02-22 17:00:04 +01:00
Jürgen Mummert 8c9ea29170 Fix organization listing SQL for MariaDB tags 2026-02-22 16:49:55 +01:00
Jürgen Mummert 8c5921d02a Add list_default_organisationen template: UUID-based logos, improved tag filter 2026-02-22 16:20:07 +01:00
Jürgen Mummert c4a7150f41 Add conditional reset button for event filters 2026-02-22 13:03:58 +01:00
Jürgen Mummert 993b3aa774 Prevent recursive select change loops in event filter 2026-02-22 12:50:35 +01:00
Jürgen Mummert a03612dc9c Set active state on selected filter widgets 2026-02-22 12:16:39 +01:00
Jürgen Mummert b525f37862 Wrap select filters in dedicated container 2026-02-22 12:14:24 +01:00
70 changed files with 4168 additions and 255 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ Contao 5 Bundle für Organisations-, Veranstaltungsort- und Event-Verwaltung ink
## Installation (lokal via Composer) ## Installation (lokal via Composer)
```bash ```bash
composer require mummert-media/eventmanager-bundle composer require mummert/eventmanager-bundle
``` ```
## Installation im Contao Manager (VCS) ## Installation im Contao Manager (VCS)
@@ -18,7 +18,7 @@ composer require mummert-media/eventmanager-bundle
1. **Contao Manager****Composer****Repositories****Repository hinzufügen** 1. **Contao Manager****Composer****Repositories****Repository hinzufügen**
2. Typ: `vcs` 2. Typ: `vcs`
3. URL: `https://github.com/<owner>/eventmanager-bundle` 3. URL: `https://github.com/<owner>/eventmanager-bundle`
4. Paket hinzufügen: `mummert-media/eventmanager-bundle` 4. Paket hinzufügen: `mummert/eventmanager-bundle`
5. Bei Bedarf Version `dev-main` oder ein Tag (z. B. `1.0.0`) wählen 5. Bei Bedarf Version `dev-main` oder ein Tag (z. B. `1.0.0`) wählen
6. Änderungen anwenden 6. Änderungen anwenden
+4 -3
View File
@@ -1,5 +1,5 @@
{ {
"name": "mummert-media/eventmanager-bundle", "name": "mummert/eventmanager-bundle",
"description": "Internal Contao backend bundle for organizations, locations and event/member assignments.", "description": "Internal Contao backend bundle for organizations, locations and event/member assignments.",
"type": "contao-bundle", "type": "contao-bundle",
"license": "proprietary", "license": "proprietary",
@@ -11,16 +11,17 @@
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"contao/core-bundle": "^5.7", "contao/core-bundle": "^5.7",
"contao/calendar-bundle": "^5.7",
"contao/manager-plugin": "^2.0", "contao/manager-plugin": "^2.0",
"numero2/contao-tags": "^0.5" "numero2/contao-tags": "^0.5"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"MummertMedia\\EventManagerBundle\\": "src/" "Mummert\\EventManagerBundle\\": "src/"
} }
}, },
"extra": { "extra": {
"contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin" "contao-manager-plugin": "Mummert\\EventManagerBundle\\Contao\\Manager\\Plugin"
}, },
"prefer-stable": true, "prefer-stable": true,
"config": { "config": {
+5 -3
View File
@@ -3,14 +3,16 @@ services:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
MummertMedia\EventManagerBundle\: Mummert\EventManagerBundle\:
resource: ../src/ resource: ../src/
exclude: exclude:
- ../src/DependencyInjection/ - ../src/DependencyInjection/
- ../src/Contao/Manager/ - ../src/Contao/Manager/
- ../src/MummertMediaEventManagerBundle.php - ../src/EventManagerBundle.php
MummertMedia\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback: Mummert\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback:
tags: tags:
- { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit } - { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit }
- { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit } - { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit }
Mummert\EventManagerBundle\Service\MapModuleDataProvider: ~
+2 -2
View File
@@ -2,8 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
$GLOBALS['TL_MODELS']['tl_organization'] = MummertMedia\EventManagerBundle\Model\OrganizationModel::class; $GLOBALS['TL_MODELS']['tl_organization'] = Mummert\EventManagerBundle\Model\OrganizationModel::class;
$GLOBALS['TL_MODELS']['tl_location'] = MummertMedia\EventManagerBundle\Model\LocationModel::class; $GLOBALS['TL_MODELS']['tl_location'] = Mummert\EventManagerBundle\Model\LocationModel::class;
$GLOBALS['BE_MOD']['content']['eventmanager_organisationen'] = [ $GLOBALS['BE_MOD']['content']['eventmanager_organisationen'] = [
'tables' => ['tl_organization'], 'tables' => ['tl_organization'],
+162 -14
View File
@@ -3,14 +3,49 @@
declare(strict_types=1); declare(strict_types=1);
use Contao\Database; use Contao\Database;
use Contao\Controller;
use Contao\StringUtil; use Contao\StringUtil;
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_organizations'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_organizations'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_filter'] = '{title_legend},name,headline,type;{eventmanager_legend},cal_calendar,eventListDomId;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['event_filter'] = '{title_legend},name,headline,type;{eventmanager_legend},cal_calendar,eventListDomId;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['eventmanager_map'] = '{title_legend},name,headline,type;{eventmanager_legend},mapShowOrganizations,mapOrganizationListPage,organizationTypeTags,mapShowExternalOrganizations,mapShowEvents,mapInitialDisplay,mapEventColor,mapOrganizationColor,mapPitch,mapCenterMode;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['organization_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,logoFolder,organizationTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['organization_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,logoFolder,organizationTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,eventFolder,termsPage,frontendAuthorId,frontendArchiveId,eventTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['event_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,eventFolder,termsPage,frontendAuthorId,frontendArchiveId,eventTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$getUsedOrganizationTagOptions = static function (): array {
$database = Database::getInstance();
$labelExpression = $database->fieldExists('title', 'tl_tags')
? "COALESCE(NULLIF(t.title, ''), t.tag)"
: 't.tag';
$rows = $database
->prepare(sprintf(
'SELECT DISTINCT t.id, %1$s AS label
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id=r.tag_id
WHERE r.ptable=? AND (r.field=? OR r.field IS NULL OR r.field = \'\')
ORDER BY label ASC',
$labelExpression,
))
->execute('tl_organization', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$label = trim((string) ($row['label'] ?? ''));
if ('' === $label) {
continue;
}
$options[(int) $row['id']] = $label;
}
return $options;
};
$GLOBALS['TL_DCA']['tl_module']['fields']['editPage'] = [ $GLOBALS['TL_DCA']['tl_module']['fields']['editPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'], 'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'],
'exclude' => true, 'exclude' => true,
@@ -71,20 +106,7 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['organizationTypeTags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'], 'label' => &$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'],
'exclude' => true, 'exclude' => true,
'inputType' => 'checkbox', 'inputType' => 'checkbox',
'options_callback' => static function () { 'options_callback' => $getUsedOrganizationTagOptions,
$rows = Database::getInstance()
->prepare('SELECT DISTINCT t.id, t.tag FROM tl_tags t LEFT JOIN tl_tags_rel r ON r.tag_id=t.id AND r.ptable=? AND r.field=? ORDER BY t.tag ASC')
->execute('tl_organization', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['tag'];
}
return $options;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'], 'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'sql' => ['type' => 'blob', 'notnull' => false], 'sql' => ['type' => 'blob', 'notnull' => false],
'save_callback' => [ 'save_callback' => [
@@ -157,3 +179,129 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['eventListDomId'] = [
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
]; ];
$GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'mapCenterMode';
$GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'mapInitialDisplay';
$GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapCenterMode_custom'] = 'mapCenterLat,mapCenterLng,mapCenterZoom';
$GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapInitialDisplay_organization_tag'] = 'mapInitialOrganizationTagId';
$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowOrganizations'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapOrganizationListPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapOrganizationListPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => false, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowExternalOrganizations'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowEvents'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapInitialDisplay'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay'],
'exclude' => true,
'inputType' => 'select',
'options' => ['random', 'events', 'organization_tag'],
'reference' => &$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay_options'],
'eval' => ['mandatory' => true, 'submitOnChange' => true, 'tl_class' => 'w50', 'includeBlankOption' => false],
'sql' => ['type' => 'string', 'length' => 32, 'default' => 'random'],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapInitialOrganizationTagId'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapInitialOrganizationTagId'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => $getUsedOrganizationTagOptions,
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapEventColor'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapEventColor'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 7, 'rgxp' => 'hexcolor', 'colorpicker' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 7, 'default' => '#BC5067'],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapOrganizationColor'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColor'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 7, 'rgxp' => 'hexcolor', 'colorpicker' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 7, 'default' => '#BC5067'],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterMode'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'],
'exclude' => true,
'inputType' => 'select',
'options' => ['markers', 'custom'],
'reference' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'],
'eval' => ['submitOnChange' => true, 'mandatory' => true, 'tl_class' => 'clr w50', 'includeBlankOption' => false],
'sql' => ['type' => 'string', 'length' => 16, 'default' => 'markers'],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterLat'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterLat'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 32, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterLng'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterLng'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 32, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 32, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterZoom'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'maxlength' => 2, 'tl_class' => 'w50'],
'sql' => ['type' => 'smallint', 'unsigned' => true, 'default' => 12],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['mapPitch'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['mapPitch'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'maxlength' => 2, 'tl_class' => 'w50'],
'sql' => ['type' => 'smallint', 'unsigned' => true, 'default' => 0],
];
if (isset($GLOBALS['TL_DCA']['tl_module']['fields']['list_layout'])) {
$GLOBALS['TL_DCA']['tl_module']['fields']['list_layout']['options_callback'] = static function (): array {
$options = Controller::getTemplateGroup('list_');
if (!isset($options['list_default_organisationen'])) {
$options['list_default_organisationen'] = 'list_default_organisationen';
}
return $options;
};
}
+1
View File
@@ -10,4 +10,5 @@ $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['Meine Organisationen', 'L
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Organisation bearbeiten', 'Bearbeitungsformular für eine Organisation.']; $GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Organisation bearbeiten', 'Bearbeitungsformular für eine Organisation.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['Meine Veranstaltungen', 'Listet Veranstaltungen der zugeordneten Organisationen auf.']; $GLOBALS['TL_LANG']['FMD']['member_events'] = ['Meine Veranstaltungen', 'Listet Veranstaltungen der zugeordneten Organisationen auf.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Eventfilter', 'Filter für kommende Veranstaltungen (Tags, Orte, Veranstalter).']; $GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Eventfilter', 'Filter für kommende Veranstaltungen (Tags, Orte, Veranstalter).'];
$GLOBALS['TL_LANG']['FMD']['eventmanager_map'] = ['Eventkarte', 'Zeigt Veranstaltungsorte, Organisationen und Events auf einer Karte an.'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.']; $GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.'];
+22
View File
@@ -13,3 +13,25 @@ $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['ID des Newsarchivs', '
$GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Eventlisten-ID (DOM)', 'Wählen Sie die ID der Eventliste, die durch das Event-Filter-Modul gefiltert werden soll.']; $GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Eventlisten-ID (DOM)', 'Wählen Sie die ID der Eventliste, die durch das Event-Filter-Modul gefiltert werden soll.'];
$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Anzuzeigende Organisationstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Organisationstyp-Tags. Leer = alle.']; $GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Anzuzeigende Organisationstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Organisationstyp-Tags. Leer = alle.'];
$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Anzuzeigende Veranstaltungstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Veranstaltungstyp-Tags. Leer = alle.']; $GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Anzuzeigende Veranstaltungstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Veranstaltungstyp-Tags. Leer = alle.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'] = ['Organisationen anzeigen', 'Wenn aktiviert, werden Organisations-Marker auf der Karte dargestellt.'];
$GLOBALS['TL_LANG']['tl_module']['mapOrganizationListPage'] = ['Seite für Organisationsliste', 'Optional: Seite, auf der die Organisationsliste liegt. Wird für den Link „mehr Infos“ im Organisations-Popup verwendet.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'] = ['Externe Organisationen anzeigen', 'Wenn aktiviert, werden externe Organisationen (isExternal=1) zusätzlich auf der Karte dargestellt. Standard: nein.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'] = ['Veranstaltungen anzeigen', 'Wenn aktiviert, werden Event- (inkl. Orts-) Marker auf der Karte dargestellt.'];
$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay'] = ['Initiale Anzeige', 'Definiert, welche Marker beim Laden der Karte initial angezeigt werden.'];
$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay_options'] = [
'random' => 'Zufällig',
'events' => 'Veranstaltungen',
'organization_tag' => 'Organisation mit Tag',
];
$GLOBALS['TL_LANG']['tl_module']['mapInitialOrganizationTagId'] = ['Initialer Organisationstag', 'Wählen Sie den Tag, der initial angezeigt werden soll (nur bei „Organisation mit Tag“).'];
$GLOBALS['TL_LANG']['tl_module']['mapEventColor'] = ['Event-Farbe (Kreise/Linien)', 'Farbe für Event-Cluster, Event-Punkte und Spiderfy-Verbindungslinien (Hex, z. B. #BC5067).'];
$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColor'] = ['Organisationsfarbe', 'Einheitliche Farbe für alle Organisations-Marker (Hex, z. B. #BC5067).'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'] = ['Karten-Zentrierung', 'Wählen Sie, ob die Karte anhand der Marker oder mit festen Koordinaten zentriert werden soll.'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [
'markers' => 'Anhand der Marker (alle Marker sichtbar)',
'custom' => 'Feste Geodaten + Zoom-Level',
];
$GLOBALS['TL_LANG']['tl_module']['mapCenterLat'] = ['Breitengrad (Center)', 'Breitengrad für die feste Kartenzentrierung, z. B. 51.0538'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterLng'] = ['Längengrad (Center)', 'Längengrad für die feste Kartenzentrierung, z. B. 13.3080'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'] = ['Zoom-Level (Center)', 'Zoom-Level für die feste Kartenzentrierung (z. B. 12).'];
$GLOBALS['TL_LANG']['tl_module']['mapPitch'] = ['Pitch (Neigung)', 'Kartenneigung in Grad (0-85). Standard bei leerer Angabe: 0.'];
+1
View File
@@ -10,4 +10,5 @@ $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['My organizations', 'Lists
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Edit organization', 'Edit form for one organization.']; $GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Edit organization', 'Edit form for one organization.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['My events', 'Lists events of the member organizations.']; $GLOBALS['TL_LANG']['FMD']['member_events'] = ['My events', 'Lists events of the member organizations.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Event filter', 'Filters upcoming events (tags, locations, organizers).']; $GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Event filter', 'Filters upcoming events (tags, locations, organizers).'];
$GLOBALS['TL_LANG']['FMD']['eventmanager_map'] = ['Event map', 'Displays locations, organizations and events on a map.'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Edit event', 'Edit form for one event.']; $GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Edit event', 'Edit form for one event.'];
+22
View File
@@ -13,3 +13,25 @@ $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['News archive ID', 'Arc
$GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Event list ID (DOM)', 'Select the event list ID that should be filtered by the event filter module.']; $GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Event list ID (DOM)', 'Select the event list ID that should be filtered by the event filter module.'];
$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Displayed organization types', 'Optional: Limit which organization type tags can be selected in the frontend. Empty = all.']; $GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Displayed organization types', 'Optional: Limit which organization type tags can be selected in the frontend. Empty = all.'];
$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Displayed event types', 'Optional: Limit which event type tags can be selected in the frontend. Empty = all.']; $GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Displayed event types', 'Optional: Limit which event type tags can be selected in the frontend. Empty = all.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'] = ['Show organizations', 'If enabled, organization markers are rendered on the map.'];
$GLOBALS['TL_LANG']['tl_module']['mapOrganizationListPage'] = ['Organization list page', 'Optional: page containing the organization list. Used for the "more info" link in organization popups.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'] = ['Show external organizations', 'If enabled, external organizations (isExternal=1) are additionally rendered on the map. Default: no.'];
$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'] = ['Show events', 'If enabled, event markers (including related locations) are rendered on the map.'];
$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay'] = ['Initial display', 'Defines which markers are initially shown when the map is loaded.'];
$GLOBALS['TL_LANG']['tl_module']['mapInitialDisplay_options'] = [
'random' => 'Random',
'events' => 'Events',
'organization_tag' => 'Organization with tag',
];
$GLOBALS['TL_LANG']['tl_module']['mapInitialOrganizationTagId'] = ['Initial organization tag', 'Select the tag to be initially shown (only for "Organization with tag").'];
$GLOBALS['TL_LANG']['tl_module']['mapEventColor'] = ['Event color (circles/lines)', 'Color for event clusters, event points and spiderfy connector lines (hex, e.g. #BC5067).'];
$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColor'] = ['Organization color', 'Unified color for all organization markers (hex, e.g. #BC5067).'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'] = ['Map centering', 'Choose whether the map should center by markers or fixed coordinates.'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [
'markers' => 'By markers (fit all visible markers)',
'custom' => 'Fixed coordinates + zoom level',
];
$GLOBALS['TL_LANG']['tl_module']['mapCenterLat'] = ['Center latitude', 'Latitude for fixed map centering, e.g. 51.0538'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterLng'] = ['Center longitude', 'Longitude for fixed map centering, e.g. 13.3080'];
$GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'] = ['Center zoom level', 'Zoom level for fixed map centering (e.g. 12).'];
$GLOBALS['TL_LANG']['tl_module']['mapPitch'] = ['Pitch', 'Map pitch in degrees (0-85). Default when empty: 0.'];
+210 -25
View File
@@ -15,6 +15,10 @@
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script> <script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
<script src="https://unpkg.com/filepond/dist/filepond.min.js"></script> <script src="https://unpkg.com/filepond/dist/filepond.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script> <script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-resize/dist/filepond-plugin-image-resize.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-transform/dist/filepond-plugin-image-transform.min.js"></script>
<script type="module" src="{{ asset('bundles/eventmanager/editor.js') }}?v=1"></script>
<script src="{{ asset('bundles/eventmanager/editor-fallback.js') }}?v=1"></script>
{{ form_start(form, { action: app.request.uri, attr: { 'aria-live': 'polite' } }) }} {{ form_start(form, { action: app.request.uri, attr: { 'aria-live': 'polite' } }) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
@@ -23,8 +27,14 @@
{{ form_row(form.title) }} {{ form_row(form.title) }}
{{ form_row(form.startDate) }} {{ form_row(form.startDate) }}
{{ form_row(form.endDate) }} {{ form_row(form.endDate) }}
{{ form_row(form.addTime) }} <div class="widget fullwidth">
<div id="event-time-wrap" style="display:none;" hidden aria-hidden="true"> <div class="ns-tag-switch-item">
{{ form_widget(form.addTime, { attr: { class: (form.addTime.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_label(form.addTime) }}
</div>
{{ form_errors(form.addTime) }}
</div>
<div id="event-time-wrap" hidden aria-hidden="true">
{{ form_row(form.startTime) }} {{ form_row(form.startTime) }}
{{ form_row(form.endTime) }} {{ form_row(form.endTime) }}
</div> </div>
@@ -32,22 +42,162 @@
{{ form_row(form.organization_ids) }} {{ form_row(form.organization_ids) }}
{% endif %} {% endif %}
{{ form_row(form.location_id) }} {{ form_row(form.location_id) }}
{{ form_row(form.tags) }}
{{ form_row(form.teaser) }}
{{ form_row(form.description) }}
{{ form_row(form.url) }}
{{ form_row(form.addImage) }}
<div id="event-image-upload-wrap" style="display:none;" hidden aria-hidden="true"> <fieldset class="widget" aria-describedby="event-tags-help event-tags-errors">
<legend>{{ form.tags.vars.label }}</legend>
<p id="event-tags-help" class="event-editor-shortcuts">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="event-tags-errors">{{ form_errors(form.tags) }}</div>
</fieldset>
<div class="widget">
{{ form_label(form.teaser, null, { label_attr: { id: form.teaser.vars.id ~ '-label' } }) }}
<div
class="event-editor-toolbar"
data-mm-editor-toolbar="{{ form.teaser.vars.id }}"
role="toolbar"
aria-label="Formatierungswerkzeuge Beschreibung"
aria-orientation="horizontal"
aria-describedby="{{ form.teaser.vars.id }}-shortcuts"
aria-controls="{{ form.teaser.vars.id }}-editor"
>
<button type="button" data-action="paragraph" title="Absatz">
<img src="{{ asset('bundles/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/icons/redo.svg') }}" alt="" aria-hidden="true">
<span class="visually-hidden">Wiederholen</span>
</button>
</div>
<div id="{{ form.teaser.vars.id }}-shortcuts" class="event-editor-shortcuts">
<strong>Tastaturkürzel:</strong>
<ul>
<li><span aria-hidden="true">Strg/Cmd+B</span> Fett</li>
<li><span aria-hidden="true">Strg/Cmd+I</span> Kursiv</li>
<li><span aria-hidden="true">Strg/Cmd+U</span> Unterstrichen</li>
<li><span aria-hidden="true">Strg/Cmd+Z</span> Rückgängig</li>
<li><span aria-hidden="true">Strg/Cmd+Shift+Z</span> Wiederholen</li>
<li>Pfeiltasten links/rechts in der Toolbar zwischen Buttons wechseln</li>
</ul>
</div>
<div
id="{{ form.teaser.vars.id }}-editor"
class="event-editor"
data-mm-editor="tiptap"
data-textarea-id="{{ form.teaser.vars.id }}"
data-editor-label="Beschreibung"
role="textbox"
aria-multiline="true"
aria-labelledby="{{ form.teaser.vars.id }}-label"
aria-describedby="{{ form.teaser.vars.id }}-shortcuts {{ form.teaser.vars.id }}-counter {{ form.teaser.vars.id }}-errors"
></div>
<div id="{{ form.teaser.vars.id }}-counter" class="event-editor-counter" data-mm-editor-counter-for="{{ form.teaser.vars.id }}" role="status" aria-live="polite"></div>
{{ form_widget(form.teaser, { attr: { class: 'js-event-teaser-source', rows: 8, 'aria-hidden': 'true', tabindex: '-1' } }) }}
<div id="{{ form.teaser.vars.id }}-errors">{{ form_errors(form.teaser) }}</div>
</div>
{{ form_row(form.url) }}
<div class="widget fullwidth">
<div class="ns-tag-switch-item">
{{ form_widget(form.addImage, { attr: { class: (form.addImage.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_label(form.addImage) }}
</div>
{{ form_errors(form.addImage) }}
</div>
<div id="event-image-upload-wrap" hidden aria-hidden="true">
{{ form_row(form.eventUpload) }} {{ form_row(form.eventUpload) }}
{{ form_row(form.photographer) }} {{ form_row(form.photographer) }}
<p class="help-text">Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.</p> <p class="help-text">Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.</p>
</div> </div>
{{ form_row(form.termsAccepted) }} <div class="widget fullwidth">
{{ form_row(form.isSoldOut) }} <div class="ns-tag-switch-item">
{{ form_row(form.isCanceled) }} {{ form_widget(form.termsAccepted, { attr: { class: (form.termsAccepted.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_row(form.published) }} {{ form_label(form.termsAccepted) }}
</div>
{{ form_errors(form.termsAccepted) }}
</div>
<div class="widget fullwidth">
<div class="ns-tag-switch-item">
{{ form_widget(form.isSoldOut, { attr: { class: (form.isSoldOut.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_label(form.isSoldOut) }}
</div>
{{ form_errors(form.isSoldOut) }}
</div>
<div class="widget fullwidth">
<div class="ns-tag-switch-item">
{{ form_widget(form.isCanceled, { attr: { class: (form.isCanceled.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_label(form.isCanceled) }}
</div>
{{ form_errors(form.isCanceled) }}
</div>
<div class="widget fullwidth">
<div class="ns-tag-switch-item">
{{ form_widget(form.published, { attr: { class: (form.published.vars.attr.class|default('') ~ ' ns-tag-switch-input')|trim, role: 'switch' } }) }}
{{ form_label(form.published) }}
</div>
{{ form_errors(form.published) }}
</div>
<div class="actions" aria-label="Formularaktionen"> <div class="actions" aria-label="Formularaktionen">
<button type="submit" id="save-btn" disabled>Speichern</button> <button type="submit" id="save-btn" disabled>Speichern</button>
<button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button> <button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button>
@@ -59,7 +209,7 @@
(function () { (function () {
const locationSelect = document.querySelector('select.js-location-choice'); const locationSelect = document.querySelector('select.js-location-choice');
const organizationSelect = document.querySelector('select.js-organization-choice'); const organizationSelect = document.querySelector('select.js-organization-choice');
const typeSelect = document.querySelector('select.js-event-tags-choice'); const tagSwitches = document.querySelectorAll('input.ns-tag-switch-input[role="switch"]');
if (organizationSelect && typeof window.Choices === 'function') { if (organizationSelect && typeof window.Choices === 'function') {
new window.Choices(organizationSelect, { new window.Choices(organizationSelect, {
@@ -86,18 +236,21 @@
}); });
} }
if (typeSelect && typeof window.Choices === 'function') { const syncSwitchState = (input) => {
new window.Choices(typeSelect, { const isChecked = !!input.checked;
searchEnabled: false, input.setAttribute('aria-checked', isChecked ? 'true' : 'false');
shouldSort: false,
removeItemButton: true, const stateNode = document.querySelector(`[data-switch-state-for="${input.id}"]`);
itemSelectText: '',
placeholder: true, if (stateNode) {
placeholderValue: typeSelect.dataset.placeholder || 'Typen suchen …', stateNode.textContent = isChecked ? 'Ein' : 'Aus';
noResultsText: 'Keine Treffer', }
noChoicesText: 'Keine Optionen verfügbar' };
});
} tagSwitches.forEach((input) => {
syncSwitchState(input);
input.addEventListener('change', () => syncSwitchState(input));
});
if (typeof window.flatpickr === 'function') { if (typeof window.flatpickr === 'function') {
const dateInputs = document.querySelectorAll('input.js-flatpickr-date'); const dateInputs = document.querySelectorAll('input.js-flatpickr-date');
@@ -212,14 +365,46 @@
if (typeof window.FilePondPluginImagePreview !== 'undefined') { if (typeof window.FilePondPluginImagePreview !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImagePreview); window.FilePond.registerPlugin(window.FilePondPluginImagePreview);
} }
if (typeof window.FilePondPluginImageResize !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImageResize);
}
if (typeof window.FilePondPluginImageTransform !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImageTransform);
}
const filePondOptions = { const filePondOptions = {
instantUpload: false, instantUpload: false,
storeAsFile: true, storeAsFile: true,
allowMultiple: false, allowMultiple: false,
allowReplace: true, allowReplace: true,
stylePanelAspectRatio: 0.6666667,
styleItemPanelAspectRatio: 0.6666667,
credits: false, credits: false,
acceptedFileTypes: ['image/*'], acceptedFileTypes: ['image/*'],
allowImageResize: true,
imageResizeUpscale: false,
allowImageTransform: true,
onaddfile: function (error, fileItem) {
if (error || !fileItem || typeof fileItem.setMetadata !== 'function') {
return;
}
const fileType = fileItem.fileType || (fileItem.file && fileItem.file.type) || '';
if (fileType === 'image/svg+xml') {
fileItem.setMetadata('resize', null);
return;
}
fileItem.setMetadata('resize', {
mode: 'contain',
upscale: false,
size: {
width: 2000,
height: 2000
}
});
},
labelIdle: 'Bild hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>' labelIdle: 'Bild hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>'
}; };
+294 -98
View File
@@ -1,78 +1,56 @@
<div id="eventfilters" data-eventlist-id="{{ targetEventListId|default('eventlist')|e('html_attr') }}"> <div id="eventfilters" data-eventlist-id="{{ targetEventListId|default('eventlist')|e('html_attr') }}">
<div class="tag-buttons"> <div class="select-filters">
<button type="button" data-filter-tag="all" class="active" aria-pressed="true">Alle</button> <div class="widget-select category">
<label for="tag-filter" class="visually-hidden">Kategorie wählen</label>
<select id="tag-filter" placeholder="Kategorie wählen" autocomplete="off">
<option value="">Kategorie wählen</option>
{% for tag in tagButtons|default([]) %}
<option value="tag-{{ tag.id }}">{{ tag.title }} ({{ tag.count }})</option>
{% endfor %}
</select>
<button type="button" class="eventfilter-clear" data-eventfilter-clear="tag" aria-label="Kategorie-Filter zurücksetzen" hidden><span class="icon-cross"></span></button>
</div>
{% for tag in tagButtons|default([]) %} <div class="widget-select places">
<button type="button" data-filter-tag="{{ tag.id }}" aria-pressed="false">{{ tag.title }} ({{ tag.count }})</button> <label for="location-filter" class="visually-hidden">Ort wählen</label>
{% endfor %} <select id="location-filter" placeholder="Ort wählen" autocomplete="off">
</div> <option value="">Ort wählen</option>
{% for location in locations|default([]) %}
<option value="location-{{ location.id }}">{{ location.title }} ({{ location.count }})</option>
{% endfor %}
</select>
<button type="button" class="eventfilter-clear" data-eventfilter-clear="location" aria-label="Ort-Filter zurücksetzen" hidden><span class="icon-cross"></span></button>
</div>
<div class="widget-select places"> <div class="widget-select org">
<label for="location-filter" class="visually-hidden">Orte:</label> <label for="org-filter" class="visually-hidden">Veranstalter wählen</label>
<select id="location-filter"> <select id="org-filter" placeholder="Veranstalter wählen" autocomplete="off">
<option value="all">Orte</option> <option value="">Veranstalter wählen</option>
{% for location in locations|default([]) %} {% for organization in organizations|default([]) %}
<option value="location-{{ location.id }}">{{ location.title }} ({{ location.count }})</option> <option value="org-{{ organization.id }}">{{ organization.title }} ({{ organization.count }})</option>
{% endfor %} {% endfor %}
</select> </select>
</div> <button type="button" class="eventfilter-clear" data-eventfilter-clear="org" aria-label="Veranstalter-Filter zurücksetzen" hidden><span class="icon-cross"></span></button>
</div>
<div class="widget-select org">
<label for="org-filter" class="visually-hidden">Veranstalter:</label>
<select id="org-filter">
<option value="all">Veranstalter</option>
{% for organization in organizations|default([]) %}
<option value="org-{{ organization.id }}">{{ organization.title }} ({{ organization.count }})</option>
{% endfor %}
</select>
</div> </div>
<p id="eventfilter-status" class="visually-hidden" aria-live="polite"></p> <p id="eventfilter-status" class="visually-hidden" aria-live="polite"></p>
</div> </div>
<style> <style>
.visually-hidden { .event-filter-target-list > .event {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.event-filter-target-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.event-filter-target-list .event {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
transition: opacity 220ms ease, transform 220ms ease; transition: opacity 180ms ease, transform 180ms ease;
transition-behavior: allow-discrete;
will-change: opacity, transform; will-change: opacity, transform;
} }
.event-filter-target-list .event.is-filtered-out { .event-filter-target-list > .event.is-filtered-out {
opacity: 0; opacity: 0;
transform: translateY(6px); transform: translateY(6px);
pointer-events: none; pointer-events: none;
} }
#eventfilters .active,
#eventfilters select.active {
outline: 2px solid currentColor;
outline-offset: 2px;
}
#eventfilters button:focus-visible,
#eventfilters select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style> </style>
<script type="module"> <script type="module">
@@ -84,22 +62,201 @@
const targetEventListId = filters.dataset.eventlistId || 'eventlist'; const targetEventListId = filters.dataset.eventlistId || 'eventlist';
const list = document.getElementById(targetEventListId); const list = document.getElementById(targetEventListId);
const initialStoredEventListView = (() => {
try {
return (window.localStorage.getItem('eventlist_view') || '').trim();
} catch (error) {
return '';
}
})();
const applyDefaultGrid = (targetList) => {
if (!targetList) {
return;
}
targetList.classList.add('event-filter-target-list');
targetList.classList.add('grid');
};
if (!list) { if (!list) {
console.warn(`[event_filter] Target event list #${targetEventListId} was not found.`); console.warn(`[event_filter] Target event list #${targetEventListId} was not found.`);
window.addEventListener('DOMContentLoaded', () => {
applyDefaultGrid(document.getElementById(targetEventListId));
}, { once: true });
} }
list?.classList.add('event-filter-target-list'); applyDefaultGrid(list);
window.addEventListener('DOMContentLoaded', () => {
window.setTimeout(() => {
if ('' !== initialStoredEventListView) {
return;
}
const targetList = document.getElementById(targetEventListId);
if (!targetList) {
return;
}
targetList.classList.remove('list');
applyDefaultGrid(targetList);
const toggleToList = document.getElementById('toggletolist');
const toggleToGrid = document.getElementById('toggletogrid');
toggleToList?.classList.remove('disabled');
toggleToGrid?.classList.add('disabled');
try {
window.localStorage.setItem('eventlist_view', 'grid');
} catch (error) {
}
}, 0);
}, { once: true });
const events = list ? Array.from(list.querySelectorAll(':scope > .event')) : []; const events = list ? Array.from(list.querySelectorAll(':scope > .event')) : [];
const tagButtons = Array.from(filters.querySelectorAll('button[data-filter-tag]')); const tagSelect = filters.querySelector('#tag-filter');
const locationSelect = filters.querySelector('#location-filter'); const locationSelect = filters.querySelector('#location-filter');
const orgSelect = filters.querySelector('#org-filter'); const orgSelect = filters.querySelector('#org-filter');
const tagWidget = tagSelect?.closest('.widget-select');
const locationWidget = locationSelect?.closest('.widget-select');
const orgWidget = orgSelect?.closest('.widget-select');
const clearTagButton = filters.querySelector('[data-eventfilter-clear="tag"]');
const clearLocationButton = filters.querySelector('[data-eventfilter-clear="location"]');
const clearOrgButton = filters.querySelector('[data-eventfilter-clear="org"]');
const status = filters.querySelector('#eventfilter-status'); const status = filters.querySelector('#eventfilter-status');
const stateStorageKey = 'event-filter-state';
const stateQueryKey = 'event_filter';
const animationMs = 220; const animationMs = 180;
let hideTimers = new WeakMap(); let hideTimers = new WeakMap();
let currentFilter = { type: 'tag', value: 'all' }; let currentFilter = { type: 'all', value: '' };
let suppressedChangeEvents = 0;
const hasOptionValue = (selectElement, value) => {
if (!selectElement) {
return false;
}
return Array.from(selectElement.options).some((option) => option.value === value);
};
const rawValueToFilterState = (rawValue) => {
const value = (rawValue || '').trim();
if (!value) {
return { type: 'all', value: '' };
}
if (value.startsWith('tag-') && hasOptionValue(tagSelect, value)) {
return { type: 'tag', value: value.replace('tag-', '') };
}
if (value.startsWith('location-') && hasOptionValue(locationSelect, value)) {
return { type: 'location', value: value.replace('location-', '') };
}
if (value.startsWith('org-') && hasOptionValue(orgSelect, value)) {
return { type: 'org', value: value.replace('org-', '') };
}
return { type: 'all', value: '' };
};
const filterStateToRawValue = (filterState) => {
if (!filterState.value || filterState.type === 'all') {
return '';
}
if (filterState.type === 'tag') {
return `tag-${filterState.value}`;
}
if (filterState.type === 'location') {
return `location-${filterState.value}`;
}
if (filterState.type === 'org') {
return `org-${filterState.value}`;
}
return '';
};
const readUrlState = () => {
const url = new URL(window.location.href);
return (url.searchParams.get(stateQueryKey) || '').trim();
};
const writeUrlState = (value) => {
const url = new URL(window.location.href);
if (value) {
url.searchParams.set(stateQueryKey, value);
} else {
url.searchParams.delete(stateQueryKey);
}
window.history.replaceState(window.history.state, '', url);
};
const readStoredState = () => {
try {
return (window.sessionStorage.getItem(stateStorageKey) || '').trim();
} catch (error) {
return '';
}
};
const writeStoredState = (value) => {
try {
if (value) {
window.sessionStorage.setItem(stateStorageKey, value);
} else {
window.sessionStorage.removeItem(stateStorageKey);
}
} catch (error) {
// noop
}
};
const syncState = (filterState) => {
const rawValue = filterStateToRawValue(filterState);
writeStoredState(rawValue);
writeUrlState(rawValue);
};
const applyControlState = (filterState) => {
const tagValue = filterState.type === 'tag' ? `tag-${filterState.value}` : '';
const locationValue = filterState.type === 'location' ? `location-${filterState.value}` : '';
const orgValue = filterState.type === 'org' ? `org-${filterState.value}` : '';
setSelectValue(tagSelect, tagValue);
setSelectValue(locationSelect, locationValue);
setSelectValue(orgSelect, orgValue);
};
const setSelectValue = (selectElement, value) => {
if (!selectElement) {
return;
}
if (selectElement.value === value) {
return;
}
suppressedChangeEvents += 1;
try {
selectElement.value = value;
} finally {
queueMicrotask(() => {
suppressedChangeEvents = Math.max(0, suppressedChangeEvents - 1);
});
}
};
const clearHideTimer = (eventItem) => { const clearHideTimer = (eventItem) => {
const timer = hideTimers.get(eventItem); const timer = hideTimers.get(eventItem);
@@ -132,19 +289,33 @@
}; };
const setActiveControl = ({ type, value }) => { const setActiveControl = ({ type, value }) => {
tagButtons.forEach((button) => { const hasActiveTag = type === 'tag' && Boolean(value);
const isActive = type === 'tag' && button.dataset.filterTag === value; const hasActiveLocation = type === 'location' && Boolean(value);
button.classList.toggle('active', isActive); const hasActiveOrg = type === 'org' && Boolean(value);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
if (locationSelect) { tagWidget?.classList.toggle('active', hasActiveTag);
locationSelect.classList.toggle('active', type === 'location'); locationWidget?.classList.toggle('active', hasActiveLocation);
orgWidget?.classList.toggle('active', hasActiveOrg);
if (clearTagButton) {
clearTagButton.hidden = !hasActiveTag;
} }
if (orgSelect) { if (clearLocationButton) {
orgSelect.classList.toggle('active', type === 'org'); clearLocationButton.hidden = !hasActiveLocation;
} }
if (clearOrgButton) {
clearOrgButton.hidden = !hasActiveOrg;
}
};
const resetAllFilters = () => {
setSelectValue(tagSelect, '');
setSelectValue(locationSelect, '');
setSelectValue(orgSelect, '');
applyFilter({ type: 'all', value: '' });
}; };
const parseIdList = (rawValue) => (rawValue ?? '') const parseIdList = (rawValue) => (rawValue ?? '')
@@ -153,7 +324,7 @@
.filter(Boolean); .filter(Boolean);
const matches = (eventItem, filterState) => { const matches = (eventItem, filterState) => {
if (filterState.value === 'all') { if (!filterState.value || filterState.type === 'all') {
return true; return true;
} }
@@ -177,12 +348,12 @@
return; return;
} }
const visibleCount = events.filter((eventItem) => !eventItem.hidden).length; const visibleCount = events.filter((eventItem) => matches(eventItem, filterState)).length;
let filterText = 'alle'; let filterText = 'alle';
if (filterState.type === 'tag' && filterState.value !== 'all') { if (filterState.type === 'tag' && tagSelect) {
const activeButton = tagButtons.find((button) => button.dataset.filterTag === filterState.value); const option = tagSelect.options[tagSelect.selectedIndex];
filterText = activeButton ? activeButton.textContent.trim() : `Tag ${filterState.value}`; filterText = option ? option.textContent.trim() : 'Kategorie wählen';
} }
if (filterState.type === 'location' && locationSelect) { if (filterState.type === 'location' && locationSelect) {
@@ -211,49 +382,74 @@
}); });
updateStatus(filterState); updateStatus(filterState);
syncState(filterState);
}; };
tagButtons.forEach((button) => { tagSelect?.addEventListener('change', () => {
button.addEventListener('click', () => { if (suppressedChangeEvents > 0) {
if (locationSelect) { return;
locationSelect.value = 'all'; }
}
if (orgSelect) { const selectedValue = tagSelect.value;
orgSelect.value = 'all';
}
applyFilter({ type: 'tag', value: button.dataset.filterTag ?? 'all' }); setSelectValue(locationSelect, '');
}); setSelectValue(orgSelect, '');
applyFilter(
!selectedValue
? { type: 'all', value: '' }
: { type: 'tag', value: selectedValue.replace('tag-', '') },
);
}); });
locationSelect?.addEventListener('change', () => { locationSelect?.addEventListener('change', () => {
const selectedValue = locationSelect.value; if (suppressedChangeEvents > 0) {
return;
if (orgSelect) {
orgSelect.value = 'all';
} }
const selectedValue = locationSelect.value;
setSelectValue(tagSelect, '');
setSelectValue(orgSelect, '');
applyFilter( applyFilter(
selectedValue === 'all' !selectedValue
? { type: 'tag', value: 'all' } ? { type: 'all', value: '' }
: { type: 'location', value: selectedValue.replace('location-', '') }, : { type: 'location', value: selectedValue.replace('location-', '') },
); );
}); });
orgSelect?.addEventListener('change', () => { orgSelect?.addEventListener('change', () => {
const selectedValue = orgSelect.value; if (suppressedChangeEvents > 0) {
return;
if (locationSelect) {
locationSelect.value = 'all';
} }
const selectedValue = orgSelect.value;
setSelectValue(tagSelect, '');
setSelectValue(locationSelect, '');
applyFilter( applyFilter(
selectedValue === 'all' !selectedValue
? { type: 'tag', value: 'all' } ? { type: 'all', value: '' }
: { type: 'org', value: selectedValue.replace('org-', '') }, : { type: 'org', value: selectedValue.replace('org-', '') },
); );
}); });
applyFilter(currentFilter); clearTagButton?.addEventListener('click', resetAllFilters);
clearLocationButton?.addEventListener('click', resetAllFilters);
clearOrgButton?.addEventListener('click', resetAllFilters);
const urlState = readUrlState();
const storedState = readStoredState();
const initialState = rawValueToFilterState(urlState || storedState);
applyControlState(initialState);
applyFilter(initialState);
window.addEventListener('popstate', () => {
const stateFromUrl = rawValueToFilterState(readUrlState());
applyControlState(stateFromUrl);
applyFilter(stateFromUrl);
});
</script> </script>
@@ -0,0 +1,88 @@
{% set tags = mapOrganizationTags|default([]) %}
{% set showOrganizations = mapShowOrganizations|default(false) %}
{% set showEvents = mapShowEvents|default(false) %}
{% set showTagButtons = showOrganizations and tags is iterable and tags|length > 0 %}
{% set showEventButton = showOrganizations and showEvents %}
{% set showFilterButtons = showTagButtons or showEventButton %}
<section
id="{{ mapFilterWrapperId|e('html_attr') }}"
class="eventmanager-map-filter"
data-map-filter-wrapper="1"
role="region"
aria-label="Kartenfilter"
>
{% if showFilterButtons %}
<button
type="button"
class="eventmanager-map-filter__toggle is-expanded"
data-map-filter-toggle="1"
aria-expanded="true"
aria-controls="{{ mapFilterGroupId|e('html_attr') }}"
>
<span class="eventmanager-map-filter__toggle-label eventmanager-map-filter__toggle-label--expand">Filter einblenden</span>
<span class="eventmanager-map-filter__toggle-label eventmanager-map-filter__toggle-label--collapse">Filter ausblenden</span>
</button>
{% endif %}
<div
id="{{ mapFilterGroupId|e('html_attr') }}"
class="eventmanager-map-filter__group"
role="group"
aria-label="Organisationstypen"
>
{% if showTagButtons %}
{% for tag in tags %}
<button
type="button"
class="eventmanager-map-filter__tag"
data-map-tag-filter="{{ tag.id|e('html_attr') }}"
aria-pressed="false"
>{{ tag.label|e }}</button>
{% endfor %}
{% endif %}
{% if showEventButton %}
<button
type="button"
class="eventmanager-map-filter__tag"
data-map-event-toggle="1"
aria-pressed="false"
>Veranstaltungen</button>
{% endif %}
<button
type="button"
class="eventmanager-map-filter__tag"
data-map-style-mode="street"
aria-pressed="true"
>Straße</button>
<button
type="button"
class="eventmanager-map-filter__tag"
data-map-style-mode="satellite"
aria-pressed="false"
>Satellit</button>
</div>
</section>
<div
id="{{ mapContainerId|e('html_attr') }}"
class="eventmanager-map"
data-eventmanager-map="1"
data-map-filter-wrapper-id="{{ mapFilterWrapperId|default('')|e('html_attr') }}"
data-map-style="{{ mapStyleUrl|e('html_attr') }}"
data-map-data-id="{{ mapDataElementId|e('html_attr') }}"
data-map-event-color="{{ mapEventColor|default('#BC5067')|e('html_attr') }}"
data-map-organization-color="{{ mapOrganizationColor|default('#BC5067')|e('html_attr') }}"
data-map-initial-display="{{ mapInitialDisplay|default('all')|e('html_attr') }}"
data-map-initial-tag-id="{{ mapInitialTagId|default(0)|e('html_attr') }}"
data-map-center-mode="{{ mapCenterMode|default('markers')|e('html_attr') }}"
data-map-center-lat="{{ mapCenterLat|default('')|e('html_attr') }}"
data-map-center-lng="{{ mapCenterLng|default('')|e('html_attr') }}"
data-map-center-zoom="{{ mapCenterZoom|default(12)|e('html_attr') }}"
data-map-pitch="{{ mapPitch|default(0)|e('html_attr') }}"
></div>
<script type="application/json" id="{{ mapDataElementId|e('html_attr') }}">{{ mapItemsJson|raw }}</script>
<script type="module" src="/bundles/eventmanager/assets/map-module.js?v=20260227b"></script>
@@ -16,30 +16,32 @@
</span> </span>
{% if isEditor %} {% if isEditor %}
<form method="post" style="display:inline;" aria-label="Sichtbarkeit für {{ item.title }} ändern"> <div class="member-events-actions">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <form method="post" class="member-events-action-form" aria-label="Sichtbarkeit für {{ item.title }} ändern">
<input type="hidden" name="action" value="toggle_published"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="action" value="toggle_published">
<button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button> <input type="hidden" name="event_id" value="{{ item.id }}">
</form> <button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button>
</form>
{% if item.editUrl %} {% if item.editUrl %}
<a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a> <a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a>
{% endif %} {% endif %}
<form method="post" style="display:inline;" aria-label="{{ item.title }} duplizieren"> <form method="post" class="member-events-action-form" data-confirm-message="{{ ('Möchten Sie das Event \"' ~ item.title ~ '\" wirklich duplizieren?')|e('html_attr') }}" aria-label="{{ item.title }} duplizieren">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="duplicate"> <input type="hidden" name="action" value="duplicate">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button> <button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button>
</form> </form>
<form method="post" style="display:inline;" onsubmit="return confirm('wirklich löschen?');" aria-label="{{ item.title }} löschen"> <form method="post" class="member-events-action-form" data-confirm-message="{{ ('Möchten Sie das Event \"' ~ item.title ~ '\" wirklich löschen?')|e('html_attr') }}" aria-label="{{ item.title }} löschen">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} löschen">Löschen</button> <button type="submit" aria-label="{{ item.title }} löschen">Löschen</button>
</form> </form>
</div>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
@@ -54,6 +56,8 @@
</form> </form>
{% endif %} {% endif %}
<script src="/bundles/eventmanager/assets/member-events-confirm.js?v=20260301a"></script>
<h2>Vergangene Veranstaltungen</h2> <h2>Vergangene Veranstaltungen</h2>
{% if pastEvents is empty %} {% if pastEvents is empty %}
<p>Keine vergangenen Veranstaltungen gefunden.</p> <p>Keine vergangenen Veranstaltungen gefunden.</p>
@@ -69,30 +73,32 @@
</span> </span>
{% if isEditor %} {% if isEditor %}
<form method="post" style="display:inline;" aria-label="Sichtbarkeit für {{ item.title }} ändern"> <div class="member-events-actions">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <form method="post" class="member-events-action-form" aria-label="Sichtbarkeit für {{ item.title }} ändern">
<input type="hidden" name="action" value="toggle_published"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="action" value="toggle_published">
<button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button> <input type="hidden" name="event_id" value="{{ item.id }}">
</form> <button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button>
</form>
{% if item.editUrl %} {% if item.editUrl %}
<a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a> <a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a>
{% endif %} {% endif %}
<form method="post" style="display:inline;" aria-label="{{ item.title }} duplizieren"> <form method="post" class="member-events-action-form" data-confirm-message="{{ ('Möchten Sie das Event \"' ~ item.title ~ '\" wirklich duplizieren?')|e('html_attr') }}" aria-label="{{ item.title }} duplizieren">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="duplicate"> <input type="hidden" name="action" value="duplicate">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button> <button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button>
</form> </form>
<form method="post" style="display:inline;" onsubmit="return confirm('wirklich löschen?');" aria-label="{{ item.title }} löschen"> <form method="post" class="member-events-action-form" data-confirm-message="{{ ('Möchten Sie das Event \"' ~ item.title ~ '\" wirklich löschen?')|e('html_attr') }}" aria-label="{{ item.title }} löschen">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="event_id" value="{{ item.id }}"> <input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} löschen">Löschen</button> <button type="submit" aria-label="{{ item.title }} löschen">Löschen</button>
</form> </form>
</div>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
@@ -5,17 +5,141 @@
<p role="alert">{{ error }}</p> <p role="alert">{{ error }}</p>
<p><a href="{{ backUrl }}">Zurück</a></p> <p><a href="{{ backUrl }}">Zurück</a></p>
{% elseif form is defined and form %} {% elseif form is defined and form %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css"> <link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css"> <link rel="stylesheet" href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css">
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
<script src="https://unpkg.com/filepond/dist/filepond.min.js"></script> <script src="https://unpkg.com/filepond/dist/filepond.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script> <script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-resize/dist/filepond-plugin-image-resize.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-transform/dist/filepond-plugin-image-transform.min.js"></script>
<script type="module" src="{{ asset('bundles/eventmanager/editor.js') }}?v=1"></script>
<script src="{{ asset('bundles/eventmanager/editor-fallback.js') }}?v=1"></script>
{{ form_start(form, { attr: { 'aria-live': 'polite' } }) }} {{ form_start(form, { attr: { 'aria-live': 'polite' } }) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" id="remove_logo" name="remove_logo" value="0"> <input type="hidden" id="remove_logo" name="remove_logo" value="0">
{{ form_widget(form) }} {{ form_row(form.title) }}
{{ form_row(form.street) }}
{{ form_row(form.street2) }}
{{ form_row(form.postal) }}
{{ form_row(form.city) }}
{{ form_row(form.phone) }}
{{ form_row(form.email) }}
{{ form_row(form.website) }}
<div class="widget">
{{ form_label(form.description, null, { label_attr: { id: form.description.vars.id ~ '-label' } }) }}
<div
class="event-editor-toolbar"
data-mm-editor-toolbar="{{ form.description.vars.id }}"
role="toolbar"
aria-label="Formatierungswerkzeuge Beschreibung"
aria-orientation="horizontal"
aria-describedby="{{ form.description.vars.id }}-shortcuts"
aria-controls="{{ form.description.vars.id }}-editor"
>
<button type="button" data-action="paragraph" title="Absatz">
<img src="{{ asset('bundles/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/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/eventmanager/icons/redo.svg') }}" alt="" aria-hidden="true">
<span class="visually-hidden">Wiederholen</span>
</button>
</div>
<div id="{{ form.description.vars.id }}-shortcuts" class="event-editor-shortcuts">
<strong>Tastaturkürzel:</strong>
<ul>
<li><span aria-hidden="true">Strg/Cmd+B</span> Fett</li>
<li><span aria-hidden="true">Strg/Cmd+I</span> Kursiv</li>
<li><span aria-hidden="true">Strg/Cmd+U</span> Unterstrichen</li>
<li><span aria-hidden="true">Strg/Cmd+Z</span> Rückgängig</li>
<li><span aria-hidden="true">Strg/Cmd+Shift+Z</span> Wiederholen</li>
<li>Pfeiltasten links/rechts in der Toolbar zwischen Buttons wechseln</li>
</ul>
</div>
<div
id="{{ form.description.vars.id }}-editor"
class="event-editor"
data-mm-editor="tiptap"
data-textarea-id="{{ form.description.vars.id }}"
data-editor-label="Beschreibung"
role="textbox"
aria-multiline="true"
aria-labelledby="{{ form.description.vars.id }}-label"
aria-describedby="{{ form.description.vars.id }}-shortcuts {{ form.description.vars.id }}-counter {{ form.description.vars.id }}-errors"
></div>
<div id="{{ form.description.vars.id }}-counter" class="event-editor-counter" data-mm-editor-counter-for="{{ form.description.vars.id }}" role="status" aria-live="polite"></div>
{{ form_widget(form.description, { attr: { class: 'js-organization-description-source', rows: 8, 'aria-hidden': 'true', tabindex: '-1' } }) }}
<div id="{{ form.description.vars.id }}-errors">{{ form_errors(form.description) }}</div>
</div>
<fieldset class="widget" aria-describedby="organization-tags-help organization-tags-errors">
<legend>{{ form.tags.vars.label }}</legend>
<p id="organization-tags-help" class="event-editor-shortcuts">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="organization-tags-errors">{{ form_errors(form.tags) }}</div>
</fieldset>
{{ form_row(form.logoUpload) }}
<div class="actions" aria-label="Formularaktionen"> <div class="actions" aria-label="Formularaktionen">
<button type="submit" id="save-btn" disabled>Speichern</button> <button type="submit" id="save-btn" disabled>Speichern</button>
<button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button> <button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button>
@@ -25,20 +149,23 @@
<script> <script>
(function () { (function () {
const typeSelect = document.querySelector('select.js-organization-tags-choice'); const tagSwitches = document.querySelectorAll('input.ns-tag-switch-input[role="switch"]');
if (typeSelect && typeof window.Choices === 'function') { const syncSwitchState = (input) => {
new window.Choices(typeSelect, { const isChecked = !!input.checked;
searchEnabled: false, input.setAttribute('aria-checked', isChecked ? 'true' : 'false');
shouldSort: false,
removeItemButton: true, const stateNode = document.querySelector(`[data-switch-state-for="${input.id}"]`);
itemSelectText: '',
placeholder: true, if (stateNode) {
placeholderValue: typeSelect.dataset.placeholder || 'Typ auswählen …', stateNode.textContent = isChecked ? 'Ein' : 'Aus';
noResultsText: 'Keine Treffer', }
noChoicesText: 'Keine Optionen verfügbar' };
});
} tagSwitches.forEach((input) => {
syncSwitchState(input);
input.addEventListener('change', () => syncSwitchState(input));
});
const currentLogoPath = {{ (currentLogoPath is defined and currentLogoPath) ? ('/' ~ currentLogoPath)|json_encode|raw : 'null' }}; const currentLogoPath = {{ (currentLogoPath is defined and currentLogoPath) ? ('/' ~ currentLogoPath)|json_encode|raw : 'null' }};
const logoInput = document.querySelector('input.js-logo-upload'); const logoInput = document.querySelector('input.js-logo-upload');
@@ -73,14 +200,46 @@
if (typeof window.FilePondPluginImagePreview !== 'undefined') { if (typeof window.FilePondPluginImagePreview !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImagePreview); window.FilePond.registerPlugin(window.FilePondPluginImagePreview);
} }
if (typeof window.FilePondPluginImageResize !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImageResize);
}
if (typeof window.FilePondPluginImageTransform !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImageTransform);
}
const filePondOptions = { const filePondOptions = {
instantUpload: false, instantUpload: false,
storeAsFile: true, storeAsFile: true,
allowMultiple: false, allowMultiple: false,
allowReplace: true, allowReplace: true,
stylePanelAspectRatio: 1,
styleItemPanelAspectRatio: 1,
credits: false, credits: false,
acceptedFileTypes: ['image/*'], acceptedFileTypes: ['image/*'],
allowImageResize: true,
imageResizeUpscale: false,
allowImageTransform: true,
onaddfile: function (error, fileItem) {
if (error || !fileItem || typeof fileItem.setMetadata !== 'function') {
return;
}
const fileType = fileItem.fileType || (fileItem.file && fileItem.file.type) || '';
if (fileType === 'image/svg+xml') {
fileItem.setMetadata('resize', null);
return;
}
fileItem.setMetadata('resize', {
mode: 'contain',
upscale: false,
size: {
width: 800,
height: 800
}
});
},
labelIdle: 'Logo hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>' labelIdle: 'Logo hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>'
}; };
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
(function () {
var decodeHtmlEntities = function (value) {
var textarea = document.createElement('textarea');
textarea.innerHTML = String(value || '');
return textarea.value;
};
var handleSubmit = function (event) {
var target = event.target;
if (!(target instanceof HTMLFormElement)) {
return;
}
var message = decodeHtmlEntities(target.getAttribute('data-confirm-message') || '');
if (!message) {
return;
}
if (!window.confirm(message)) {
event.preventDefault();
}
};
document.addEventListener('submit', handleSubmit, true);
})();
File diff suppressed because one or more lines are too long
+112
View File
@@ -0,0 +1,112 @@
(function () {
const mounts = document.querySelectorAll('[data-mm-editor="tiptap"]');
if (!mounts.length) {
return;
}
const runFallback = (mount) => {
if (!mount || mount.querySelector('.ProseMirror') || mount.getAttribute('data-mm-fallback-ready') === '1') {
return;
}
const textareaId = mount.getAttribute('data-textarea-id');
if (!textareaId) {
return;
}
const textarea = document.getElementById(textareaId);
if (!textarea) {
return;
}
const toolbar = document.querySelector('[data-mm-editor-toolbar="' + textareaId + '"]');
const form = textarea.closest('form');
mount.setAttribute('contenteditable', 'true');
mount.setAttribute('data-mm-fallback-ready', '1');
mount.classList.add('mm-fallback-editor');
mount.innerHTML = textarea.value || '<p></p>';
textarea.style.display = 'none';
const syncState = () => {
textarea.value = mount.innerHTML;
};
const focusMount = () => {
mount.focus();
};
mount.addEventListener('input', syncState);
mount.addEventListener('blur', syncState);
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');
focusMount();
switch (action) {
case 'paragraph':
document.execCommand('formatBlock', false, 'p');
break;
case 'h2':
document.execCommand('formatBlock', false, 'h2');
break;
case 'h3':
document.execCommand('formatBlock', false, 'h3');
break;
case 'bold':
document.execCommand('bold', false);
break;
case 'italic':
document.execCommand('italic', false);
break;
case 'underline':
document.execCommand('underline', false);
break;
case 'bulletList':
document.execCommand('insertUnorderedList', false);
break;
case 'orderedList':
document.execCommand('insertOrderedList', false);
break;
case 'indent':
document.execCommand('indent', false);
break;
case 'outdent':
document.execCommand('outdent', false);
break;
case 'undo':
document.execCommand('undo', false);
break;
case 'redo':
document.execCommand('redo', false);
break;
default:
break;
}
syncState();
});
}
if (form) {
form.addEventListener('submit', syncState);
}
};
window.setTimeout(() => {
mounts.forEach(runFallback);
}, 900);
})();
+330
View File
@@ -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 CHARACTER_LIMIT = 30000;
(function () {
const editorMounts = document.querySelectorAll('[data-mm-editor="tiptap"]');
if (!editorMounts.length) {
return;
}
const syncState = (editor, textarea) => {
textarea.value = editor.getHTML();
};
const updateEditorA11yState = (mount, textarea, textareaId, editor) => {
if (!mount || !textareaId || !editor || !textarea) {
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;
const label = mount.getAttribute('data-editor-label') || textarea.getAttribute('aria-label') || 'Text';
mount.setAttribute('aria-invalid', hasError ? 'true' : 'false');
mount.setAttribute('aria-required', textarea.required ? 'true' : 'false');
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', textarea.required ? 'true' : 'false');
proseMirror.setAttribute('aria-label', label);
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 = CHARACTER_LIMIT - used;
counterNode.textContent = `${words.toLocaleString('de-DE')} Wörter · ${used.toLocaleString('de-DE')} / ${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:
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-mm-editor-toolbar="${textareaId}"]`);
const counterNode = document.querySelector(`[data-mm-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: CHARACTER_LIMIT,
}),
],
content: textarea.value || '<p></p>',
onCreate({ editor: currentEditor }) {
updateToolbarState(toolbar, currentEditor);
updateCharacterCounter(counterNode, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
onUpdate({ editor: currentEditor }) {
syncState(currentEditor, textarea);
updateToolbarState(toolbar, currentEditor);
updateCharacterCounter(counterNode, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
onSelectionUpdate({ editor: currentEditor }) {
updateToolbarState(toolbar, currentEditor);
updateEditorA11yState(mount, textarea, textareaId, currentEditor);
},
});
editor.on('transaction', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, textareaId, editor);
});
editor.on('focus', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, textareaId, editor);
});
editor.on('blur', () => {
updateToolbarState(toolbar, editor);
updateCharacterCounter(counterNode, editor);
updateEditorA11yState(mount, textarea, 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, textarea, textareaId, editor);
});
}
updateEditorA11yState(mount, textarea, textareaId, editor);
});
})();
+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+1
View File
@@ -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

+4
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+4
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+3
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+3 -3
View File
@@ -2,21 +2,21 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Contao\Manager; namespace Mummert\EventManagerBundle\Contao\Manager;
use Contao\CalendarBundle\ContaoCalendarBundle; use Contao\CalendarBundle\ContaoCalendarBundle;
use Contao\CoreBundle\ContaoCoreBundle; use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface; use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig; use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface; use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use MummertMedia\EventManagerBundle\MummertMediaEventManagerBundle; use Mummert\EventManagerBundle\EventManagerBundle;
class Plugin implements BundlePluginInterface class Plugin implements BundlePluginInterface
{ {
public function getBundles(ParserInterface $parser): iterable public function getBundles(ParserInterface $parser): iterable
{ {
return [ return [
BundleConfig::create(MummertMediaEventManagerBundle::class) BundleConfig::create(EventManagerBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), ->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
]; ];
} }
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend; namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
@@ -13,8 +13,8 @@ use Contao\FrontendUser;
use Contao\ModuleModel; use Contao\ModuleModel;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil; use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Form\EventType; use Mummert\EventManagerBundle\Form\EventType;
use MummertMedia\EventManagerBundle\Service\EventRepository; use Mummert\EventManagerBundle\Service\EventRepository;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -103,8 +103,9 @@ class EventEditController extends AbstractFrontendModuleController
'endTime' => $this->resolveFormEndTime($event), 'endTime' => $this->resolveFormEndTime($event),
'location_id' => (int) ($event['location_id'] ?? 0), 'location_id' => (int) ($event['location_id'] ?? 0),
'tags' => $currentTagIds, 'tags' => $currentTagIds,
'teaser' => (string) ($event['teaser'] ?? ''), 'teaser' => '' !== trim((string) ($event['teaser'] ?? ''))
'description' => (string) ($event['description'] ?? ''), ? (string) ($event['teaser'] ?? '')
: (string) ($event['description'] ?? ''),
'url' => (string) ($event['url'] ?? ''), 'url' => (string) ($event['url'] ?? ''),
'photographer' => (string) ($event['photographer'] ?? ''), 'photographer' => (string) ($event['photographer'] ?? ''),
'addImage' => '1' === (string) ($event['addImage'] ?? ''), 'addImage' => '1' === (string) ($event['addImage'] ?? ''),
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend; namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Contao\PageModel;
use Contao\StringUtil;
use Mummert\EventManagerBundle\Service\MapModuleDataProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'eventmanager_map', category: 'eventmanager', template: 'frontend/event_map')]
class EventMapController extends AbstractFrontendModuleController
{
private const MAP_STYLE_URL = 'https://maps.mummert.media/metadaten/world-light.json';
private const DEFAULT_CENTER_MODE = 'markers';
private const DEFAULT_EVENT_COLOR = '#BC5067';
private const DEFAULT_INITIAL_DISPLAY = 'random';
/**
* @var list<string>
*/
private const ALLOWED_INITIAL_DISPLAYS = ['random', 'events', 'organization_tag'];
public function __construct(
private readonly MapModuleDataProvider $mapModuleDataProvider,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$containerId = sprintf('eventmanager-map-%d', (int) ($model->id ?? 0));
$dataElementId = sprintf('%s-data', $containerId);
$filterWrapperId = sprintf('%s-filter', $containerId);
$filterGroupId = sprintf('%s-filter-group', $containerId);
$showOrganizations = '1' === (string) ($model->mapShowOrganizations ?? '');
$showExternalOrganizations = '1' === (string) ($model->mapShowExternalOrganizations ?? '');
$showEvents = '1' === (string) ($model->mapShowEvents ?? '');
$selectedOrganizationTagIds = array_values(array_unique(array_filter(
array_map('intval', StringUtil::deserialize($model->organizationTypeTags ?? null, true)),
static fn (int $tagId): bool => $tagId > 0,
)));
$availableOrganizationTags = $this->mapModuleDataProvider->getOrganizationTags();
$availableOrganizationTagIds = array_map(
static fn (array $tag): int => (int) ($tag['id'] ?? 0),
$availableOrganizationTags,
);
$selectedOrganizationTagIds = [] === $selectedOrganizationTagIds
? []
: array_values(array_intersect($selectedOrganizationTagIds, $availableOrganizationTagIds));
$organizationListPageId = (int) ($model->mapOrganizationListPage ?? 0);
$organizationListBaseUrl = $this->buildOrganizationListBaseUrl($organizationListPageId, $request);
$initialDisplay = (string) ($model->mapInitialDisplay ?? self::DEFAULT_INITIAL_DISPLAY);
$initialOrganizationTagId = (int) ($model->mapInitialOrganizationTagId ?? 0);
$centerMode = (string) ($model->mapCenterMode ?? self::DEFAULT_CENTER_MODE);
$mapPitch = $this->normalizePitch($model->mapPitch ?? 0);
$eventColor = $this->normalizeHexColor((string) ($model->mapEventColor ?? self::DEFAULT_EVENT_COLOR));
$organizationColor = $this->normalizeHexColor((string) ($model->mapOrganizationColor ?? $eventColor), $eventColor);
if (!in_array($centerMode, ['markers', 'custom'], true)) {
$centerMode = self::DEFAULT_CENTER_MODE;
}
if (!in_array($initialDisplay, self::ALLOWED_INITIAL_DISPLAYS, true)) {
$initialDisplay = self::DEFAULT_INITIAL_DISPLAY;
}
if ($initialOrganizationTagId < 0) {
$initialOrganizationTagId = 0;
}
$template->set('mapContainerId', $containerId);
$template->set('mapDataElementId', $dataElementId);
$template->set('mapFilterWrapperId', $filterWrapperId);
$template->set('mapFilterGroupId', $filterGroupId);
$template->set('mapStyleUrl', self::MAP_STYLE_URL);
$template->set('mapShowOrganizations', $showOrganizations);
$template->set('mapShowEvents', $showEvents);
$template->set('mapCenterMode', $centerMode);
$template->set('mapEventColor', $eventColor);
$template->set('mapOrganizationColor', $organizationColor);
$template->set('mapInitialDisplay', $initialDisplay);
$template->set('mapInitialTagId', $initialOrganizationTagId);
$template->set('mapCenterLat', trim((string) ($model->mapCenterLat ?? '')));
$template->set('mapCenterLng', trim((string) ($model->mapCenterLng ?? '')));
$template->set('mapCenterZoom', (int) ($model->mapCenterZoom ?? 12));
$template->set('mapPitch', $mapPitch);
$template->set('mapItemsJson', json_encode(
$this->mapModuleDataProvider->getMapItems($showOrganizations, $showEvents, $showExternalOrganizations, $selectedOrganizationTagIds, $organizationListBaseUrl),
\JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR,
));
$template->set('mapOrganizationTags', $this->mapModuleDataProvider->getOrganizationTags($selectedOrganizationTagIds));
return $template->getResponse();
}
private function normalizeHexColor(string $value, string $fallback = self::DEFAULT_EVENT_COLOR): string
{
$normalized = strtoupper(trim($value));
if (preg_match('/^#[0-9A-F]{6}$/', $normalized)) {
return $normalized;
}
if (preg_match('/^[0-9A-F]{6}$/', $normalized)) {
return '#'.$normalized;
}
if (preg_match('/^#[0-9A-F]{3}$/', $normalized)) {
return sprintf(
'#%1$s%1$s%2$s%2$s%3$s%3$s',
$normalized[1],
$normalized[2],
$normalized[3],
);
}
if (preg_match('/^[0-9A-F]{3}$/', $normalized)) {
return sprintf(
'#%1$s%1$s%2$s%2$s%3$s%3$s',
$normalized[0],
$normalized[1],
$normalized[2],
);
}
return $fallback;
}
private function normalizePitch(mixed $value): int
{
if (!is_scalar($value)) {
return 0;
}
$parsedPitch = (int) round((float) str_replace(',', '.', (string) $value));
if ($parsedPitch < 0) {
return 0;
}
if ($parsedPitch > 85) {
return 85;
}
return $parsedPitch;
}
private function buildOrganizationListBaseUrl(int $pageId, Request $request): string
{
if ($pageId <= 0) {
return '';
}
$pageModel = PageModel::findById($pageId);
if (null === $pageModel) {
return '';
}
$frontendUrl = trim((string) $pageModel->getFrontendUrl());
if ('' === $frontendUrl) {
return '';
}
if (preg_match('#^https?://#i', $frontendUrl)) {
return $frontendUrl;
}
return rtrim($request->getSchemeAndHttpHost(), '/').'/'.ltrim($frontendUrl, '/');
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend; namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
@@ -11,7 +11,7 @@ use Contao\FrontendUser;
use Contao\ModuleModel; use Contao\ModuleModel;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil; use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Service\EventRepository; use Mummert\EventManagerBundle\Service\EventRepository;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -65,7 +65,14 @@ class MemberEventsController extends AbstractFrontendModuleController
if ('toggle_published' === $action) { if ('toggle_published' === $action) {
$this->eventRepository->togglePublished($eventId); $this->eventRepository->togglePublished($eventId);
} elseif ('duplicate' === $action) { } elseif ('duplicate' === $action) {
$this->eventRepository->duplicate($eventId); $newEventId = $this->eventRepository->duplicate($eventId);
if ($newEventId > 0 && $editPage instanceof PageModel) {
return new RedirectResponse($this->generateContentUrl($editPage, [
'event' => (string) $newEventId,
'ref' => base64_encode($backUrl),
]));
}
} elseif ('delete' === $action) { } elseif ('delete' === $action) {
$this->eventRepository->delete($eventId); $this->eventRepository->delete($eventId);
} }
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend; namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
@@ -11,7 +11,7 @@ use Contao\FrontendUser;
use Contao\ModuleModel; use Contao\ModuleModel;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil; use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Service\OrganizationRepository; use Mummert\EventManagerBundle\Service\OrganizationRepository;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend; namespace Mummert\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController; use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule; use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
@@ -13,8 +13,8 @@ use Contao\FrontendUser;
use Contao\ModuleModel; use Contao\ModuleModel;
use Contao\PageModel; use Contao\PageModel;
use Contao\StringUtil; use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Form\OrganizationType; use Mummert\EventManagerBundle\Form\OrganizationType;
use MummertMedia\EventManagerBundle\Service\OrganizationRepository; use Mummert\EventManagerBundle\Service\OrganizationRepository;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -70,14 +70,13 @@ class OrganizationEditController extends AbstractFrontendModuleController
$formData = [ $formData = [
'title' => (string) ($organization['title'] ?? ''), 'title' => (string) ($organization['title'] ?? ''),
'street' => (string) ($organization['street'] ?? ''), 'street' => (string) ($organization['street'] ?? ''),
'street2' => (string) ($organization['street2'] ?? ''),
'postal' => (string) ($organization['postal'] ?? ''), 'postal' => (string) ($organization['postal'] ?? ''),
'city' => (string) ($organization['city'] ?? ''), 'city' => (string) ($organization['city'] ?? ''),
'state' => (string) ($organization['state'] ?? ''),
'country' => (string) ($organization['country'] ?? ''),
'phone' => (string) ($organization['phone'] ?? ''), 'phone' => (string) ($organization['phone'] ?? ''),
'email' => (string) ($organization['email'] ?? ''), 'email' => (string) ($organization['email'] ?? ''),
'website' => (string) ($organization['website'] ?? ''), 'website' => (string) ($organization['website'] ?? ''),
'description' => (string) ($organization['description'] ?? ''), 'description' => $this->normalizeTextareaDescription((string) ($organization['description'] ?? '')),
'tags' => $this->organizationRepository->getTagIdsForOrganization($organizationId), 'tags' => $this->organizationRepository->getTagIdsForOrganization($organizationId),
]; ];
@@ -219,4 +218,14 @@ class OrganizationEditController extends AbstractFrontendModuleController
return $page instanceof PageModel ? $this->generateContentUrl($page) : '/'; return $page instanceof PageModel ? $this->generateContentUrl($page) : '/';
} }
private function normalizeTextareaDescription(string $value): string
{
$decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$decoded = preg_replace('/<\s*br\s*\/?>/i', "\n", $decoded) ?? $decoded;
$decoded = preg_replace('/<\s*\/p\s*>\s*<\s*p[^>]*>/i', "\n\n", $decoded) ?? $decoded;
$decoded = strip_tags($decoded);
return trim($decoded);
}
} }
@@ -2,14 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\DependencyInjection; namespace Mummert\EventManagerBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class MummertMediaEventManagerExtension extends Extension class EventManagerExtension extends Extension
{ {
public function load(array $configs, ContainerBuilder $container): void public function load(array $configs, ContainerBuilder $container): void
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener\DataContainer; namespace Mummert\EventManagerBundle\EventListener\DataContainer;
use Contao\DataContainer; use Contao\DataContainer;
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener; namespace Mummert\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\StringUtil; use Contao\StringUtil;
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener; namespace Mummert\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\Module; use Contao\Module;
@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\StringUtil;
use Contao\Template;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Psr\Log\LoggerInterface;
#[AsHook('parseTemplate', method: 'onParseTemplate')]
class OrganizationListingTemplateDataListener
{
public function __construct(
private readonly Connection $connection,
private readonly ?LoggerInterface $logger = null,
) {
}
public function onParseTemplate(Template $template): void
{
if ('list_default_organisationen' !== (string) $template->getName()) {
return;
}
$tbody = $template->tbody;
if (!\is_array($tbody) || [] === $tbody) {
return;
}
$rowToOrganizationIdMap = [];
foreach ($tbody as $rowIndex => $row) {
if (!\is_array($row)) {
continue;
}
$organizationId = $this->extractOrganizationId($row);
if ($organizationId > 0) {
$rowToOrganizationIdMap[(int) $rowIndex] = $organizationId;
}
}
if ([] === $rowToOrganizationIdMap) {
return;
}
$organizationIds = array_values(array_unique(array_values($rowToOrganizationIdMap)));
$organizationTagMap = $this->fetchOrganizationTagMap($organizationIds);
$organizationLogoMap = $this->fetchOrganizationLogoMap($organizationIds);
$organizationTagLabelMap = [];
$rowTagIdsList = [];
foreach ($organizationTagMap as $tagData) {
foreach (($tagData['ids'] ?? []) as $index => $tagId) {
$label = trim((string) (($tagData['labels'][$index] ?? '') ?: ''));
if ('' !== $tagId && '' !== $label && !isset($organizationTagLabelMap[(string) $tagId])) {
$organizationTagLabelMap[(string) $tagId] = $label;
}
}
}
foreach ($rowToOrganizationIdMap as $rowIndex => $organizationId) {
if (!isset($tbody[$rowIndex]) || !\is_array($tbody[$rowIndex])) {
continue;
}
$tagData = $organizationTagMap[$organizationId] ?? ['ids' => [], 'labels' => []];
$logoData = $organizationLogoMap[$organizationId] ?? ['uuid' => '', 'isSvg' => false];
$logoUuid = (string) ($logoData['uuid'] ?? '');
$logoIsSvg = (bool) ($logoData['isSvg'] ?? false);
$tagIdsCsv = implode(',', $tagData['ids']);
$tagLabelsCsv = implode(', ', $tagData['labels']);
$tbody[$rowIndex]['tag_ids']['content'] = $tagIdsCsv;
$tbody[$rowIndex]['tag_labels']['content'] = $tagLabelsCsv;
$tbody[$rowIndex]['tags']['content'] = $tagLabelsCsv;
if ('' !== $logoUuid) {
$tbody[$rowIndex]['logo_uuid']['content'] = $logoUuid;
$tbody[$rowIndex]['logo_is_svg']['content'] = $logoIsSvg ? '1' : '';
}
}
foreach ($tbody as $rowIndex => $row) {
if (!\is_array($row)) {
$rowTagIdsList[] = '';
continue;
}
$organizationId = $rowToOrganizationIdMap[(int) $rowIndex] ?? 0;
$rowTagIdsList[] = ($organizationId > 0 && isset($organizationTagMap[$organizationId]))
? implode(',', $organizationTagMap[$organizationId]['ids'] ?? [])
: '';
}
if (null !== $this->logger) {
$rowsWithoutTagIds = 0;
foreach ($rowToOrganizationIdMap as $organizationId) {
if (!isset($organizationTagMap[$organizationId]) || [] === ($organizationTagMap[$organizationId]['ids'] ?? [])) {
++$rowsWithoutTagIds;
}
}
if ($rowsWithoutTagIds > 0) {
$this->logger->warning('Organization listing enrichment found rows without tag IDs.', [
'template' => (string) $template->getName(),
'totalRows' => \count($tbody),
'mappedRows' => \count($rowToOrganizationIdMap),
'rowsWithoutTagIds' => $rowsWithoutTagIds,
]);
}
}
$template->organization_tag_label_map = $organizationTagLabelMap;
$template->organization_row_tag_ids_list = $rowTagIdsList;
$template->tbody = $tbody;
}
/** @param array<string, mixed> $row */
private function extractOrganizationId(array $row): int
{
foreach (['id', 'organization_id', 'org_id'] as $fieldName) {
if (isset($row[$fieldName]) && \is_scalar($row[$fieldName]) && ctype_digit((string) $row[$fieldName])) {
return (int) $row[$fieldName];
}
$value = $this->extractRowFieldContent($row, $fieldName);
if ('' !== $value && ctype_digit($value)) {
return (int) $value;
}
}
foreach ($row as $column) {
if (!\is_array($column)) {
continue;
}
foreach (['url', 'href'] as $urlField) {
if (!isset($column[$urlField]) || !\is_scalar($column[$urlField])) {
continue;
}
$organizationId = $this->extractIdFromUrl((string) $column[$urlField]);
if ($organizationId > 0) {
return $organizationId;
}
}
}
return 0;
}
/** @param array<string, mixed> $row */
private function extractRowFieldContent(array $row, string $fieldName): string
{
if (!isset($row[$fieldName])) {
return '';
}
if (\is_scalar($row[$fieldName])) {
return trim((string) $row[$fieldName]);
}
if (!\is_array($row[$fieldName])) {
return '';
}
$field = $row[$fieldName];
if (!isset($field['content']) || !\is_scalar($field['content'])) {
return '';
}
return trim((string) $field['content']);
}
private function extractIdFromUrl(string $url): int
{
$parts = parse_url($url);
if (!\is_array($parts) || !isset($parts['query'])) {
return 0;
}
parse_str((string) $parts['query'], $query);
foreach (['show', 'id'] as $queryKey) {
if (!isset($query[$queryKey])) {
continue;
}
$value = $query[$queryKey];
if (\is_scalar($value) && ctype_digit((string) $value)) {
return (int) $value;
}
}
return 0;
}
/** @param list<int> $organizationIds
* @return array<int, array{ids: list<string>, labels: list<string>}>
*/
private function fetchOrganizationTagMap(array $organizationIds): array
{
if ([] === $organizationIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT r.pid AS organization_id, r.tag_id, t.tag AS label
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id = r.tag_id
WHERE r.ptable = ? AND r.field = ? AND r.pid IN (?)
ORDER BY r.pid ASC, r.tag_id ASC',
['tl_organization', 'tags', $organizationIds],
[ParameterType::STRING, ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->executeQuery(
'SELECT r.pid AS organization_id, r.tag_id, t.tag AS label
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id = r.tag_id
WHERE r.ptable = ? AND r.pid IN (?)
ORDER BY r.pid ASC, r.tag_id ASC',
['tl_organization', $organizationIds],
[ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
}
$map = [];
$seen = [];
foreach ($rows as $row) {
$organizationId = (int) ($row['organization_id'] ?? 0);
$tagId = (int) ($row['tag_id'] ?? 0);
$label = trim((string) ($row['label'] ?? ''));
if ($organizationId <= 0 || $tagId <= 0 || '' === $label || isset($seen[$organizationId][$tagId])) {
continue;
}
$seen[$organizationId][$tagId] = true;
$map[$organizationId]['ids'][] = (string) $tagId;
$map[$organizationId]['labels'][] = $label;
}
foreach ($map as $organizationId => $tagData) {
$map[$organizationId]['ids'] = array_values(array_unique($tagData['ids'] ?? []));
$map[$organizationId]['labels'] = array_values(array_unique($tagData['labels'] ?? []));
}
return $map;
}
/** @param list<int> $organizationIds
* @return array<int, array{uuid: string, isSvg: bool}>
*/
private function fetchOrganizationLogoMap(array $organizationIds): array
{
if ([] === $organizationIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT o.id AS organization_id, o.logo AS logo_uuid, f.extension AS file_extension, f.path AS file_path
FROM tl_organization o
LEFT JOIN tl_files f ON f.uuid = o.logo
WHERE o.id IN (?)',
[$organizationIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$organizationId = (int) ($row['organization_id'] ?? 0);
$logoUuid = $this->normalizeUuid($row['logo_uuid'] ?? null);
$extension = strtolower(trim((string) ($row['file_extension'] ?? '')));
if ('' === $extension && isset($row['file_path']) && \is_scalar($row['file_path'])) {
$extension = strtolower((string) pathinfo((string) $row['file_path'], PATHINFO_EXTENSION));
}
$isSvg = 'svg' === $extension;
if ($organizationId <= 0 || '' === $logoUuid) {
continue;
}
$map[$organizationId] = [
'uuid' => $logoUuid,
'isSvg' => $isSvg,
];
}
return $map;
}
private function normalizeUuid(mixed $value): string
{
if (null === $value) {
return '';
}
if (\is_resource($value)) {
$value = stream_get_contents($value) ?: '';
}
$trimmed = trim((string) $value);
if ('' === $trimmed) {
return '';
}
if (preg_match('/^[0-9a-fA-F-]{36}$/', $trimmed)) {
return strtolower($trimmed);
}
if (16 === strlen($trimmed)) {
return StringUtil::binToUuid($trimmed);
}
return '';
}
}
@@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle; namespace Mummert\EventManagerBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Bundle\Bundle;
class MummertMediaEventManagerBundle extends Bundle class EventManagerBundle extends Bundle
{ {
public function getPath(): string public function getPath(): string
{ {
+10 -8
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Form; namespace Mummert\EventManagerBundle\Form;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -119,17 +119,19 @@ class EventType extends AbstractType
], ],
]) ])
->add('tags', ChoiceType::class, [ ->add('tags', ChoiceType::class, [
'label' => 'Typen', 'label' => 'Kategorien',
'choices' => $options['tag_choices'], 'choices' => $options['tag_choices'],
'expanded' => true,
'multiple' => true, 'multiple' => true,
'required' => false, 'required' => false,
'attr' => [ 'choice_attr' => static function (): array {
'class' => 'js-event-tags-choice', return [
'data-placeholder' => 'Typen suchen …', 'role' => 'switch',
], 'class' => 'ns-tag-switch-input',
];
},
]) ])
->add('teaser', TextareaType::class, ['label' => 'Teaser', 'required' => false]) ->add('teaser', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('url', UrlType::class, [ ->add('url', UrlType::class, [
'label' => 'Link (extern)', 'label' => 'Link (extern)',
'required' => false, 'required' => false,
+10 -8
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Form; namespace Mummert\EventManagerBundle\Form;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -20,23 +20,25 @@ class OrganizationType extends AbstractType
$builder $builder
->add('title', TextType::class, ['label' => 'Titel', 'required' => true]) ->add('title', TextType::class, ['label' => 'Titel', 'required' => true])
->add('street', TextType::class, ['label' => 'Straße', 'required' => false]) ->add('street', TextType::class, ['label' => 'Straße', 'required' => false])
->add('street2', TextType::class, ['label' => 'Weitere Adressangaben', 'required' => false])
->add('postal', TextType::class, ['label' => 'PLZ', 'required' => false]) ->add('postal', TextType::class, ['label' => 'PLZ', 'required' => false])
->add('city', TextType::class, ['label' => 'Ort', 'required' => false]) ->add('city', TextType::class, ['label' => 'Ort', 'required' => false])
->add('state', TextType::class, ['label' => 'Bundesland', 'required' => false])
->add('country', TextType::class, ['label' => 'Land', 'required' => false])
->add('phone', TextType::class, ['label' => 'Telefon', 'required' => false]) ->add('phone', TextType::class, ['label' => 'Telefon', 'required' => false])
->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false]) ->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false])
->add('website', TextType::class, ['label' => 'Webseite', 'required' => false]) ->add('website', TextType::class, ['label' => 'Webseite', 'required' => false])
->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false]) ->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('tags', ChoiceType::class, [ ->add('tags', ChoiceType::class, [
'label' => 'Typen', 'label' => 'Kategorien',
'choices' => $options['tag_choices'], 'choices' => $options['tag_choices'],
'expanded' => true,
'multiple' => true, 'multiple' => true,
'required' => false, 'required' => false,
'attr' => [ 'choice_attr' => static function (): array {
'class' => 'js-organization-tags-choice', return [
'data-placeholder' => 'Typ auswählen …', 'role' => 'switch',
], 'class' => 'ns-tag-switch-input',
];
},
]) ])
->add('logoUpload', FileType::class, [ ->add('logoUpload', FileType::class, [
'label' => 'Logo hochladen', 'label' => 'Logo hochladen',
+1 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Model; namespace Mummert\EventManagerBundle\Model;
use Contao\Model; use Contao\Model;
+1 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Model; namespace Mummert\EventManagerBundle\Model;
use Contao\Model; use Contao\Model;
+3 -2
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Service; namespace Mummert\EventManagerBundle\Service;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
@@ -84,7 +84,8 @@ class EventRepository
unset($row['id']); unset($row['id']);
if (array_key_exists('title', $row)) { if (array_key_exists('title', $row)) {
$row['title'] = trim((string) $row['title'].' (Kopie)'); $normalizedTitle = html_entity_decode((string) $row['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$row['title'] = trim($normalizedTitle.' (Kopie)');
} }
if (array_key_exists('alias', $row)) { if (array_key_exists('alias', $row)) {
+567
View File
@@ -0,0 +1,567 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Service;
use Contao\CalendarEventsModel;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\Routing\Exception\ExceptionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MapModuleDataProvider
{
/**
* @var array<string,array<string,true>>
*/
private array $tableColumns = [];
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
private readonly ContentUrlGenerator $urlGenerator,
) {
}
/**
* @return list<array{type:string,markerType:string,id:int,title:string,latitude:float,longitude:float,extra:array<string,mixed>}>
*/
public function getMapItems(bool $includeOrganizations = true, bool $includeEvents = true, bool $includeExternalOrganizations = false, array $selectedOrganizationTagIds = [], string $organizationListBaseUrl = ''): array
{
if (!$includeOrganizations && !$includeEvents) {
return [];
}
$selectedOrganizationTagIds = array_values(array_unique(array_map(
static fn (int|string $tagId): string => (string) (int) $tagId,
array_filter($selectedOrganizationTagIds, static fn (int|string $tagId): bool => (int) $tagId > 0),
)));
$locationTable = $this->resolveExistingTable(['tl_location']);
$organizationTable = $this->resolveExistingTable(['tl_organization', 'tl_organisation']);
$locationGeoColumns = null !== $locationTable ? $this->resolveGeoColumns($locationTable) : null;
if (null === $locationTable || null === $locationGeoColumns) {
return [];
}
$items = [];
$seen = [];
$organizationRows = $includeOrganizations
? $this->fetchOrganizationRows($organizationTable, $locationTable, $locationGeoColumns, $includeExternalOrganizations)
: [];
$organizationTagMap = $this->fetchOrganizationTagMap(array_values(array_unique(array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$organizationRows,
))));
if ($includeOrganizations) {
foreach ($organizationRows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || isset($seen['organisation'][$id])) {
continue;
}
$locationId = (int) ($row['location_id'] ?? 0);
if ($locationId > 0) {
$coords = $this->extractCoordinates($row['location_latitude'] ?? null, $row['location_longitude'] ?? null);
if (null === $coords) {
continue;
}
} else {
$coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null);
}
if (null === $coords) {
continue;
}
$seen['organisation'][$id] = true;
$tagData = $organizationTagMap[$id] ?? ['ids' => [], 'labels' => []];
if ([] !== $selectedOrganizationTagIds
&& [] === array_intersect($selectedOrganizationTagIds, $tagData['ids'] ?? [])) {
continue;
}
$items[] = [
'type' => 'organisation',
'markerType' => $this->buildOrganizationMarkerType($tagData['ids']),
'id' => $id,
'title' => trim((string) ($row['title'] ?? '')),
'latitude' => $coords['latitude'],
'longitude' => $coords['longitude'],
'extra' => [
'organizationTagIds' => $tagData['ids'],
'organizationTagLabels' => $tagData['labels'],
'detailUrl' => $this->buildOrganizationDetailUrl($organizationListBaseUrl, $id),
],
];
}
}
if ($includeEvents) {
foreach ($this->fetchEventRows($locationTable, $locationGeoColumns) as $row) {
$id = (int) ($row['event_id'] ?? 0);
if ($id <= 0 || isset($seen['event'][$id])) {
continue;
}
$coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null);
if (null === $coords) {
continue;
}
$seen['event'][$id] = true;
$items[] = [
'type' => 'event',
'markerType' => 'event',
'id' => $id,
'title' => trim((string) ($row['event_title'] ?? '')),
'latitude' => $coords['latitude'],
'longitude' => $coords['longitude'],
'extra' => [
'locationTitle' => trim((string) ($row['location_title'] ?? '')),
'startDate' => $this->formatStartDateTime(
(int) ($row['startDate'] ?? 0),
(string) ($row['addTime'] ?? ''),
(int) ($row['startTime'] ?? 0),
),
'detailUrl' => $this->resolveEventDetailUrl($id),
],
];
}
}
return $items;
}
/**
* @return list<array{id:int,label:string}>
*/
public function getOrganizationTags(array $selectedTagIds = []): array
{
if (!$this->tableExists('tl_tags') || !$this->tableExists('tl_tags_rel')) {
return [];
}
$selectedTagIds = array_values(array_unique(array_filter(
array_map('intval', $selectedTagIds),
static fn (int $tagId): bool => $tagId > 0,
)));
$columns = $this->getColumnMap('tl_tags');
$labelColumn = isset($columns['title']) ? 'title' : (isset($columns['tag']) ? 'tag' : null);
if (null === $labelColumn) {
return [];
}
$qb = $this->connection->createQueryBuilder();
$qb
->select('DISTINCT t.id', sprintf('t.%s AS label', $labelColumn))
->from('tl_tags_rel', 'r')
->innerJoin('r', 'tl_tags', 't', 't.id = r.tag_id')
->andWhere('r.ptable = :organization_ptable')
->andWhere('(r.field = :organization_field OR r.field IS NULL OR r.field = \'\')')
->setParameter('organization_ptable', 'tl_organization')
->setParameter('organization_field', 'tags')
->orderBy(sprintf('t.%s', $labelColumn), 'ASC');
if ([] !== $selectedTagIds) {
$qb
->andWhere('t.id IN (:selectedTagIds)')
->setParameter('selectedTagIds', $selectedTagIds, ArrayParameterType::INTEGER);
}
if (isset($columns['invisible'])) {
$qb->andWhere("(t.invisible IS NULL OR t.invisible = '' OR t.invisible = '0')");
}
$rows = $qb->executeQuery()->fetchAllAssociative();
$tags = [];
$seen = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$label = trim((string) ($row['label'] ?? ''));
if ($id <= 0 || '' === $label || isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$tags[] = [
'id' => $id,
'label' => $label,
];
}
return $tags;
}
/**
* @param list<int> $organizationIds
* @return array<int, array{ids: list<string>, labels: list<string>}>
*/
private function fetchOrganizationTagMap(array $organizationIds): array
{
$organizationIds = array_values(array_unique(array_filter($organizationIds, static fn (int $id): bool => $id > 0)));
if ([] === $organizationIds || !$this->tableExists('tl_tags_rel') || !$this->tableExists('tl_tags')) {
return [];
}
$tagColumns = $this->getColumnMap('tl_tags');
$tagLabelColumn = isset($tagColumns['title']) ? 'title' : (isset($tagColumns['tag']) ? 'tag' : null);
if (null === $tagLabelColumn) {
return [];
}
$rows = $this->connection->executeQuery(
sprintf(
'SELECT r.pid AS organization_id, r.tag_id, t.%s AS label
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id = r.tag_id
WHERE r.ptable = ? AND r.field = ? AND r.pid IN (?)
ORDER BY r.pid ASC, r.tag_id ASC',
$tagLabelColumn,
),
['tl_organization', 'tags', $organizationIds],
[ParameterType::STRING, ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->executeQuery(
sprintf(
'SELECT r.pid AS organization_id, r.tag_id, t.%s AS label
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id = r.tag_id
WHERE r.ptable = ? AND r.pid IN (?)
ORDER BY r.pid ASC, r.tag_id ASC',
$tagLabelColumn,
),
['tl_organization', $organizationIds],
[ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
}
$map = [];
$seen = [];
foreach ($rows as $row) {
$organizationId = (int) ($row['organization_id'] ?? 0);
$tagId = (int) ($row['tag_id'] ?? 0);
$label = trim((string) ($row['label'] ?? ''));
if ($organizationId <= 0 || $tagId <= 0 || isset($seen[$organizationId][$tagId])) {
continue;
}
$seen[$organizationId][$tagId] = true;
$map[$organizationId]['ids'][] = (string) $tagId;
if ('' !== $label) {
$map[$organizationId]['labels'][] = $label;
}
}
foreach ($map as $organizationId => $tagData) {
$map[$organizationId]['ids'] = array_values(array_unique($tagData['ids'] ?? []));
$map[$organizationId]['labels'] = array_values(array_unique($tagData['labels'] ?? []));
}
return $map;
}
/**
* @param list<string> $tagIds
*/
private function buildOrganizationMarkerType(array $tagIds): string
{
$firstTagId = (string) ($tagIds[0] ?? '');
if ('' !== $firstTagId && ctype_digit($firstTagId) && (int) $firstTagId > 0) {
return sprintf('organisation-tag-%d', (int) $firstTagId);
}
return 'organisation';
}
/**
* @return list<array<string,mixed>>
*/
private function fetchOrganizationRows(?string $table, string $locationTable, array $locationGeoColumns, bool $includeExternalOrganizations): array
{
if (null === $table) {
return [];
}
$organizationGeoColumns = $this->resolveGeoColumns($table);
$organizationColumns = $this->getColumnMap($table);
$hasLocationReference = isset($organizationColumns['location_id']);
if (null === $organizationGeoColumns && !$hasLocationReference) {
return [];
}
$qb = $this->connection->createQueryBuilder();
$qb
->select('o.id', 'o.title')
->from($table, 'o')
->orderBy('o.id', 'ASC');
if (null !== $organizationGeoColumns) {
$qb
->addSelect(sprintf('o.%s AS latitude', $organizationGeoColumns['latitude']))
->addSelect(sprintf('o.%s AS longitude', $organizationGeoColumns['longitude']));
}
if ($hasLocationReference) {
$qb
->addSelect('o.location_id')
->leftJoin('o', $locationTable, 'ol', 'ol.id = o.location_id')
->addSelect(sprintf('ol.%s AS location_latitude', $locationGeoColumns['latitude']))
->addSelect(sprintf('ol.%s AS location_longitude', $locationGeoColumns['longitude']));
$this->applyPublicationConstraints($qb, 'ol', $locationTable);
}
if (!$includeExternalOrganizations && isset($organizationColumns['isexternal'])) {
$qb->andWhere("(o.isExternal IS NULL OR o.isExternal = '' OR o.isExternal = '0')");
}
$this->applyPublicationConstraints($qb, 'o', $table);
return $qb->executeQuery()->fetchAllAssociative();
}
/**
* @return list<array<string,mixed>>
*/
private function fetchEventRows(string $locationTable, array $locationGeoColumns): array
{
if (!$this->tableExists('tl_calendar_events')) {
return [];
}
$eventColumns = $this->getColumnMap('tl_calendar_events');
$today = strtotime('today');
$qb = $this->connection->createQueryBuilder();
$qb
->select(
'e.id AS event_id',
'e.title AS event_title',
'e.startDate',
'e.addTime',
'e.startTime',
'l.title AS location_title',
sprintf('l.%s AS latitude', $locationGeoColumns['latitude']),
sprintf('l.%s AS longitude', $locationGeoColumns['longitude'])
)
->from('tl_calendar_events', 'e')
->innerJoin('e', $locationTable, 'l', 'l.id = e.location_id')
->andWhere('e.location_id > 0')
->orderBy('e.id', 'ASC');
if (isset($eventColumns['startdate'])) {
$qb
->andWhere('e.startDate >= :event_start_date_from')
->setParameter('event_start_date_from', $today);
}
$this->applyPublicationConstraints($qb, 'e', 'tl_calendar_events');
$this->applyPublicationConstraints($qb, 'l', $locationTable);
return $qb->executeQuery()->fetchAllAssociative();
}
private function applyPublicationConstraints(QueryBuilder $qb, string $alias, string $table): void
{
$columns = $this->getColumnMap($table);
$now = time();
if (isset($columns['published'])) {
$qb
->andWhere(sprintf('%s.published = :%s_published', $alias, $alias))
->setParameter(sprintf('%s_published', $alias), '1');
}
if (isset($columns['start'])) {
$qb
->andWhere(sprintf('(%1$s.start IS NULL OR %1$s.start = 0 OR %1$s.start <= :%1$s_start_now)', $alias))
->setParameter(sprintf('%s_start_now', $alias), $now);
}
if (isset($columns['stop'])) {
$qb
->andWhere(sprintf('(%1$s.stop IS NULL OR %1$s.stop = 0 OR %1$s.stop > :%1$s_stop_now)', $alias))
->setParameter(sprintf('%s_stop_now', $alias), $now);
}
}
/**
* @return array{latitude:float,longitude:float}|null
*/
private function extractCoordinates(mixed $latitude, mixed $longitude): ?array
{
if (null === $latitude || null === $longitude) {
return null;
}
$lat = trim(str_replace(',', '.', (string) $latitude));
$lng = trim(str_replace(',', '.', (string) $longitude));
if ('' === $lat || '' === $lng || !is_numeric($lat) || !is_numeric($lng)) {
return null;
}
$latitudeFloat = (float) $lat;
$longitudeFloat = (float) $lng;
if (
($latitudeFloat < -90.0 || $latitudeFloat > 90.0)
|| ($longitudeFloat < -180.0 || $longitudeFloat > 180.0)
|| (0.0 === $latitudeFloat && 0.0 === $longitudeFloat)
) {
return null;
}
return [
'latitude' => $latitudeFloat,
'longitude' => $longitudeFloat,
];
}
private function formatStartDateTime(int $startDate, string $addTime, int $startTime): string
{
if ($startDate <= 0) {
return '';
}
$formattedDate = date('d.m.Y', $startDate);
if ('1' !== $addTime || $startTime <= 0) {
return $formattedDate;
}
return sprintf('%s %s Uhr', $formattedDate, date('H:i', $startTime));
}
private function resolveEventDetailUrl(int $eventId): string
{
if ($eventId <= 0) {
return '';
}
$eventModel = $this->framework
->getAdapter(CalendarEventsModel::class)
->findById($eventId);
if (null === $eventModel) {
return '';
}
try {
return (string) $this->urlGenerator->generate($eventModel, [], UrlGeneratorInterface::ABSOLUTE_PATH);
} catch (ExceptionInterface) {
return '';
}
}
private function buildOrganizationDetailUrl(string $baseUrl, int $organizationId): string
{
$normalizedBaseUrl = trim($baseUrl);
if ('' === $normalizedBaseUrl || $organizationId <= 0) {
return '';
}
$separator = str_contains($normalizedBaseUrl, '?') ? '&' : '?';
return sprintf('%s%sshow=%d', $normalizedBaseUrl, $separator, $organizationId);
}
/**
* @param list<string> $candidates
*/
private function resolveExistingTable(array $candidates): ?string
{
foreach ($candidates as $candidate) {
if ($this->tableExists($candidate)) {
return $candidate;
}
}
return null;
}
/**
* @return array{latitude:string,longitude:string}|null
*/
private function resolveGeoColumns(string $table): ?array
{
$columns = $this->getColumnMap($table);
if (isset($columns['latitude'], $columns['longitude'])) {
return [
'latitude' => 'latitude',
'longitude' => 'longitude',
];
}
if (isset($columns['lat'], $columns['lng'])) {
return [
'latitude' => 'lat',
'longitude' => 'lng',
];
}
return null;
}
private function tableExists(string $table): bool
{
try {
return $this->connection->createSchemaManager()->tablesExist([$table]);
} catch (\Throwable) {
return false;
}
}
/**
* @return array<string,true>
*/
private function getColumnMap(string $table): array
{
if (isset($this->tableColumns[$table])) {
return $this->tableColumns[$table];
}
$columns = [];
try {
foreach ($this->connection->createSchemaManager()->listTableColumns($table) as $name => $column) {
$columns[strtolower((string) $name)] = true;
}
} catch (\Throwable) {
$columns = [];
}
$this->tableColumns[$table] = $columns;
return $columns;
}
}
+2 -3
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Service; namespace Mummert\EventManagerBundle\Service;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
@@ -71,10 +71,9 @@ class OrganizationRepository
[ [
'title' => $data['title'] ?? '', 'title' => $data['title'] ?? '',
'street' => $data['street'] ?? '', 'street' => $data['street'] ?? '',
'street2' => $data['street2'] ?? '',
'postal' => $data['postal'] ?? '', 'postal' => $data['postal'] ?? '',
'city' => $data['city'] ?? '', 'city' => $data['city'] ?? '',
'state' => $data['state'] ?? '',
'country' => $data['country'] ?? '',
'phone' => $data['phone'] ?? '', 'phone' => $data['phone'] ?? '',
'email' => $data['email'] ?? '', 'email' => $data['email'] ?? '',
'website' => $data['website'] ?? '', 'website' => $data['website'] ?? '',