Compare commits

...

57 Commits

Author SHA1 Message Date
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
Jürgen Mummert 1b477c24da Wrap event filter tag buttons in dedicated container 2026-02-22 12:11:46 +01:00
Jürgen Mummert 85b669d214 Improve event filter accessibility semantics and live status 2026-02-22 11:32:59 +01:00
Jürgen Mummert e3cc85115b Add configurable target event list ID for event filter module 2026-02-22 11:27:26 +01:00
Jürgen Mummert 3a24b24b84 Fix event filter location and tags column mapping 2026-02-22 11:13:03 +01:00
Jürgen Mummert 68d7f9ef66 Fix calendar query for instances without tl_calendar.published 2026-02-22 11:08:07 +01:00
Jürgen Mummert 9668a455f3 Fix DBAL parameter types in event filter queries 2026-02-22 11:04:09 +01:00
Jürgen Mummert 0685f8eeb1 Add event filter frontend module with server-side aggregates 2026-02-22 11:01:12 +01:00
Jürgen Mummert ba10d1c403 Refactor event full data provider for Twig layout 2026-02-22 10:36:15 +01:00
Jürgen Mummert d1258ff5f1 Add event full template data hook 2026-02-22 09:53:31 +01:00
Jürgen Mummert a59c4d6146 Add location title to event teaser payload 2026-02-22 09:44:16 +01:00
Jürgen Mummert b328b36e14 Refactor teaser listener to batch prefetch 2026-02-22 09:29:27 +01:00
Jürgen Mummert 717d243cec Ensure teaser data hook runs after tags listener 2026-02-22 09:11:05 +01:00
Jürgen Mummert 181445e1e3 Add event teaser data attribute listener 2026-02-22 08:56:17 +01:00
Jürgen Mummert 43579dd44b Set lat/lng SQL precision to DECIMAL(11,8) 2026-02-22 07:04:43 +01:00
Jürgen Mummert f93ed0d0c6 Refactor type handling to contao-tags and add module tag filters 2026-02-21 22:23:53 +01:00
Jürgen Mummert a6440c74a2 Update composer metadata 2026-02-17 19:08:34 +01:00
33 changed files with 4412 additions and 284 deletions
-216
View File
@@ -1,216 +0,0 @@
Create a Contao 5.7 bundle (PHP 8.4)
Vendor: MummertMedia
Bundle: EventManagerBundle
Composer name: mummert-media/eventmanager-bundle
Type: contao-bundle
This bundle is internal only.
IMPORTANT:
Only implement backend functionality.
No frontend logic.
No controllers.
No services.
No access logic.
No event listeners.
No future planning.
Only backend entities and relations.
====================================================
GOAL
====================================================
Extend Contao backend with:
1) New entity: tl_organization named Organisationen, just german translation
2) New entity: tl_location named Veranstaltungsorte, just german translation
3) Join table: tl_member_organization
4) Join table: tl_calendar_events_organization
5) Extend tl_calendar_events with:
- location_id (exactly one location) with dropdown in Backend (title ASC)
- organization assignment (multiple via join table)
6) Extend tl_member so that:
- A member can belong to multiple organizations
7) Extend tl_organization so that:
- Multiple members can be assigned to it
Everything manageable via Contao backend only.
====================================================
DATABASE TABLES
====================================================
Use Contao DCA SQL definitions (DBAL 4 compatible).
Do NOT use serialized fields.
Do NOT use string length values like "255".
Use integer values.
------------------------------------
tl_organization + add german translation
------------------------------------
id int unsigned auto_increment
pid int unsigned default 0
tstamp int unsigned default 0
dateAdded int unsigned default 0
title varchar(255)
alias varchar(128)
logo binary(16) nullable
street varchar(255)
street2 varchar(255)
postal varchar(255)
city varchar(255)
state varchar(255)
country varchar(2)
phone varchar(64)
email varchar(255)
website varchar(255)
lat decimal(10,8)
lng decimal(11,8)
description text nullable
published char(1)
isExternal char(1)
type varchar(32)
------------------------------------
tl_location + add german translation
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
dateAdded int unsigned default 0
title varchar(255)
alias varchar(128)
description text nullable
street varchar(255)
street2 varchar(255)
postal varchar(255)
city varchar(255)
state varchar(255)
country varchar(2) default 'de'
lat decimal(10,8)
lng decimal(11,8)
image binary(16) nullable
published char(1)
------------------------------------
tl_member_organization
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
member_id int unsigned
organization_id int unsigned
------------------------------------
tl_calendar_events_organization
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
event_id int unsigned
organization_id int unsigned
====================================================
DCA REQUIREMENTS
====================================================
1) tl_organization:
- Backend list view
- Editable fields
- Published toggle
- Alias field
- Type as select field with static options:
accommodation, shopping, culture
- Add multi-relation field for members
(store relation in tl_member_organization)
2) tl_location:
- Backend list view
- Editable fields
- Published toggle
- Alias field
3) tl_calendar_events:
- Add field location_id (select, mandatory)
foreignKey: tl_location.title
- Add multi-organization relation
(store relation in tl_calendar_events_organization)
4) tl_member:
- Add multi-organization relation
(store relation in tl_member_organization)
====================================================
RELATION HANDLING
====================================================
Use proper many-to-many handling via join tables.
Use relation definitions in DCA.
Do NOT use serialized arrays.
Do NOT store IDs as comma-separated strings.
====================================================
BACKEND INTEGRATION
====================================================
Register backend modules for:
- Organisationen
- Veranstaltungsorte
Under backend group content
Use standard Contao backend module registration.
====================================================
PROJECT STRUCTURE
====================================================
Generate:
src/
MummertMediaEventManagerBundle.php
DependencyInjection/
MummertMediaEventManagerExtension.php
Contao/
Manager/
Plugin.php
contao/
dca/
tl_organization.php
tl_location.php
tl_member.php
tl_calendar_events.php
config/
config.php
config/
services.yaml
composer.json
====================================================
QUALITY RULES
====================================================
- declare(strict_types=1);
- PHP 8.4
- DBAL 4 compatible SQL
- no deprecated Contao 4 syntax
- no frontend logic
- no controllers
- no services
- no placeholders
- complete working DCA
- ready to install via path repository
====================================================
OUTPUT
====================================================
Generate full file contents for all required files.
Code must be production-ready.
+10 -3
View File
@@ -9,9 +9,10 @@
"eventmanager"
],
"require": {
"php": "^8.4",
"php": "^8.3",
"contao/core-bundle": "^5.7",
"contao/manager-plugin": "^2.0"
"contao/manager-plugin": "^2.0",
"numero2/contao-tags": "^0.5"
},
"autoload": {
"psr-4": {
@@ -21,5 +22,11 @@
"extra": {
"contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin"
},
"prefer-stable": true
"prefer-stable": true,
"config": {
"allow-plugins": {
"contao-components/installer": true,
"contao/manager-plugin": true
}
}
}
+2
View File
@@ -14,3 +14,5 @@ services:
tags:
- { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit }
- { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit }
MummertMedia\EventManagerBundle\Service\MapModuleDataProvider: ~
+10 -6
View File
@@ -22,7 +22,7 @@ $GLOBALS['TL_DCA']['tl_calendar_events']['config']['onload_callback'][] = static
PaletteManipulator::create()
->addLegend('organization_legend', 'details_legend', PaletteManipulator::POSITION_AFTER)
->addField(['location_id', 'type', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND)
->addField(['location_id', 'tags', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_calendar_events');
PaletteManipulator::create()
@@ -71,14 +71,18 @@ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['location_id'] = [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['type'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type'],
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['tags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['tags'],
'exclude' => true,
'filter' => true,
'inputType' => 'select',
'options' => ['accommodation', 'shopping', 'culture'],
'reference' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type_options'],
'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
'foreignKey' => 'tl_tags.tag',
'options_callback' => ['numero2_tags.listener.data_container.tags', 'getTagOptions'],
'load_callback' => [['numero2_tags.listener.data_container.tags', 'loadTags']],
'save_callback' => [['numero2_tags.listener.data_container.tags', 'saveTags']],
'eval' => ['multiple' => true, 'size' => 8, 'tl_class' => 'w50 tags', 'chosen' => true, 'groupTagsByField' => true, 'tagGroup' => 'event_type'],
'sql' => ['type' => 'blob', 'notnull' => false],
'relation' => ['type' => 'hasMany', 'load' => 'eager'],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['organizations'] = [
+2 -2
View File
@@ -148,8 +148,8 @@ $GLOBALS['TL_DCA']['tl_location'] = [
'label' => &$GLOBALS['TL_LANG']['tl_location']['lat'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'],
'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
],
'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['lng'],
+274 -2
View File
@@ -2,10 +2,16 @@
declare(strict_types=1);
use Contao\Database;
use Contao\Controller;
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_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{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;{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;{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']['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']['fields']['editPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'],
@@ -62,3 +68,269 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['frontendArchiveId'] = [
'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['organizationTypeTags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'],
'exclude' => true,
'inputType' => 'checkbox',
'options_callback' => static function () {
$database = Database::getInstance();
$labelExpression = $database->fieldExists('title', 'tl_tags')
? "COALESCE(NULLIF(t.title, ''), t.tag)"
: 't.tag';
$rows = Database::getInstance()
->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=?
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;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'sql' => ['type' => 'blob', 'notnull' => false],
'save_callback' => [
static function ($value): array {
return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
},
],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['eventTypeTags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'],
'exclude' => true,
'inputType' => 'checkbox',
'options_callback' => static function () {
$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_calendar_events', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['tag'];
}
return $options;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'sql' => ['type' => 'blob', 'notnull' => false],
'save_callback' => [
static function ($value): array {
return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
},
],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['eventListDomId'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['eventListDomId'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => static function (): array {
$rows = Database::getInstance()
->prepare('SELECT id, name, cssID FROM tl_module WHERE type=? ORDER BY name ASC, id ASC')
->execute('eventlist')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$moduleId = (int) ($row['id'] ?? 0);
if ($moduleId <= 0) {
continue;
}
$cssId = StringUtil::deserialize($row['cssID'] ?? null, true);
$domId = trim((string) ($cssId[0] ?? ''));
$domId = '' !== $domId ? $domId : sprintf('mod_eventlist_%d', $moduleId);
$moduleName = trim((string) ($row['name'] ?? ''));
if ('' === $moduleName) {
$moduleName = sprintf('Eventliste %d', $moduleId);
}
$options[$domId] = sprintf('%s [%s]', $moduleName, $domId);
}
return $options;
},
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'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' => 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=?
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;
},
'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;
};
}
+11 -8
View File
@@ -70,7 +70,7 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
],
'palettes' => [
'__selector__' => [],
'default' => '{title_legend},title,alias,type,isExternal,logo;{address_legend},street,street2,postal,city,state,country;{contact_legend},phone,email,website;{geo_legend},lat,lng;{description_legend},description;{relation_legend},members;{publish_legend},published',
'default' => '{title_legend},title,alias,tags,isExternal,logo;{address_legend},street,street2,postal,city,state,country;{contact_legend},phone,email,website;{geo_legend},lat,lng;{description_legend},description;{relation_legend},members;{publish_legend},published',
],
'fields' => [
'id' => [
@@ -178,8 +178,8 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['lat'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'],
'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
],
'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['lng'],
@@ -211,14 +211,17 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
'eval' => ['tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
],
'type' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['type'],
'tags' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['tags'],
'exclude' => true,
'inputType' => 'select',
'options' => ['accommodation', 'shopping', 'culture'],
'reference' => &$GLOBALS['TL_LANG']['tl_organization']['type_options'],
'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
'foreignKey' => 'tl_tags.tag',
'options_callback' => ['numero2_tags.listener.data_container.tags', 'getTagOptions'],
'load_callback' => [['numero2_tags.listener.data_container.tags', 'loadTags']],
'save_callback' => [['numero2_tags.listener.data_container.tags', 'saveTags']],
'eval' => ['multiple' => true, 'size' => 8, 'tl_class' => 'w50 tags', 'chosen' => true, 'groupTagsByField' => true, 'tagGroup' => 'organization_type'],
'sql' => ['type' => 'blob', 'notnull' => false],
'relation' => ['type' => 'hasMany', 'load' => 'eager'],
],
'members' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['members'],
+2
View File
@@ -9,4 +9,6 @@ $GLOBALS['TL_LANG']['FMD']['eventmanager'] = 'Event-Manager';
$GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['Meine Organisationen', 'Listet Organisationen des eingeloggten Mitglieds auf.'];
$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']['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.'];
+1 -5
View File
@@ -4,13 +4,9 @@ declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organisationen und Ort';
$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Veranstaltungsort', 'Zugeordneter Veranstaltungsort'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type'] = ['Typ', 'Mehrere Typen auswählbar.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['tags'] = ['Typen', 'Vorhandene Tags für Veranstaltungstypen auswählen.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organisationen', 'Zugeordnete Organisationen'];
$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Urheber/Fotograf', 'Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Ausverkauft', 'Diese Veranstaltung ist ausverkauft.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Abgesagt', 'Diese Veranstaltung wurde abgesagt.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Nutzungsbedingungen akzeptiert', 'Die Nutzungsbedingungen wurden akzeptiert.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['accommodation'] = 'Unterkunft';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['culture'] = 'Kultur';
+25
View File
@@ -10,3 +10,28 @@ $GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event-Ordner', 'Bitte wähle
$GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Seite mit Nutzungsbedingungen', 'Optional: Seite mit den Nutzungsbedingungen, die im Frontend beim Zustimmungs-Label verlinkt wird.'];
$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend Benutzer ID', 'ID des Backend-Benutzers, der als Autor für frontendseitig angelegte Events gesetzt wird.'];
$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['ID des Newsarchivs', 'Archiv-ID (pid), in das frontendseitig angelegte Events gespeichert werden.'];
$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']['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 -5
View File
@@ -19,13 +19,9 @@ $GLOBALS['TL_LANG']['tl_organization']['lng'] = ['Längengrad', 'Längengrad'];
$GLOBALS['TL_LANG']['tl_organization']['description'] = ['Beschreibung', 'Beschreibung'];
$GLOBALS['TL_LANG']['tl_organization']['published'] = ['Veröffentlicht', 'Organisation veröffentlichen'];
$GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['Extern', 'Externe Organisation'];
$GLOBALS['TL_LANG']['tl_organization']['type'] = ['Typ', 'Mehrere Typen auswählbar.'];
$GLOBALS['TL_LANG']['tl_organization']['tags'] = ['Typen', 'Vorhandene Tags für Organisationstypen auswählen.'];
$GLOBALS['TL_LANG']['tl_organization']['members'] = ['Mitglieder', 'Zugeordnete Mitglieder'];
$GLOBALS['TL_LANG']['tl_organization']['type_options']['accommodation'] = 'Unterkunft';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['culture'] = 'Kultur';
$GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Titel';
$GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Adresse';
$GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Kontakt';
+2
View File
@@ -9,4 +9,6 @@ $GLOBALS['TL_LANG']['FMD']['eventmanager'] = 'Event manager';
$GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['My organizations', 'Lists organizations of the logged-in member.'];
$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']['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.'];
+1 -5
View File
@@ -4,13 +4,9 @@ declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organizations and location';
$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Location', 'Assigned location'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type'] = ['Type', 'Multiple types can be selected.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['tags'] = ['Types', 'Select existing tags for event types.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organizations', 'Assigned organizations'];
$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Author/Photographer', 'Please provide the image author/photographer and make sure you have permission to use the image.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Sold out', 'This event is sold out.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Canceled', 'This event has been canceled.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Terms accepted', 'The terms of use have been accepted.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['accommodation'] = 'Accommodation';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['culture'] = 'Culture';
+25
View File
@@ -10,3 +10,28 @@ $GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event folder', 'Please selec
$GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Terms page', 'Optional: page containing the terms of use linked from the frontend consent label.'];
$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend user ID', 'Backend user ID that should be set as author for events created from the frontend.'];
$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['News archive ID', 'Archive ID (pid) where events created from the frontend should be stored.'];
$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']['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.'];
+1 -5
View File
@@ -19,13 +19,9 @@ $GLOBALS['TL_LANG']['tl_organization']['lng'] = ['Longitude', 'Longitude'];
$GLOBALS['TL_LANG']['tl_organization']['description'] = ['Description', 'Description'];
$GLOBALS['TL_LANG']['tl_organization']['published'] = ['Published', 'Publish organization'];
$GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['External', 'External organization'];
$GLOBALS['TL_LANG']['tl_organization']['type'] = ['Type', 'Multiple types can be selected.'];
$GLOBALS['TL_LANG']['tl_organization']['tags'] = ['Types', 'Select existing tags for organization types.'];
$GLOBALS['TL_LANG']['tl_organization']['members'] = ['Members', 'Assigned members'];
$GLOBALS['TL_LANG']['tl_organization']['type_options']['accommodation'] = 'Accommodation';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['culture'] = 'Culture';
$GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Title';
$GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Address';
$GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Contact';
@@ -32,7 +32,7 @@
{{ form_row(form.organization_ids) }}
{% endif %}
{{ form_row(form.location_id) }}
{{ form_row(form.type) }}
{{ form_row(form.tags) }}
{{ form_row(form.teaser) }}
{{ form_row(form.description) }}
{{ form_row(form.url) }}
@@ -59,7 +59,7 @@
(function () {
const locationSelect = document.querySelector('select.js-location-choice');
const organizationSelect = document.querySelector('select.js-organization-choice');
const typeSelect = document.querySelector('select.js-event-type-choice');
const typeSelect = document.querySelector('select.js-event-tags-choice');
if (organizationSelect && typeof window.Choices === 'function') {
new window.Choices(organizationSelect, {
@@ -0,0 +1,425 @@
<div id="eventfilters" data-eventlist-id="{{ targetEventListId|default('eventlist')|e('html_attr') }}">
<div class="select-filters">
<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>
</div>
<div class="widget-select places">
<label for="location-filter" class="visually-hidden">Ort wählen</label>
<select id="location-filter" placeholder="Ort wählen" autocomplete="off">
<option value="">Ort wählen</option>
{% for location in locations|default([]) %}
<option value="location-{{ location.id }}">{{ location.title }} ({{ location.count }})</option>
{% endfor %}
</select>
</div>
<div class="widget-select org">
<label for="org-filter" class="visually-hidden">Veranstalter wählen</label>
<select id="org-filter" placeholder="Veranstalter wählen" autocomplete="off">
<option value="">Veranstalter wählen</option>
{% for organization in organizations|default([]) %}
<option value="org-{{ organization.id }}">{{ organization.title }} ({{ organization.count }})</option>
{% endfor %}
</select>
</div>
</div>
<button type="button" id="eventfilter-reset" class="eventfilter-reset" aria-label="Alle Filter zurücksetzen" aria-controls="{{ targetEventListId|default('eventlist')|e('html_attr') }}" hidden>Filter zurücksetzen</button>
<p id="eventfilter-status" class="visually-hidden" aria-live="polite"></p>
</div>
<style>
.visually-hidden {
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;
transform: translateY(0);
transition: opacity 220ms ease, transform 220ms ease;
will-change: opacity, transform;
}
.event-filter-target-list .event.is-filtered-out {
opacity: 0;
transform: translateY(6px);
pointer-events: none;
}
#eventfilters .widget-select.active select {
outline: 3px solid currentColor;
outline-offset: 3px;
}
#eventfilters select:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
#eventfilters .eventfilter-reset[hidden] {
display: none;
}
</style>
<script type="module">
const filters = document.getElementById('eventfilters');
if (!filters) {
throw new Error('Event filter requires #eventfilters.');
}
const targetEventListId = filters.dataset.eventlistId || 'eventlist';
const list = document.getElementById(targetEventListId);
if (!list) {
console.warn(`[event_filter] Target event list #${targetEventListId} was not found.`);
}
list?.classList.add('event-filter-target-list');
const events = list ? Array.from(list.querySelectorAll(':scope > .event')) : [];
const tagSelect = filters.querySelector('#tag-filter');
const locationSelect = filters.querySelector('#location-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 resetButton = filters.querySelector('#eventfilter-reset');
const status = filters.querySelector('#eventfilter-status');
const stateStorageKey = 'event-filter-state';
const stateQueryKey = 'event_filter';
const animationMs = 220;
let hideTimers = new WeakMap();
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 timer = hideTimers.get(eventItem);
if (timer) {
window.clearTimeout(timer);
hideTimers.delete(eventItem);
}
};
const showEvent = (eventItem) => {
clearHideTimer(eventItem);
eventItem.hidden = false;
requestAnimationFrame(() => {
eventItem.classList.remove('is-filtered-out');
});
};
const hideEvent = (eventItem) => {
clearHideTimer(eventItem);
eventItem.classList.add('is-filtered-out');
const timer = window.setTimeout(() => {
eventItem.hidden = true;
hideTimers.delete(eventItem);
}, animationMs);
hideTimers.set(eventItem, timer);
};
const setActiveControl = ({ type, value }) => {
const hasActiveTag = type === 'tag' && Boolean(value);
const hasActiveLocation = type === 'location' && Boolean(value);
const hasActiveOrg = type === 'org' && Boolean(value);
tagWidget?.classList.toggle('active', hasActiveTag);
locationWidget?.classList.toggle('active', hasActiveLocation);
orgWidget?.classList.toggle('active', hasActiveOrg);
if (resetButton) {
resetButton.hidden = !(hasActiveTag || hasActiveLocation || hasActiveOrg);
}
};
const parseIdList = (rawValue) => (rawValue ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const matches = (eventItem, filterState) => {
if (!filterState.value || filterState.type === 'all') {
return true;
}
if (filterState.type === 'tag') {
return parseIdList(eventItem.dataset.tags).includes(filterState.value);
}
if (filterState.type === 'location') {
return eventItem.dataset.location === filterState.value;
}
if (filterState.type === 'org') {
return parseIdList(eventItem.dataset.org).includes(filterState.value);
}
return true;
};
const updateStatus = (filterState) => {
if (!status) {
return;
}
const visibleCount = events.filter((eventItem) => !eventItem.hidden).length;
let filterText = 'alle';
if (filterState.type === 'tag' && tagSelect) {
const option = tagSelect.options[tagSelect.selectedIndex];
filterText = option ? option.textContent.trim() : 'Kategorie wählen';
}
if (filterState.type === 'location' && locationSelect) {
const option = locationSelect.options[locationSelect.selectedIndex];
filterText = option ? option.textContent.trim() : 'Ort';
}
if (filterState.type === 'org' && orgSelect) {
const option = orgSelect.options[orgSelect.selectedIndex];
filterText = option ? option.textContent.trim() : 'Veranstalter';
}
status.textContent = `${visibleCount} Veranstaltungen angezeigt, Filter: ${filterText}.`;
};
const applyFilter = (filterState) => {
currentFilter = filterState;
setActiveControl(filterState);
events.forEach((eventItem) => {
if (matches(eventItem, filterState)) {
showEvent(eventItem);
} else {
hideEvent(eventItem);
}
});
updateStatus(filterState);
syncState(filterState);
};
tagSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = tagSelect.value;
setSelectValue(locationSelect, '');
setSelectValue(orgSelect, '');
applyFilter(
!selectedValue
? { type: 'all', value: '' }
: { type: 'tag', value: selectedValue.replace('tag-', '') },
);
});
locationSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = locationSelect.value;
setSelectValue(tagSelect, '');
setSelectValue(orgSelect, '');
applyFilter(
!selectedValue
? { type: 'all', value: '' }
: { type: 'location', value: selectedValue.replace('location-', '') },
);
});
orgSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = orgSelect.value;
setSelectValue(tagSelect, '');
setSelectValue(locationSelect, '');
applyFilter(
!selectedValue
? { type: 'all', value: '' }
: { type: 'org', value: selectedValue.replace('org-', '') },
);
});
resetButton?.addEventListener('click', () => {
setSelectValue(tagSelect, '');
setSelectValue(locationSelect, '');
setSelectValue(orgSelect, '');
applyFilter({ type: 'all', value: '' });
});
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>
@@ -0,0 +1,115 @@
{% 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>
<style>
.eventmanager-map-filter {
display: grid;
gap: .5rem;
margin-bottom: .75rem;
}
.eventmanager-map-filter__group {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.eventmanager-map-filter__tag[aria-pressed="true"] {
font-weight: 600;
}
.eventmanager-map {
width: 100%;
min-height: 420px;
}
.maplibregl-popup {
z-index: 2147483647;
}
</style>
<script type="application/json" id="{{ mapDataElementId|e('html_attr') }}">{{ mapItemsJson|raw }}</script>
<script type="module" src="/bundles/mummertmediaeventmanager/assets/map-module.js?v=20260227a"></script>
@@ -25,7 +25,7 @@
<script>
(function () {
const typeSelect = document.querySelector('select.js-organization-type-choice');
const typeSelect = document.querySelector('select.js-organization-tags-choice');
if (typeSelect && typeof window.Choices === 'function') {
new window.Choices(typeSelect, {
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -73,7 +73,7 @@ class EventEditController extends AbstractFrontendModuleController
'startTime' => null,
'endTime' => null,
'location_id' => 0,
'type' => null,
'tags' => null,
'teaser' => '',
'description' => '',
'url' => '',
@@ -88,9 +88,11 @@ class EventEditController extends AbstractFrontendModuleController
}
$memberOrganizationIds = $this->eventRepository->getOrganizationIdsForMember((int) $user->id);
$allowedEventTagIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($model->eventTypeTags ?? null, true))));
$organizationChoices = $this->eventRepository->getOrganizationChoicesForMember((int) $user->id);
$showOrganization = count($memberOrganizationIds) > 1;
$currentOrganizationIds = $this->eventRepository->getOrganizationIdsForEvent($eventId);
$currentTagIds = $this->eventRepository->getTagIdsForEvent($eventId);
$formData = [
'title' => (string) ($event['title'] ?? ''),
@@ -100,7 +102,7 @@ class EventEditController extends AbstractFrontendModuleController
'startTime' => ('1' === (string) ($event['addTime'] ?? '') && !empty($event['startTime'])) ? date('H:i', (int) $event['startTime']) : '',
'endTime' => $this->resolveFormEndTime($event),
'location_id' => (int) ($event['location_id'] ?? 0),
'type' => StringUtil::deserialize($event['type'] ?? null, true),
'tags' => $currentTagIds,
'teaser' => (string) ($event['teaser'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'url' => (string) ($event['url'] ?? ''),
@@ -135,6 +137,7 @@ class EventEditController extends AbstractFrontendModuleController
$form = $this->createForm(EventType::class, $formData, [
'location_choices' => $this->eventRepository->getLocationChoices(),
'tag_choices' => $this->eventRepository->getTagChoicesForEventType($allowedEventTagIds),
'organization_choices' => $organizationChoices,
'selected_organization_ids' => $showOrganization ? ($formData['organization_ids'] ?? []) : [],
'show_organization' => $showOrganization,
@@ -213,6 +216,14 @@ class EventEditController extends AbstractFrontendModuleController
$this->eventRepository->update($eventId, $submittedData);
}
$selectedTagIds = array_values(array_unique(array_map('intval', (array) ($submittedData['tags'] ?? []))));
if ([] !== $allowedEventTagIds) {
$selectedTagIds = array_values(array_intersect($selectedTagIds, $allowedEventTagIds));
}
$this->eventRepository->assignTagsToEvent($eventId, $selectedTagIds);
$useImage = !empty($submittedData['addImage']);
$removeImage = '1' === (string) $request->request->get('remove_image', '0');
$uploadedImage = $form->get('eventUpload')->getData();
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace MummertMedia\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\StringUtil;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'event_filter', category: 'eventmanager', template: 'frontend/event_filter')]
class EventFilterController extends AbstractFrontendModuleController
{
public function __construct(
private readonly Connection $connection,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$calendarIds = array_map('intval', StringUtil::deserialize($model->cal_calendar, true));
$eventIds = $this->findUpcomingEventIds($calendarIds);
$targetEventListId = trim((string) ($model->eventListDomId ?? ''));
$template->set('tagButtons', $this->findTagButtons($eventIds));
$template->set('locations', $this->findLocations($eventIds));
$template->set('organizations', $this->findOrganizations($eventIds));
$template->set('targetEventListId', '' !== $targetEventListId ? $targetEventListId : 'eventlist');
return $template->getResponse();
}
/**
* @param list<int> $calendarIds
*
* @return list<int>
*/
private function findUpcomingEventIds(array $calendarIds): array
{
$today = strtotime('today');
if ([] === $calendarIds) {
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT e.id
FROM tl_calendar_events e
WHERE e.published='1'
AND (e.startTime>=? OR e.endTime>=?)
ORDER BY e.startTime ASC
SQL,
[$today, $today],
[ParameterType::INTEGER, ParameterType::INTEGER],
)->fetchFirstColumn();
} else {
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT e.id
FROM tl_calendar_events e
WHERE e.pid IN (?)
AND e.published='1'
AND (e.startTime>=? OR e.endTime>=?)
ORDER BY e.startTime ASC
SQL,
[$calendarIds, $today, $today],
[ArrayParameterType::INTEGER, ParameterType::INTEGER, ParameterType::INTEGER],
)->fetchFirstColumn();
}
return array_values(array_unique(array_map('intval', $rows)));
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findTagButtons(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT t.id, t.tag, COUNT(DISTINCT r.pid) AS total
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id=r.tag_id
WHERE r.ptable='tl_calendar_events'
AND r.field='tags'
AND r.pid IN (?)
GROUP BY t.id, t.tag
ORDER BY t.tag ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['tag'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findLocations(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT l.id, l.title, COUNT(e.id) AS total
FROM tl_calendar_events e
INNER JOIN tl_location l ON l.id=e.location_id
WHERE e.id IN (?)
AND e.location_id>0
GROUP BY l.id, l.title
ORDER BY l.title ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findOrganizations(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT o.id, o.title, COUNT(DISTINCT rel.event_id) AS total
FROM tl_calendar_events_organization rel
INNER JOIN tl_organization o ON o.id=rel.organization_id
WHERE rel.event_id IN (?)
GROUP BY o.id, o.title
ORDER BY o.title ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace MummertMedia\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 MummertMedia\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, '/');
}
}
@@ -58,6 +58,7 @@ class OrganizationEditController extends AbstractFrontendModuleController
}
$organization = $this->organizationRepository->findById($organizationId);
$allowedOrganizationTagIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($model->organizationTypeTags ?? null, true))));
if (null === $organization) {
$template->set('error', 'Organisation nicht gefunden.');
@@ -77,7 +78,7 @@ class OrganizationEditController extends AbstractFrontendModuleController
'email' => (string) ($organization['email'] ?? ''),
'website' => (string) ($organization['website'] ?? ''),
'description' => (string) ($organization['description'] ?? ''),
'type' => StringUtil::deserialize($organization['type'] ?? null, true),
'tags' => $this->organizationRepository->getTagIdsForOrganization($organizationId),
];
$currentLogoPath = null;
@@ -90,13 +91,22 @@ class OrganizationEditController extends AbstractFrontendModuleController
}
}
$form = $this->createForm(OrganizationType::class, $formData);
$form = $this->createForm(OrganizationType::class, $formData, [
'tag_choices' => $this->organizationRepository->getTagChoicesForOrganizationType($allowedOrganizationTagIds),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$submittedData = (array) $form->getData();
$this->organizationRepository->update($organizationId, $submittedData);
$selectedTagIds = array_values(array_unique(array_map('intval', (array) ($submittedData['tags'] ?? []))));
if ([] !== $allowedOrganizationTagIds) {
$selectedTagIds = array_values(array_intersect($selectedTagIds, $allowedOrganizationTagIds));
}
$this->organizationRepository->assignTagsToOrganization($organizationId, $selectedTagIds);
$deleteLogo = '1' === (string) $request->request->get('remove_logo', '0');
$uploadedLogo = $form->get('logoUpload')->getData();
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\StringUtil;
use Contao\Template;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
#[AsHook('parseTemplate', method: 'onParseTemplate')]
class EventFullTemplateDataListener
{
public function __construct(
private readonly Connection $connection,
) {
}
public function onParseTemplate(Template $template): void
{
$templateName = (string) $template->getName();
if (!str_starts_with($templateName, 'event_full')) {
return;
}
$data = $template->getData();
$eventId = (int) ($data['id'] ?? 0);
if ($eventId <= 0) {
return;
}
$locationId = isset($data['location_id']) ? (int) $data['location_id'] : 0;
$locationRow = $this->fetchLocationRow($locationId);
$organizations = $this->fetchOrganizations($eventId);
$tags = $this->fetchTags($eventId);
$template->location_row = $locationRow;
$template->organizations = $organizations;
$template->tags = $tags;
$template->has_tag_21 = \in_array(21, array_map(static fn (array $tag): int => (int) ($tag['id'] ?? 0), $tags), true);
if ($locationRow && '' !== ($locationRow['lng'] ?? '') && '' !== ($locationRow['lat'] ?? '') && 58 !== (int) ($locationRow['id'] ?? 0)) {
$this->registerMapAssets();
$template->show_location_map = true;
} else {
$template->show_location_map = false;
}
$template->location_fields = $this->toFlatLocationFields($locationRow);
$template->organization_fields = $this->toFlatOrganizationFields($organizations);
$template->tag_fields = $this->toFlatTagFields($tags);
}
/** @return array<string, string> */
private function fetchLocationRow(int $locationId): array
{
if ($locationId <= 0) {
return [];
}
$row = $this->connection->createQueryBuilder()
->select('*')
->from('tl_location')
->where('id = :id')
->setParameter('id', $locationId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (false === $row || null === $row) {
return [];
}
$normalized = [];
foreach ($row as $column => $value) {
$normalized[$column] = $this->toString($value);
}
if ('' !== ($normalized['image'] ?? '')) {
$normalized['image_uuid'] = $this->normalizeUuid($normalized['image']);
}
return $normalized;
}
/** @return list<array<string, string>> */
private function fetchOrganizations(int $eventId): array
{
$rows = $this->connection->createQueryBuilder()
->select('o.*')
->from('tl_calendar_events_organization', 'ceo')
->innerJoin('ceo', 'tl_organization', 'o', 'o.id = ceo.organization_id')
->where('ceo.event_id = :eventId')
->setParameter('eventId', $eventId, ParameterType::INTEGER)
->orderBy('ceo.organization_id', 'ASC')
->executeQuery()
->fetchAllAssociative();
$organizations = [];
foreach ($rows as $row) {
$normalized = [];
foreach ($row as $column => $value) {
$normalized[$column] = $this->toString($value);
}
if ('' !== ($normalized['logo'] ?? '')) {
$normalized['logo_uuid'] = $this->normalizeUuid($normalized['logo']);
}
$organizations[] = $normalized;
}
return $organizations;
}
/** @return list<array<string, string>> */
private function fetchTags(int $eventId): array
{
$rows = $this->connection->createQueryBuilder()
->select('t.*')
->from('tl_tags_rel', 'r')
->innerJoin('r', 'tl_tags', 't', 't.id = r.tag_id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->andWhere('r.pid = :pid')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->setParameter('pid', $eventId, ParameterType::INTEGER)
->orderBy('r.tag_id', 'ASC')
->executeQuery()
->fetchAllAssociative();
$tags = [];
foreach ($rows as $row) {
$title = '';
if (isset($row['title']) && '' !== (string) $row['title']) {
$title = (string) $row['title'];
} elseif (isset($row['tag'])) {
$title = (string) $row['tag'];
}
$tags[] = [
'id' => (string) ((int) ($row['id'] ?? 0)),
'title' => $title,
'tag' => (string) ($row['tag'] ?? ''),
];
}
return $tags;
}
/** @param array<string, string> $locationRow
* @return array<string, string>
*/
private function toFlatLocationFields(array $locationRow): array
{
$fields = [];
foreach ($locationRow as $column => $value) {
$fields['location_'.$column] = $value;
}
return $fields;
}
/** @param list<array<string, string>> $organizations
* @return array<string, string>
*/
private function toFlatOrganizationFields(array $organizations): array
{
$fields = [];
$index = 1;
foreach ($organizations as $organization) {
foreach ($organization as $column => $value) {
$fields['organization'.$index.'_'.$column] = $value;
}
++$index;
}
return $fields;
}
/** @param list<array<string, string>> $tags
* @return array<string, string>
*/
private function toFlatTagFields(array $tags): array
{
$fields = [];
$index = 1;
foreach ($tags as $tag) {
$fields['tag'.$index.'_title'] = (string) ($tag['title'] ?? '');
++$index;
}
return $fields;
}
private function normalizeUuid(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^[0-9a-fA-F-]{36}$/', $trimmed)) {
return strtolower($trimmed);
}
if (16 === strlen($trimmed)) {
return StringUtil::binToUuid($trimmed);
}
return $trimmed;
}
private function registerMapAssets(): void
{
$GLOBALS['TL_JAVASCRIPT'] ??= [];
$GLOBALS['TL_CSS'] ??= [];
$jsAssets = [
'https://maps.mummert.media/libraries/maplibre-gl.js',
'https://maps.mummert.media/libraries/pmtiles.js',
];
foreach ($jsAssets as $asset) {
if (!\in_array($asset, $GLOBALS['TL_JAVASCRIPT'], true)) {
$GLOBALS['TL_JAVASCRIPT'][] = $asset;
}
}
$cssAsset = 'https://maps.mummert.media/libraries/maplibre-gl.css';
if (!\in_array($cssAsset, $GLOBALS['TL_CSS'], true)) {
$GLOBALS['TL_CSS'][] = $cssAsset;
}
}
private function toString(mixed $value): string
{
if (null === $value) {
return '';
}
if (\is_bool($value)) {
return $value ? '1' : '0';
}
if (\is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\Module;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\ParameterType;
#[AsHook('getAllEvents', method: 'onGetAllEvents', priority: -512)]
class EventTeaserDataAttributesListener
{
public function __construct(
private readonly Connection $connection,
) {
}
public function onGetAllEvents(array $events, array $calendars, int $start, int $end, Module $module): array
{
$eventIds = $this->collectEventIds($events);
$tagMap = $this->fetchTagMap($eventIds);
$organizationMap = $this->fetchOrganizationMap($eventIds);
$locationMap = $this->fetchLocationMap($eventIds);
$locationTitleMap = $this->fetchLocationTitleMap($locationMap);
foreach ($events as $dayKey => $eventsPerDay) {
foreach ($eventsPerDay as $timeKey => $eventsPerTime) {
foreach ($eventsPerTime as $index => $event) {
if (!isset($event['id'])) {
$events[$dayKey][$timeKey][$index]['location_id'] = null;
$events[$dayKey][$timeKey][$index]['location_title'] = '';
$events[$dayKey][$timeKey][$index]['event_tags'] = '';
$events[$dayKey][$timeKey][$index]['event_org'] = '';
continue;
}
$eventId = (int) $event['id'];
if (isset($event['location_id']) && '' !== (string) $event['location_id']) {
$events[$dayKey][$timeKey][$index]['location_id'] = (int) $event['location_id'];
} else {
$events[$dayKey][$timeKey][$index]['location_id'] = $locationMap[$eventId] ?? null;
}
$locationId = $events[$dayKey][$timeKey][$index]['location_id'];
$events[$dayKey][$timeKey][$index]['location_title'] = null !== $locationId
? ($locationTitleMap[(int) $locationId] ?? '')
: '';
$events[$dayKey][$timeKey][$index]['event_tags'] = implode(',', $tagMap[$eventId] ?? []);
$events[$dayKey][$timeKey][$index]['event_org'] = implode(',', $organizationMap[$eventId] ?? []);
}
}
}
return $events;
}
/** @return list<int> */
private function collectEventIds(array $events): array
{
$eventIds = [];
foreach ($events as $eventsPerDay) {
foreach ($eventsPerDay as $eventsPerTime) {
foreach ($eventsPerTime as $event) {
if (!isset($event['id'])) {
continue;
}
$eventIds[] = (int) $event['id'];
}
}
}
return array_values(array_unique(array_map('intval', $eventIds)));
}
/** @param list<int> $eventIds
* @return array<int, list<int>>
*/
private function fetchTagMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT pid, tag_id FROM tl_tags_rel WHERE ptable = ? AND field = ? AND pid IN (?) ORDER BY pid ASC, tag_id ASC',
['tl_calendar_events', 'tags', $eventIds],
[ParameterType::STRING, ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['pid'] ?? 0);
$tagId = (int) ($row['tag_id'] ?? 0);
if ($eventId <= 0 || $tagId <= 0) {
continue;
}
$map[$eventId][] = $tagId;
}
foreach ($map as $eventId => $tagIds) {
$map[$eventId] = array_values(array_unique(array_map('intval', $tagIds)));
}
return $map;
}
/** @param list<int> $eventIds
* @return array<int, list<int>>
*/
private function fetchOrganizationMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$tables = ['tl_events_organization', 'tl_calendar_events_organization'];
foreach ($tables as $table) {
try {
$rows = $this->connection->executeQuery(
sprintf('SELECT event_id, organization_id FROM %s WHERE event_id IN (?) ORDER BY event_id ASC, organization_id ASC', $table),
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['event_id'] ?? 0);
$organizationId = (int) ($row['organization_id'] ?? 0);
if ($eventId <= 0 || $organizationId <= 0) {
continue;
}
$map[$eventId][] = $organizationId;
}
foreach ($map as $eventId => $organizationIds) {
$map[$eventId] = array_values(array_unique(array_map('intval', $organizationIds)));
}
return $map;
} catch (Exception) {
continue;
}
}
return [];
}
/** @param list<int> $eventIds
* @return array<int, int|null>
*/
private function fetchLocationMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT id, location_id FROM tl_calendar_events WHERE id IN (?)',
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['id'] ?? 0);
if ($eventId <= 0) {
continue;
}
$map[$eventId] = null !== ($row['location_id'] ?? null) ? (int) $row['location_id'] : null;
}
return $map;
}
/** @param array<int, int|null> $locationMap
* @return array<int, string>
*/
private function fetchLocationTitleMap(array $locationMap): array
{
$locationIds = array_values(array_unique(array_filter(array_map('intval', $locationMap), static fn (int $id): bool => $id > 0)));
if ([] === $locationIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT id, title FROM tl_location WHERE id IN (?)',
[$locationIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$locationId = (int) ($row['id'] ?? 0);
if ($locationId <= 0) {
continue;
}
$map[$locationId] = (string) ($row['title'] ?? '');
}
return $map;
}
}
@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace MummertMedia\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 '';
}
}
+8 -10
View File
@@ -39,7 +39,7 @@ class EventType extends AbstractType
])
->add('startDate', DateType::class, [
'label' => 'Startdatum',
'required' => false,
'required' => true,
'widget' => 'single_text',
'input' => 'string',
'html5' => false,
@@ -110,7 +110,7 @@ class EventType extends AbstractType
->add('location_id', ChoiceType::class, [
'label' => 'Veranstaltungsort',
'choices' => $options['location_choices'],
'required' => false,
'required' => true,
'choice_value' => static fn ($value) => null !== $value ? (string) $value : '',
'placeholder' => 'Bitte auswählen',
'attr' => [
@@ -118,17 +118,13 @@ class EventType extends AbstractType
'data-placeholder' => 'Veranstaltungsort suchen …',
],
])
->add('type', ChoiceType::class, [
'label' => 'Typ',
'choices' => [
'Unterkunft' => 'accommodation',
'Einkaufen' => 'shopping',
'Kultur' => 'culture',
],
->add('tags', ChoiceType::class, [
'label' => 'Typen',
'choices' => $options['tag_choices'],
'multiple' => true,
'required' => false,
'attr' => [
'class' => 'js-event-type-choice',
'class' => 'js-event-tags-choice',
'data-placeholder' => 'Typen suchen …',
],
])
@@ -191,6 +187,7 @@ class EventType extends AbstractType
$resolver->setDefaults([
'csrf_protection' => true,
'location_choices' => [],
'tag_choices' => [],
'organization_choices' => [],
'selected_organization_ids' => [],
'show_organization' => false,
@@ -198,6 +195,7 @@ class EventType extends AbstractType
]);
$resolver->setAllowedTypes('location_choices', 'array');
$resolver->setAllowedTypes('tag_choices', 'array');
$resolver->setAllowedTypes('organization_choices', 'array');
$resolver->setAllowedTypes('selected_organization_ids', 'array');
$resolver->setAllowedTypes('show_organization', 'bool');
+14 -8
View File
@@ -11,6 +11,7 @@ use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OrganizationType extends AbstractType
{
@@ -27,17 +28,13 @@ class OrganizationType extends AbstractType
->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false])
->add('website', TextType::class, ['label' => 'Webseite', 'required' => false])
->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('type', ChoiceType::class, [
'label' => 'Typ',
'choices' => [
'Unterkunft' => 'accommodation',
'Einkaufen' => 'shopping',
'Kultur' => 'culture',
],
->add('tags', ChoiceType::class, [
'label' => 'Typen',
'choices' => $options['tag_choices'],
'multiple' => true,
'required' => false,
'attr' => [
'class' => 'js-organization-type-choice',
'class' => 'js-organization-tags-choice',
'data-placeholder' => 'Typ auswählen …',
],
])
@@ -51,4 +48,13 @@ class OrganizationType extends AbstractType
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'tag_choices' => [],
]);
$resolver->setAllowedTypes('tag_choices', 'array');
}
}
+140 -1
View File
@@ -131,6 +131,12 @@ class EventRepository
);
}
$tagIds = $this->getTagIdsForEvent($eventId);
if ([] !== $tagIds) {
$this->assignTagsToEvent($newEventId, $tagIds);
}
return $newEventId;
}
@@ -257,6 +263,12 @@ class EventRepository
public function delete(int $eventId): void
{
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId],
['pid' => ParameterType::INTEGER],
);
$this->connection->delete(
'tl_calendar_events_organization',
['event_id' => $eventId],
@@ -324,6 +336,73 @@ class EventRepository
return array_values(array_unique(array_map('intval', $rows)));
}
/**
* @return array<string, int>
*/
public function getTagChoicesForEventType(array $allowedTagIds = []): array
{
$rows = $this->connection->createQueryBuilder()
->select('DISTINCT t.id', 't.tag')
->from('tl_tags', 't')
->innerJoin('t', 'tl_tags_rel', 'r', 'r.tag_id = t.id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->createQueryBuilder()
->select('t.id', 't.tag')
->from('tl_tags', 't')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
$choices = [];
foreach ($rows as $row) {
$choices[(string) ($row['tag'] ?? '')] = (int) $row['id'];
}
$allowedTagIds = array_values(array_unique(array_map('intval', $allowedTagIds)));
if ([] !== $allowedTagIds) {
$choices = array_filter(
$choices,
static fn (int $id): bool => in_array($id, $allowedTagIds, true),
);
}
return $choices;
}
/** @return array<int> */
public function getTagIdsForEvent(int $eventId): array
{
if ($eventId <= 0) {
return [];
}
$ids = $this->connection->createQueryBuilder()
->select('tag_id')
->from('tl_tags_rel')
->where('ptable = :ptable')
->andWhere('field = :field')
->andWhere('pid = :pid')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->setParameter('pid', $eventId, ParameterType::INTEGER)
->orderBy('tag_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $ids)));
}
/**
* @return array<string, int>
*/
@@ -496,6 +575,41 @@ class EventRepository
return $eventId;
}
/** @param array<int|string> $tagIds */
public function assignTagsToEvent(int $eventId, array $tagIds): void
{
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId],
['pid' => ParameterType::INTEGER],
);
$tagIds = array_values(array_unique(array_map('intval', $tagIds)));
if ([] === $tagIds) {
return;
}
$allowedTagIds = $this->getAllowedTagIdsForEvents();
$tagIds = array_values(array_intersect($tagIds, $allowedTagIds));
foreach ($tagIds as $tagId) {
$this->connection->insert(
'tl_tags_rel',
[
'tag_id' => $tagId,
'pid' => $eventId,
'ptable' => 'tl_calendar_events',
'field' => 'tags',
],
[
'tag_id' => ParameterType::INTEGER,
'pid' => ParameterType::INTEGER,
],
);
}
}
/**
* @param array<string, mixed> $data
*
@@ -533,7 +647,6 @@ class EventRepository
'addTime' => $addTime ? 1 : 0,
'startTime' => $startTimeTimestamp,
'endTime' => $endTimeTimestamp,
'type' => serialize($data['type'] ?? []),
'teaser' => $data['teaser'] ?? null,
'description' => $data['description'] ?? null,
'url' => $url,
@@ -616,4 +729,30 @@ class EventRepository
$types,
);
}
/** @return array<int> */
private function getAllowedTagIdsForEvents(): array
{
$ids = $this->connection->createQueryBuilder()
->select('DISTINCT r.tag_id')
->from('tl_tags_rel', 'r')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->executeQuery()
->fetchFirstColumn();
$ids = array_values(array_unique(array_map('intval', $ids)));
if ([] !== $ids) {
return $ids;
}
return array_values(array_unique(array_map('intval', $this->connection->createQueryBuilder()
->select('id')
->from('tl_tags')
->executeQuery()
->fetchFirstColumn())));
}
}
+585
View File
@@ -0,0 +1,585 @@
<?php
declare(strict_types=1);
namespace MummertMedia\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 fetchLocationRows(string $locationTable, array $locationGeoColumns): array
{
$qb = $this->connection->createQueryBuilder();
$qb
->select('l.id', 'l.title')
->addSelect(sprintf('l.%s AS latitude', $locationGeoColumns['latitude']))
->addSelect(sprintf('l.%s AS longitude', $locationGeoColumns['longitude']))
->from($locationTable, 'l')
->orderBy('l.id', 'ASC');
$this->applyPublicationConstraints($qb, 'l', $locationTable);
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;
}
}
+128 -1
View File
@@ -79,7 +79,6 @@ class OrganizationRepository
'email' => $data['email'] ?? '',
'website' => $data['website'] ?? '',
'description' => $data['description'] ?? null,
'type' => serialize($data['type'] ?? []),
'tstamp' => time(),
],
['id' => $organizationId],
@@ -109,4 +108,132 @@ class OrganizationRepository
$types,
);
}
/**
* @return array<string, int>
*/
public function getTagChoicesForOrganizationType(array $allowedTagIds = []): array
{
$rows = $this->connection->createQueryBuilder()
->select('DISTINCT t.id', 't.tag')
->from('tl_tags', 't')
->innerJoin('t', 'tl_tags_rel', 'r', 'r.tag_id = t.id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->createQueryBuilder()
->select('t.id', 't.tag')
->from('tl_tags', 't')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
$choices = [];
foreach ($rows as $row) {
$choices[(string) ($row['tag'] ?? '')] = (int) $row['id'];
}
$allowedTagIds = array_values(array_unique(array_map('intval', $allowedTagIds)));
if ([] !== $allowedTagIds) {
$choices = array_filter(
$choices,
static fn (int $id): bool => in_array($id, $allowedTagIds, true),
);
}
return $choices;
}
/** @return array<int> */
public function getTagIdsForOrganization(int $organizationId): array
{
if ($organizationId <= 0) {
return [];
}
$ids = $this->connection->createQueryBuilder()
->select('tag_id')
->from('tl_tags_rel')
->where('ptable = :ptable')
->andWhere('field = :field')
->andWhere('pid = :pid')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->setParameter('pid', $organizationId, ParameterType::INTEGER)
->orderBy('tag_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $ids)));
}
/** @param array<int|string> $tagIds */
public function assignTagsToOrganization(int $organizationId, array $tagIds): void
{
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_organization', 'field' => 'tags', 'pid' => $organizationId],
['pid' => ParameterType::INTEGER],
);
$tagIds = array_values(array_unique(array_map('intval', $tagIds)));
if ([] === $tagIds) {
return;
}
$allowedTagIds = $this->getAllowedTagIdsForOrganization();
$tagIds = array_values(array_intersect($tagIds, $allowedTagIds));
foreach ($tagIds as $tagId) {
$this->connection->insert(
'tl_tags_rel',
[
'tag_id' => $tagId,
'pid' => $organizationId,
'ptable' => 'tl_organization',
'field' => 'tags',
],
[
'tag_id' => ParameterType::INTEGER,
'pid' => ParameterType::INTEGER,
],
);
}
}
/** @return array<int> */
private function getAllowedTagIdsForOrganization(): array
{
$ids = $this->connection->createQueryBuilder()
->select('DISTINCT r.tag_id')
->from('tl_tags_rel', 'r')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->executeQuery()
->fetchFirstColumn();
$ids = array_values(array_unique(array_map('intval', $ids)));
if ([] !== $ids) {
return $ids;
}
return array_values(array_unique(array_map('intval', $this->connection->createQueryBuilder()
->select('id')
->from('tl_tags')
->executeQuery()
->fetchFirstColumn())));
}
}