Compare commits

..

22 Commits

Author SHA1 Message Date
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
28 changed files with 2017 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
}
}
}
+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'],
+97 -2
View File
@@ -2,10 +2,14 @@
declare(strict_types=1);
use Contao\Database;
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']['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 +66,94 @@ $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 () {
$rows = Database::getInstance()
->prepare('SELECT DISTINCT t.id, t.tag FROM tl_tags t LEFT JOIN tl_tags_rel r ON r.tag_id=t.id AND r.ptable=? AND r.field=? ORDER BY t.tag ASC')
->execute('tl_organization', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['tag'];
}
return $options;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'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' => ''],
];
+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'],
+1
View File
@@ -9,4 +9,5 @@ $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']['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';
+3
View File
@@ -10,3 +10,6 @@ $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.'];
+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';
+1
View File
@@ -9,4 +9,5 @@ $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']['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';
+3
View File
@@ -10,3 +10,6 @@ $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.'];
+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,336 @@
<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">
<option value="all" data-placeholder="true">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">
<option value="all" data-placeholder="true">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">
<option value="all" data-placeholder="true">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" 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 .ss-main {
outline: 2px solid currentColor;
outline-offset: 2px;
}
#eventfilters .ss-main:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
#eventfilters .eventfilter-reset[hidden] {
display: none;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/slim-select@3/dist/slimselect.css">
<script src="https://cdn.jsdelivr.net/npm/slim-select@3/dist/slimselect.min.js"></script>
<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 animationMs = 220;
let hideTimers = new WeakMap();
let currentFilter = { type: 'all', value: 'all' };
let suppressedChangeEvents = 0;
const initSlimSelect = (selectElement) => {
if (!selectElement || 'undefined' === typeof window.SlimSelect) {
return null;
}
return new window.SlimSelect({
select: selectElement,
settings: {
showSearch: false,
allowDeselect: false,
},
});
};
const tagSlim = initSlimSelect(tagSelect);
const locationSlim = initSlimSelect(locationSelect);
const orgSlim = initSlimSelect(orgSelect);
const setSelectValue = (selectElement, slimInstance, value) => {
if (!selectElement) {
return;
}
if (selectElement.value === value) {
return;
}
suppressedChangeEvents += 1;
try {
if (slimInstance) {
slimInstance.setSelected(value);
} else {
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' && value !== 'all';
const hasActiveLocation = type === 'location' && value !== 'all';
const hasActiveOrg = type === 'org' && value !== 'all';
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 === '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);
}
if (filterState.type === 'all') {
return true;
}
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);
};
tagSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = tagSelect.value;
setSelectValue(locationSelect, locationSlim, 'all');
setSelectValue(orgSelect, orgSlim, 'all');
applyFilter(
selectedValue === 'all'
? { type: 'all', value: 'all' }
: { type: 'tag', value: selectedValue.replace('tag-', '') },
);
});
locationSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = locationSelect.value;
setSelectValue(tagSelect, tagSlim, 'all');
setSelectValue(orgSelect, orgSlim, 'all');
applyFilter(
selectedValue === 'all'
? { type: 'all', value: 'all' }
: { type: 'location', value: selectedValue.replace('location-', '') },
);
});
orgSelect?.addEventListener('change', () => {
if (suppressedChangeEvents > 0) {
return;
}
const selectedValue = orgSelect.value;
setSelectValue(tagSelect, tagSlim, 'all');
setSelectValue(locationSelect, locationSlim, 'all');
applyFilter(
selectedValue === 'all'
? { type: 'all', value: 'all' }
: { type: 'org', value: selectedValue.replace('org-', '') },
);
});
resetButton?.addEventListener('click', () => {
setSelectValue(tagSelect, tagSlim, 'all');
setSelectValue(locationSelect, locationSlim, 'all');
setSelectValue(orgSelect, orgSlim, 'all');
applyFilter({ type: 'all', value: 'all' });
});
applyFilter(currentFilter);
</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, {
@@ -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;
}
}
@@ -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,281 @@
<?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;
#[AsHook('parseTemplate', method: 'onParseTemplate')]
class OrganizationListingTemplateDataListener
{
public function __construct(
private readonly Connection $connection,
) {
}
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;
}
$organizationTagMap = $this->fetchOrganizationTagMap(array_values(array_unique(array_values($rowToOrganizationIdMap))));
$organizationLogoUuidMap = $this->fetchOrganizationLogoUuidMap(array_values(array_unique(array_values($rowToOrganizationIdMap))));
foreach ($rowToOrganizationIdMap as $rowIndex => $organizationId) {
$tagData = $organizationTagMap[$organizationId] ?? ['labels' => [], 'slugs' => []];
$logoUuid = $organizationLogoUuidMap[$organizationId] ?? '';
if (!isset($tbody[$rowIndex]) || !\is_array($tbody[$rowIndex])) {
continue;
}
$tbody[$rowIndex]['tag_labels']['content'] = implode(', ', $tagData['labels']);
$tbody[$rowIndex]['tag_slugs']['content'] = implode(',', $tagData['slugs']);
$tbody[$rowIndex]['tags']['content'] = implode(', ', $tagData['labels']);
if ('' !== $logoUuid) {
$tbody[$rowIndex]['logo_uuid']['content'] = $logoUuid;
}
}
$template->tbody = $tbody;
}
/** @param array<string, mixed> $row */
private function extractOrganizationId(array $row): int
{
foreach (['id', 'organization_id', 'org_id'] as $fieldName) {
$value = $this->extractRowFieldContent($row, $fieldName);
if ('' !== $value && ctype_digit($value)) {
return (int) $value;
}
}
$urlCandidates = [];
foreach ($row as $column) {
if (!\is_array($column)) {
continue;
}
if (isset($column['url']) && \is_scalar($column['url'])) {
$urlCandidates[] = (string) $column['url'];
}
if (isset($column['href']) && \is_scalar($column['href'])) {
$urlCandidates[] = (string) $column['href'];
}
}
foreach ($urlCandidates as $url) {
$organizationId = $this->extractIdFromUrl($url);
if ($organizationId > 0) {
return $organizationId;
}
}
return 0;
}
/** @param array<string, mixed> $row */
private function extractRowFieldContent(array $row, string $fieldName): string
{
if (!isset($row[$fieldName]) || !\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{labels: list<string>, slugs: 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();
$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) {
continue;
}
if (isset($seen[$organizationId][$tagId])) {
continue;
}
$seen[$organizationId][$tagId] = true;
$map[$organizationId]['labels'][] = $label;
$slug = $this->slugify($label);
if ('' !== $slug) {
$map[$organizationId]['slugs'][] = $slug;
}
}
foreach ($map as $organizationId => $tagData) {
$map[$organizationId]['labels'] = array_values(array_unique($tagData['labels'] ?? []));
$map[$organizationId]['slugs'] = array_values(array_unique($tagData['slugs'] ?? []));
}
return $map;
}
/** @param list<int> $organizationIds
* @return array<int, string>
*/
private function fetchOrganizationLogoUuidMap(array $organizationIds): array
{
if ([] === $organizationIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT o.id AS organization_id, o.logo AS logo_uuid FROM tl_organization o 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);
if ($organizationId <= 0 || '' === $logoUuid) {
continue;
}
$map[$organizationId] = $logoUuid;
}
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 '';
}
private function slugify(string $value): string
{
$value = trim(mb_strtolower($value));
if ('' === $value) {
return '';
}
$value = strtr($value, [
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
$value = trim($value, '-');
return $value;
}
}
+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())));
}
}
+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())));
}
}
@@ -0,0 +1,258 @@
{% extends '@Contao/block_searchable.html.twig' %}
{% set wrapperAttributes = attrs()
.addClass(['ce_table', 'listing'])
.mergeWith(wrapperAttributes|default)
%}
{% block content %}
{% set legacyTagLabels = {
'10': 'Sport',
'11': 'Kultur',
'12': 'Politik',
'13': 'Soziales',
'14': 'Freizeit',
'15': 'Bildung',
'16': 'Religion',
'17': 'Natur',
'18': 'Gesellschaft'
} %}
{% if searchable %}
<div class="list_search">
<form method="get">
<div class="formbody">
<input type="hidden" name="order_by" value="{{ order_by }}">
<input type="hidden" name="sort" value="{{ sort }}">
{% if per_page %}
<input type="hidden" name="per_page" value="{{ per_page }}">
{% endif %}
<div class="widget widget-select">
<label for="ctrl_search" class="invisible">{{ fields_label }}</label>
<select name="search" id="ctrl_search" class="select">
{{ search_fields|raw }}
</select>
</div>
<div class="widget widget-text">
<label for="ctrl_for" class="invisible">{{ keywords_label }}</label>
<input type="text" name="for" id="ctrl_for" class="text" value="{{ for }}">
</div>
<div class="widget widget-submit">
<button type="submit" class="submit">{{ search_label }}</button>
</div>
</div>
</form>
</div>
{% endif %}
{% if per_page %}
<div class="list_per_page">
<form method="get">
<div class="formbody">
<input type="hidden" name="order_by" value="{{ order_by }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="search" value="{{ search }}">
<input type="hidden" name="for" value="{{ for }}">
<div class="widget widget-select">
<label for="ctrl_per_page" class="invisible">{{ per_page_label }}</label>
<select name="per_page" id="ctrl_per_page" class="select">
<option value="10"{% if 10 == per_page %} selected{% endif %}>10</option>
<option value="20"{% if 20 == per_page %} selected{% endif %}>20</option>
<option value="30"{% if 30 == per_page %} selected{% endif %}>30</option>
<option value="50"{% if 50 == per_page %} selected{% endif %}>50</option>
<option value="100"{% if 100 == per_page %} selected{% endif %}>100</option>
<option value="250"{% if 250 == per_page %} selected{% endif %}>250</option>
<option value="500"{% if 500 == per_page %} selected{% endif %}>500</option>
</select>
</div>
<div class="widget widget-submit">
<button type="submit" class="submit">{{ per_page_label }}</button>
</div>
</div>
</form>
</div>
{% endif %}
{% if searchable and for and not tbody|default %}
{{ no_results }}
{% else %}
{% set tagOptions = {} %}
{% for row in tbody|default([]) %}
{% set tagsRaw = row.tag_labels.content|default(row.tags.content|default(''))|striptags %}
{% set tagsPrepared = tagsRaw|replace({'&nbsp;': '', '&amp;nbsp;': '', ' ': '', ';': ',', '|': ',', '/': ',', ', ': ',', ' ,': ','}) %}
{% set tagParts = tagsPrepared is not empty ? tagsPrepared|split(',') : [] %}
{% for part in tagParts %}
{% set tagValue = part|striptags|replace({'&nbsp;': '', '&amp;nbsp;': '', ' ': ''})|trim %}
{% if legacyTagLabels[tagValue] is defined %}
{% set tagLabel = legacyTagLabels[tagValue] %}
{% elseif tagValue matches '/^\\d+$/' %}
{% set tagLabel = '' %}
{% elseif tagValue matches '/^[\\p{L}\\p{N}\\s._,&+\\-\\/]+$/u' %}
{% set tagLabel = tagValue %}
{% else %}
{% set tagLabel = '' %}
{% endif %}
{% if tagLabel is not empty %}
{% set tagSlug = tagLabel|lower|replace({'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', ' ': '-', '/': '-', '&': '-', '+': '-', '.': '', ',': '', '(': '', ')': '', '"': '', "'": ''}) %}
{% if tagSlug is not empty and tagOptions[tagSlug] is not defined %}
{% set tagOptions = tagOptions|merge({ (tagSlug): tagLabel }) %}
{% endif %}
{% endif %}
{% endfor %}
{% endfor %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/slim-select@3/dist/slimselect.min.css">
<div id="orgfilters" class="controls filters">
<label for="org-tag-filter" class="visually-hidden">Nach Typ filtern</label>
<select id="org-tag-filter" class="select" data-placeholder="Typ wählen">
<option value="">Alle</option>
{% for slug, label in tagOptions %}
<option value="{{ slug }}">{{ label }}</option>
{% endfor %}
</select>
<button type="button" id="org-filter-reset" class="submit" style="display:none;">Filter zurücksetzen</button>
</div>
<div id="org">
<div id="org-list">
{% for row in tbody|default([]) %}
{% set tagSlugsRaw = row.tag_slugs.content|default('')|trim %}
{% set tagsRaw = row.tag_labels.content|default(row.tags.content|default(''))|striptags %}
{% set tagsPrepared = tagsRaw|replace({'&nbsp;': '', '&amp;nbsp;': '', ' ': '', ';': ',', '|': ',', '/': ',', ', ': ',', ' ,': ','}) %}
{% set tagParts = tagsPrepared is not empty ? tagsPrepared|split(',') : [] %}
{% set tagClasses = [] %}
{% set tagSlugs = [] %}
{% if tagSlugsRaw is not empty %}
{% for slug in tagSlugsRaw|split(',') %}
{% set cleanedSlug = slug|trim %}
{% if cleanedSlug is not empty %}
{% set tagSlugs = tagSlugs|merge([cleanedSlug]) %}
{% set tagClasses = tagClasses|merge(['tag-' ~ cleanedSlug]) %}
{% endif %}
{% endfor %}
{% endif %}
{% for part in tagParts if tagSlugsRaw is empty %}
{% set tagValue = part|striptags|replace({'&nbsp;': '', '&amp;nbsp;': '', ' ': ''})|trim %}
{% if legacyTagLabels[tagValue] is defined %}
{% set tagLabel = legacyTagLabels[tagValue] %}
{% elseif tagValue matches '/^\\d+$/' %}
{% set tagLabel = '' %}
{% elseif tagValue matches '/^[\\p{L}\\p{N}\\s._,&+\\-\\/]+$/u' %}
{% set tagLabel = tagValue %}
{% else %}
{% set tagLabel = '' %}
{% endif %}
{% if tagLabel is not empty %}
{% set tagSlug = tagLabel|lower|replace({'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', ' ': '-', '/': '-', '&': '-', '+': '-', '.': '', ',': '', '(': '', ')': '', '"': '', "'": ''}) %}
{% if tagSlug is not empty %}
{% set tagClasses = tagClasses|merge(['tag-' ~ tagSlug]) %}
{% set tagSlugs = tagSlugs|merge([tagSlug]) %}
{% endif %}
{% endif %}
{% endfor %}
{% set title = row.title.content|default('') %}
{% set logoUuid = row.logo_uuid.content|default('')|trim %}
{% set lastCol = row|last %}
<div class="org-item{% if tagClasses|length %} {{ tagClasses|join(' ') }}{% endif %}"{% if tagSlugs|length %} data-tags="{{ tagSlugs|join(',') }}"{% endif %}>
<div class="wrapper">
{% if logoUuid is not empty %}
<div class="logo">{{ ('{{figure::' ~ logoUuid ~ '}}')|insert_tag_raw }}</div>
{% endif %}
{% if title is not empty %}
<div class="title">{{ title|sanitize_html }}</div>
{% endif %}
{% if details and lastCol and lastCol.url|default %}
<a class="details" href="{{ lastCol.url }}" title="{{ title|striptags }} - Details"></a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if pagination is defined %}
{{ pagination|raw }}
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/slim-select@3/dist/slimselect.min.js"></script>
<script>
(function () {
const selectElement = document.querySelector('#org-tag-filter');
const resetButton = document.querySelector('#org-filter-reset');
const items = Array.from(document.querySelectorAll('#org-list .org-item'));
if (!selectElement || !items.length || typeof SlimSelect === 'undefined') {
return;
}
const slim = new SlimSelect({
select: selectElement,
settings: {
allowDeselect: true,
showSearch: false,
placeholderText: 'Alle'
}
});
const applyFilter = function (selectedTag) {
const activeTag = selectedTag || '';
if (!activeTag) {
items.forEach(function (item) {
item.style.removeProperty('display');
});
if (resetButton) {
resetButton.style.display = 'none';
}
return;
}
items.forEach(function (item) {
const itemTags = (item.getAttribute('data-tags') || '').split(',').filter(Boolean);
item.style.display = itemTags.includes(activeTag) ? '' : 'none';
});
if (resetButton) {
resetButton.style.display = activeTag ? '' : 'none';
}
};
selectElement.addEventListener('change', function () {
applyFilter(selectElement.value || '');
});
if (resetButton) {
resetButton.addEventListener('click', function () {
slim.setSelected('');
applyFilter('');
});
}
if (selectElement.value) {
applyFilter(selectElement.value);
}
})();
</script>
{% endblock %}