Initial release

This commit is contained in:
Jürgen Mummert
2026-02-17 18:53:23 +01:00
commit 63b5556b21
45 changed files with 3962 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/vendor/
/var/
/.idea/
/.vscode/
/.DS_Store
.DS_Store
/composer.lock
+216
View File
@@ -0,0 +1,216 @@
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.
+7
View File
@@ -0,0 +1,7 @@
Copyright (c) Mummert Media
All rights reserved.
This package is proprietary software.
No part of this package may be copied, modified, distributed, sublicensed,
or used in any form without prior written permission of the copyright holder.
+49
View File
@@ -0,0 +1,49 @@
# Eventmanager Bundle
Contao 5 Bundle für Organisations-, Veranstaltungsort- und Event-Verwaltung inkl. Frontend-Module.
## Voraussetzungen
- PHP `^8.4`
- Contao `^5.7`
## Installation (lokal via Composer)
```bash
composer require mummert-media/eventmanager-bundle
```
## Installation im Contao Manager (VCS)
1. **Contao Manager****Composer****Repositories****Repository hinzufügen**
2. Typ: `vcs`
3. URL: `https://github.com/<owner>/eventmanager-bundle`
4. Paket hinzufügen: `mummert-media/eventmanager-bundle`
5. Bei Bedarf Version `dev-main` oder ein Tag (z. B. `1.0.0`) wählen
6. Änderungen anwenden
## Release / Versionierung
SemVer-Tags verwenden:
```bash
git tag v1.0.0
git push origin v1.0.0
```
## GitHub Setup
```bash
git init
git add .
git commit -m "Initial release"
git branch -M main
git remote add origin git@github.com:<owner>/eventmanager-bundle.git
git push -u origin main
```
## Optional: Packagist
1. Repository auf Packagist einreichen
2. GitHub Service Hook / Auto-Update aktivieren
3. Neue Versionen über Git-Tags veröffentlichen
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mummert-media/eventmanager-bundle",
"description": "Internal Contao backend bundle for organizations, locations and event/member assignments.",
"type": "contao-bundle",
"license": "proprietary",
"keywords": [
"contao",
"contao-bundle",
"eventmanager"
],
"require": {
"php": "^8.4",
"contao/core-bundle": "^5.7",
"contao/manager-plugin": "^2.0"
},
"autoload": {
"psr-4": {
"MummertMedia\\EventManagerBundle\\": "src/"
}
},
"extra": {
"contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin"
},
"prefer-stable": true
}
+16
View File
@@ -0,0 +1,16 @@
services:
_defaults:
autowire: true
autoconfigure: true
MummertMedia\EventManagerBundle\:
resource: ../src/
exclude:
- ../src/DependencyInjection/
- ../src/Contao/Manager/
- ../src/MummertMediaEventManagerBundle.php
MummertMedia\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback:
tags:
- { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit }
- { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit }
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_MODELS']['tl_organization'] = MummertMedia\EventManagerBundle\Model\OrganizationModel::class;
$GLOBALS['TL_MODELS']['tl_location'] = MummertMedia\EventManagerBundle\Model\LocationModel::class;
$GLOBALS['BE_MOD']['content']['eventmanager_organisationen'] = [
'tables' => ['tl_organization'],
];
$GLOBALS['BE_MOD']['content']['eventmanager_veranstaltungsorte'] = [
'tables' => ['tl_location'],
];
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Contao\DataContainer;
use Contao\Database;
use Contao\StringUtil;
unset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['location']);
unset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['address']);
$GLOBALS['TL_DCA']['tl_calendar_events']['config']['onload_callback'][] = static function (): void {
if (!isset($GLOBALS['TL_DCA']['tl_calendar_events']['palettes']) || !is_array($GLOBALS['TL_DCA']['tl_calendar_events']['palettes'])) {
return;
}
foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar_events']['palettes']) as $paletteName) {
if ($paletteName === '__selector__') {
continue;
}
PaletteManipulator::create()
->addLegend('organization_legend', 'details_legend', PaletteManipulator::POSITION_AFTER)
->addField(['location_id', 'type', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_calendar_events');
PaletteManipulator::create()
->addField('photographer', 'image_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_calendar_events');
PaletteManipulator::create()
->addField(['source', 'url', 'target'], 'details_legend', PaletteManipulator::POSITION_APPEND)
->addField(['termsAccepted', 'isSoldOut', 'isCanceled'], 'publish_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_calendar_events');
}
};
$GLOBALS['TL_DCA']['tl_calendar_events']['config']['onbeforesubmit_callback'][] = static function (array $values): array {
unset($values['organizations']);
return $values;
};
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['location_id'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'],
'exclude' => true,
'filter' => true,
'sorting' => true,
'inputType' => 'select',
'foreignKey' => 'tl_location.title',
'options_callback' => static function (): array {
$result = Database::getInstance()
->query('SELECT id, title FROM tl_location ORDER BY title ASC');
$options = [];
foreach ($result->fetchAllAssoc() as $row) {
$options[(int) $row['id']] = (string) $row['title'];
}
return $options;
},
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'relation' => [
'type' => 'hasOne',
'load' => 'eager',
'table' => 'tl_location',
'field' => 'id',
],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['type'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type'],
'exclude' => 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'],
'sql' => ['type' => 'blob', 'notnull' => false],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['organizations'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'],
'exclude' => true,
'inputType' => 'select',
'foreignKey' => 'tl_organization.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => [
'type' => 'hasMany',
'load' => 'lazy',
'table' => 'tl_organization',
'field' => 'id',
],
'load_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$result = Database::getInstance()
->prepare('SELECT organization_id FROM tl_calendar_events_organization WHERE event_id=? ORDER BY organization_id')
->execute($dc->id);
return array_map(static fn (array $row): int => (int) $row['organization_id'], $result->fetchAllAssoc());
},
],
'save_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$eventId = (int) $dc->id;
$organizationIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
$db = Database::getInstance();
$time = time();
$db->prepare('DELETE FROM tl_calendar_events_organization WHERE event_id=?')
->execute($eventId);
foreach ($organizationIds as $organizationId) {
$db->prepare('INSERT INTO tl_calendar_events_organization (tstamp, event_id, organization_id) VALUES (?, ?, ?)')
->execute($time, $eventId, $organizationId);
}
return $organizationIds;
},
],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['photographer'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['isSoldOut'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['isCanceled'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['termsAccepted'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'clr'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
];
if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source'])) {
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']['eval'] ??= [];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['source']['eval']['tl_class'] = 'w50';
}
if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target'])) {
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']['eval'] ??= [];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['target']['eval']['tl_class'] = 'w50';
}
if (isset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']) && is_array($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url'])) {
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval'] ??= [];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['tl_class'] = 'clr';
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['mandatory'] = false;
unset($GLOBALS['TL_DCA']['tl_calendar_events']['fields']['url']['eval']['rgxp']);
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_calendar_events_organization'] = [
'config' => [
'dataContainer' => DC_Table::class,
'sql' => [
'keys' => [
'id' => 'primary',
'event_id' => 'index',
'organization_id' => 'index',
],
],
],
'fields' => [
'id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true, 'notnull' => true],
],
'tstamp' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
'event_id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
'organization_id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
],
];
+196
View File
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
use Contao\DataContainer;
use Contao\Database;
use Contao\DC_Table;
use Contao\StringUtil;
$GLOBALS['TL_DCA']['tl_location'] = [
'config' => [
'dataContainer' => DC_Table::class,
'enableVersioning' => true,
'sql' => [
'keys' => [
'id' => 'primary',
'alias' => 'index',
'published' => 'index',
'title' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 1,
'fields' => ['title'],
'flag' => 1,
'panelLayout' => 'search,limit',
],
'label' => [
'fields' => ['title', 'city'],
'format' => '%s (%s)',
],
'global_operations' => [
'all' => [
'href' => 'act=select',
'class' => 'header_edit_all',
'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"',
],
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'copy' => [
'href' => 'act=copy',
'icon' => 'copy.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'' . (string) ($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? '') . '\'))return false;Backend.getScrollOffset()"',
],
'toggle' => [
'href' => 'act=toggle&amp;field=published',
'icon' => 'visible.svg',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'palettes' => [
'__selector__' => [],
'default' => '{title_legend},title,alias,image;{address_legend},street,street2,postal,city,state,country;{geo_legend},lat,lng;{description_legend},description;{publish_legend},published',
],
'fields' => [
'id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
],
'tstamp' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'dateAdded' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'title' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['title'],
'exclude' => true,
'search' => true,
'sorting' => true,
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'alias' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['alias'],
'exclude' => true,
'search' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 128, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
],
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['description'],
'exclude' => true,
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => ['type' => 'text', 'notnull' => false],
],
'street' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['street'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'street2' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['street2'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'postal' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['postal'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'city' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['city'],
'exclude' => true,
'search' => true,
'sorting' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'state' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['state'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'country' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['country'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 2, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 2, 'default' => 'de'],
],
'lat' => [
'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'],
],
'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['lng'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
],
'image' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['image'],
'exclude' => true,
'inputType' => 'fileTree',
'eval' => ['filesOnly' => true, 'fieldType' => 'radio', 'tl_class' => 'clr'],
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
],
'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['published'],
'exclude' => true,
'filter' => true,
'toggle' => true,
'inputType' => 'checkbox',
'eval' => ['doNotCopy' => true],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
],
],
];
$GLOBALS['TL_DCA']['tl_location']['fields']['alias']['save_callback'][] = static function (string $value, DataContainer $dc): string {
if ($value !== '') {
return $value;
}
$value = StringUtil::generateAlias($dc->activeRecord?->title ?? '');
$exists = Database::getInstance()
->prepare('SELECT id FROM tl_location WHERE alias=? AND id!=?')
->execute($value, (int) ($dc->id ?? 0));
if ($exists->numRows > 0) {
throw new \RuntimeException(sprintf((string) ($GLOBALS['TL_LANG']['tl_location']['aliasExists'] ?? '%s'), $value));
}
return $value;
};
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Contao\DataContainer;
use Contao\Database;
use Contao\Input;
use Contao\StringUtil;
if (isset($GLOBALS['TL_DCA']['tl_member']['palettes']) && is_array($GLOBALS['TL_DCA']['tl_member']['palettes'])) {
foreach (array_keys($GLOBALS['TL_DCA']['tl_member']['palettes']) as $paletteName) {
if ($paletteName === '__selector__') {
continue;
}
PaletteManipulator::create()
->addLegend('organization_legend', 'contact_legend', PaletteManipulator::POSITION_AFTER)
->addField('organizations', 'organization_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_member');
}
}
$GLOBALS['TL_DCA']['tl_member']['config']['onbeforesubmit_callback'][] = static function (array $values): array {
unset($values['organizations']);
return $values;
};
$GLOBALS['TL_DCA']['tl_member']['config']['onsubmit_callback'][] = static function (DataContainer $dc): void {
if (!$dc->id) {
return;
}
$postedValues = Input::post('organizations');
if (null === $postedValues) {
return;
}
$organizationIds = array_values(array_unique(array_map('intval', is_array($postedValues) ? $postedValues : [$postedValues])));
$memberId = (int) $dc->id;
$db = Database::getInstance();
$time = time();
$db->prepare('DELETE FROM tl_member_organization WHERE member_id=?')
->execute($memberId);
foreach ($organizationIds as $organizationId) {
$db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)')
->execute($time, $memberId, $organizationId);
}
};
$GLOBALS['TL_DCA']['tl_member']['fields']['organizations'] = [
'label' => &$GLOBALS['TL_LANG']['tl_member']['organizations'],
'exclude' => true,
'inputType' => 'select',
'foreignKey' => 'tl_organization.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => [
'type' => 'hasMany',
'load' => 'lazy',
'table' => 'tl_organization',
'field' => 'id',
],
'load_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$result = Database::getInstance()
->prepare('SELECT organization_id FROM tl_member_organization WHERE member_id=? ORDER BY organization_id')
->execute($dc->id);
return array_map(static fn (array $row): int => (int) $row['organization_id'], $result->fetchAllAssoc());
},
],
'save_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$memberId = (int) $dc->id;
$organizationIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
$db = Database::getInstance();
$time = time();
$db->prepare('DELETE FROM tl_member_organization WHERE member_id=?')
->execute($memberId);
foreach ($organizationIds as $organizationId) {
$db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)')
->execute($time, $memberId, $organizationId);
}
return $organizationIds;
},
],
];
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_member_organization'] = [
'config' => [
'dataContainer' => DC_Table::class,
'sql' => [
'keys' => [
'id' => 'primary',
'member_id' => 'index',
'organization_id' => 'index',
],
],
],
'fields' => [
'id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true, 'notnull' => true],
],
'tstamp' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
'member_id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
'organization_id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0, 'notnull' => true],
],
],
];
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
$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']['fields']['editPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['listPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['listPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['logoFolder'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['logoFolder'],
'exclude' => true,
'inputType' => 'fileTree',
'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['eventFolder'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['eventFolder'],
'exclude' => true,
'inputType' => 'fileTree',
'eval' => ['fieldType' => 'radio', 'files' => false, 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['termsPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['termsPage'],
'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']['frontendAuthorId'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['frontendArchiveId'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
+304
View File
@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
use Contao\DataContainer;
use Contao\Database;
use Contao\DC_Table;
use Contao\StringUtil;
$GLOBALS['TL_DCA']['tl_organization'] = [
'config' => [
'dataContainer' => DC_Table::class,
'enableVersioning' => true,
'onbeforesubmit_callback' => [
static function (array $values): array {
unset($values['members']);
return $values;
},
],
'sql' => [
'keys' => [
'id' => 'primary',
'alias' => 'index',
'published' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 1,
'fields' => ['title'],
'flag' => 1,
'panelLayout' => 'search,limit',
],
'label' => [
'fields' => ['title', 'city'],
'format' => '%s (%s)',
],
'global_operations' => [
'all' => [
'href' => 'act=select',
'class' => 'header_edit_all',
'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"',
],
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'copy' => [
'href' => 'act=copy',
'icon' => 'copy.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'' . (string) ($GLOBALS['TL_LANG']['MSC']['deleteConfirm'] ?? '') . '\'))return false;Backend.getScrollOffset()"',
],
'toggle' => [
'href' => 'act=toggle&amp;field=published',
'icon' => 'visible.svg',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'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',
],
'fields' => [
'id' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true],
],
'pid' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'tstamp' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'dateAdded' => [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
],
'title' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['title'],
'exclude' => true,
'search' => true,
'sorting' => true,
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'alias' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['alias'],
'exclude' => true,
'search' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 128, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
],
'logo' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['logo'],
'exclude' => true,
'inputType' => 'fileTree',
'eval' => ['filesOnly' => true, 'fieldType' => 'radio', 'tl_class' => 'clr'],
'sql' => ['type' => 'binary', 'length' => 16, 'notnull' => false],
],
'street' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['street'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'street2' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['street2'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'postal' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['postal'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'city' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['city'],
'exclude' => true,
'search' => true,
'sorting' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'state' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['state'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'country' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['country'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 2, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 2, 'default' => ''],
],
'phone' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['phone'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 64, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 64, 'default' => ''],
],
'email' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['email'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'website' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['website'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['rgxp' => 'url', 'maxlength' => 255, 'tl_class' => 'clr'],
'sql' => ['type' => 'string', 'length' => 255, 'default' => ''],
],
'lat' => [
'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'],
],
'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['lng'],
'exclude' => true,
'inputType' => 'text',
'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
],
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['description'],
'exclude' => true,
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => ['type' => 'text', 'notnull' => false],
],
'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['published'],
'exclude' => true,
'filter' => true,
'toggle' => true,
'inputType' => 'checkbox',
'eval' => ['doNotCopy' => true],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
],
'isExternal' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['isExternal'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
],
'type' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['type'],
'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'],
'sql' => ['type' => 'blob', 'notnull' => false],
],
'members' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['members'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => static function (): array {
$result = Database::getInstance()
->query('SELECT id, firstname, lastname FROM tl_member ORDER BY lastname ASC, firstname ASC');
$options = [];
foreach ($result->fetchAllAssoc() as $row) {
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
$options[(int) $row['id']] = $name !== ''
? $name
: sprintf((string) ($GLOBALS['TL_LANG']['tl_organization']['memberFallback'] ?? '%s'), $row['id']);
}
return $options;
},
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => [
'type' => 'hasMany',
'load' => 'lazy',
'table' => 'tl_member',
'field' => 'id',
],
'load_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$result = Database::getInstance()
->prepare('SELECT member_id FROM tl_member_organization WHERE organization_id=? ORDER BY member_id')
->execute($dc->id);
return array_map(static fn (array $row): int => (int) $row['member_id'], $result->fetchAllAssoc());
},
],
'save_callback' => [
static function ($value, DataContainer $dc): array {
if (!$dc->id) {
return [];
}
$organizationId = (int) $dc->id;
$memberIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
$db = Database::getInstance();
$time = time();
$db->prepare('DELETE FROM tl_member_organization WHERE organization_id=?')
->execute($organizationId);
foreach ($memberIds as $memberId) {
$db->prepare('INSERT INTO tl_member_organization (tstamp, member_id, organization_id) VALUES (?, ?, ?)')
->execute($time, $memberId, $organizationId);
}
return $memberIds;
},
],
],
],
];
$GLOBALS['TL_DCA']['tl_organization']['fields']['alias']['save_callback'][] = static function (string $value, DataContainer $dc): string {
if ($value !== '') {
return $value;
}
$value = StringUtil::generateAlias($dc->activeRecord?->title ?? '');
$exists = Database::getInstance()
->prepare('SELECT id FROM tl_organization WHERE alias=? AND id!=?')
->execute($value, (int) ($dc->id ?? 0));
if ($exists->numRows > 0) {
throw new \RuntimeException(sprintf((string) ($GLOBALS['TL_LANG']['tl_organization']['aliasExists'] ?? '%s'), $value));
}
return $value;
};
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['MOD']['eventmanager_organisationen'] = ['Organisationen', 'Organisationen verwalten'];
$GLOBALS['TL_LANG']['MOD']['eventmanager_veranstaltungsorte'] = ['Veranstaltungsorte', 'Veranstaltungsorte verwalten'];
$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_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.'];
@@ -0,0 +1,16 @@
<?php
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']['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
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_location']['title'] = ['Titel', 'Name des Veranstaltungsortes'];
$GLOBALS['TL_LANG']['tl_location']['alias'] = ['Alias', 'Eindeutiger Alias des Veranstaltungsortes'];
$GLOBALS['TL_LANG']['tl_location']['description'] = ['Beschreibung', 'Beschreibung'];
$GLOBALS['TL_LANG']['tl_location']['street'] = ['Straße', 'Straße'];
$GLOBALS['TL_LANG']['tl_location']['street2'] = ['Adresszusatz', 'Adresszusatz'];
$GLOBALS['TL_LANG']['tl_location']['postal'] = ['PLZ', 'Postleitzahl'];
$GLOBALS['TL_LANG']['tl_location']['city'] = ['Ort', 'Ort'];
$GLOBALS['TL_LANG']['tl_location']['state'] = ['Bundesland', 'Bundesland'];
$GLOBALS['TL_LANG']['tl_location']['country'] = ['Land', 'Land'];
$GLOBALS['TL_LANG']['tl_location']['lat'] = ['Breitengrad', 'Breitengrad'];
$GLOBALS['TL_LANG']['tl_location']['lng'] = ['Längengrad', 'Längengrad'];
$GLOBALS['TL_LANG']['tl_location']['image'] = ['Bild', 'Bild-Datei'];
$GLOBALS['TL_LANG']['tl_location']['published'] = ['Veröffentlicht', 'Veranstaltungsort veröffentlichen'];
$GLOBALS['TL_LANG']['tl_location']['title_legend'] = 'Titel';
$GLOBALS['TL_LANG']['tl_location']['address_legend'] = 'Adresse';
$GLOBALS['TL_LANG']['tl_location']['geo_legend'] = 'Geodaten';
$GLOBALS['TL_LANG']['tl_location']['description_legend'] = 'Beschreibung';
$GLOBALS['TL_LANG']['tl_location']['publish_legend'] = 'Veröffentlichung';
$GLOBALS['TL_LANG']['tl_location']['aliasExists'] = 'Alias "%s" ist bereits vergeben.';
+6
View File
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_member']['organization_legend'] = 'Organisationen';
$GLOBALS['TL_LANG']['tl_member']['organizations'] = ['Organisationen', 'Zugeordnete Organisationen'];
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_module']['eventmanager_legend'] = 'Event-Manager';
$GLOBALS['TL_LANG']['tl_module']['editPage'] = ['Bearbeitungsseite', 'Bitte wählen Sie die Seite mit dem Bearbeitungsmodul aus.'];
$GLOBALS['TL_LANG']['tl_module']['listPage'] = ['Listenansicht-Seite', 'Bitte wählen Sie die Seite mit dem Listenmodul aus.'];
$GLOBALS['TL_LANG']['tl_module']['logoFolder'] = ['Logo-Ordner', 'Bitte wählen Sie den Ordner aus, in dem hochgeladene Organisationslogos gespeichert werden sollen.'];
$GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event-Ordner', 'Bitte wählen Sie den Ordner aus, in dem eventbezogene Uploads gespeichert werden sollen.'];
$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.'];
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_organization']['title'] = ['Titel', 'Name der Organisation'];
$GLOBALS['TL_LANG']['tl_organization']['alias'] = ['Alias', 'Eindeutiger Alias der Organisation'];
$GLOBALS['TL_LANG']['tl_organization']['logo'] = ['Logo', 'Logo-Datei'];
$GLOBALS['TL_LANG']['tl_organization']['street'] = ['Straße', 'Straße'];
$GLOBALS['TL_LANG']['tl_organization']['street2'] = ['Adresszusatz', 'Adresszusatz'];
$GLOBALS['TL_LANG']['tl_organization']['postal'] = ['PLZ', 'Postleitzahl'];
$GLOBALS['TL_LANG']['tl_organization']['city'] = ['Ort', 'Ort'];
$GLOBALS['TL_LANG']['tl_organization']['state'] = ['Bundesland', 'Bundesland'];
$GLOBALS['TL_LANG']['tl_organization']['country'] = ['Land', 'Land'];
$GLOBALS['TL_LANG']['tl_organization']['phone'] = ['Telefon', 'Telefonnummer'];
$GLOBALS['TL_LANG']['tl_organization']['email'] = ['E-Mail', 'E-Mail-Adresse'];
$GLOBALS['TL_LANG']['tl_organization']['website'] = ['Webseite', 'Webseite'];
$GLOBALS['TL_LANG']['tl_organization']['lat'] = ['Breitengrad', 'Breitengrad'];
$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']['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';
$GLOBALS['TL_LANG']['tl_organization']['geo_legend'] = 'Geodaten';
$GLOBALS['TL_LANG']['tl_organization']['description_legend'] = 'Beschreibung';
$GLOBALS['TL_LANG']['tl_organization']['relation_legend'] = 'Beziehungen';
$GLOBALS['TL_LANG']['tl_organization']['publish_legend'] = 'Veröffentlichung';
$GLOBALS['TL_LANG']['tl_organization']['memberFallback'] = 'Mitglied #%s';
$GLOBALS['TL_LANG']['tl_organization']['aliasExists'] = 'Alias "%s" ist bereits vergeben.';
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['MOD']['eventmanager_organisationen'] = ['Organizations', 'Manage organizations'];
$GLOBALS['TL_LANG']['MOD']['eventmanager_veranstaltungsorte'] = ['Locations', 'Manage locations'];
$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_edit'] = ['Edit event', 'Edit form for one event.'];
@@ -0,0 +1,16 @@
<?php
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']['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
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_location']['title'] = ['Title', 'Location name'];
$GLOBALS['TL_LANG']['tl_location']['alias'] = ['Alias', 'Unique location alias'];
$GLOBALS['TL_LANG']['tl_location']['description'] = ['Description', 'Description'];
$GLOBALS['TL_LANG']['tl_location']['street'] = ['Street', 'Street'];
$GLOBALS['TL_LANG']['tl_location']['street2'] = ['Address line 2', 'Address line 2'];
$GLOBALS['TL_LANG']['tl_location']['postal'] = ['Postal code', 'Postal code'];
$GLOBALS['TL_LANG']['tl_location']['city'] = ['City', 'City'];
$GLOBALS['TL_LANG']['tl_location']['state'] = ['State', 'State'];
$GLOBALS['TL_LANG']['tl_location']['country'] = ['Country', 'Country'];
$GLOBALS['TL_LANG']['tl_location']['lat'] = ['Latitude', 'Latitude'];
$GLOBALS['TL_LANG']['tl_location']['lng'] = ['Longitude', 'Longitude'];
$GLOBALS['TL_LANG']['tl_location']['image'] = ['Image', 'Image file'];
$GLOBALS['TL_LANG']['tl_location']['published'] = ['Published', 'Publish location'];
$GLOBALS['TL_LANG']['tl_location']['title_legend'] = 'Title';
$GLOBALS['TL_LANG']['tl_location']['address_legend'] = 'Address';
$GLOBALS['TL_LANG']['tl_location']['geo_legend'] = 'Geo data';
$GLOBALS['TL_LANG']['tl_location']['description_legend'] = 'Description';
$GLOBALS['TL_LANG']['tl_location']['publish_legend'] = 'Publication';
$GLOBALS['TL_LANG']['tl_location']['aliasExists'] = 'Alias "%s" already exists.';
+6
View File
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_member']['organization_legend'] = 'Organizations';
$GLOBALS['TL_LANG']['tl_member']['organizations'] = ['Organizations', 'Assigned organizations'];
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_module']['eventmanager_legend'] = 'Event manager';
$GLOBALS['TL_LANG']['tl_module']['editPage'] = ['Edit page', 'Please select the page containing the edit module.'];
$GLOBALS['TL_LANG']['tl_module']['listPage'] = ['List page', 'Please select the page containing the list module.'];
$GLOBALS['TL_LANG']['tl_module']['logoFolder'] = ['Logo folder', 'Please select the folder where uploaded organization logos should be stored.'];
$GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event folder', 'Please select the folder where event-related uploads should be stored.'];
$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.'];
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_organization']['title'] = ['Title', 'Organization name'];
$GLOBALS['TL_LANG']['tl_organization']['alias'] = ['Alias', 'Unique organization alias'];
$GLOBALS['TL_LANG']['tl_organization']['logo'] = ['Logo', 'Logo file'];
$GLOBALS['TL_LANG']['tl_organization']['street'] = ['Street', 'Street'];
$GLOBALS['TL_LANG']['tl_organization']['street2'] = ['Address line 2', 'Address line 2'];
$GLOBALS['TL_LANG']['tl_organization']['postal'] = ['Postal code', 'Postal code'];
$GLOBALS['TL_LANG']['tl_organization']['city'] = ['City', 'City'];
$GLOBALS['TL_LANG']['tl_organization']['state'] = ['State', 'State'];
$GLOBALS['TL_LANG']['tl_organization']['country'] = ['Country', 'Country'];
$GLOBALS['TL_LANG']['tl_organization']['phone'] = ['Phone', 'Phone number'];
$GLOBALS['TL_LANG']['tl_organization']['email'] = ['Email', 'Email address'];
$GLOBALS['TL_LANG']['tl_organization']['website'] = ['Website', 'Website'];
$GLOBALS['TL_LANG']['tl_organization']['lat'] = ['Latitude', 'Latitude'];
$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']['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';
$GLOBALS['TL_LANG']['tl_organization']['geo_legend'] = 'Geo data';
$GLOBALS['TL_LANG']['tl_organization']['description_legend'] = 'Description';
$GLOBALS['TL_LANG']['tl_organization']['relation_legend'] = 'Relations';
$GLOBALS['TL_LANG']['tl_organization']['publish_legend'] = 'Publication';
$GLOBALS['TL_LANG']['tl_organization']['memberFallback'] = 'Member #%s';
$GLOBALS['TL_LANG']['tl_organization']['aliasExists'] = 'Alias "%s" already exists.';
View File
@@ -0,0 +1,310 @@
{% extends "@Contao/frontend_module/_base.html.twig" %}
{% block content %}
{% if error is defined and error %}
<p role="alert">{{ error }}</p>
<p><a href="{{ backUrl }}">Zurück</a></p>
{% elseif form is defined and form %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/airbnb.css">
<link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css">
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
<script src="https://unpkg.com/filepond/dist/filepond.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script>
{{ form_start(form, { action: app.request.uri, attr: { 'aria-live': 'polite' } }) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" id="remove_image" name="remove_image" value="0">
{{ form_row(form.title) }}
{{ form_row(form.startDate) }}
{{ form_row(form.endDate) }}
{{ form_row(form.addTime) }}
<div id="event-time-wrap" style="display:none;" hidden aria-hidden="true">
{{ form_row(form.startTime) }}
{{ form_row(form.endTime) }}
</div>
{% if form.organization_ids is defined %}
{{ form_row(form.organization_ids) }}
{% endif %}
{{ form_row(form.location_id) }}
{{ form_row(form.type) }}
{{ form_row(form.teaser) }}
{{ form_row(form.description) }}
{{ form_row(form.url) }}
{{ form_row(form.addImage) }}
<div id="event-image-upload-wrap" style="display:none;" hidden aria-hidden="true">
{{ form_row(form.eventUpload) }}
{{ form_row(form.photographer) }}
<p class="help-text">Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.</p>
</div>
{{ form_row(form.termsAccepted) }}
{{ form_row(form.isSoldOut) }}
{{ form_row(form.isCanceled) }}
{{ form_row(form.published) }}
<div class="actions" aria-label="Formularaktionen">
<button type="submit" id="save-btn" disabled>Speichern</button>
<button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button>
<a href="{{ backUrl }}" aria-label="Zurück zur vorherigen Seite">Zurück</a>
</div>
{{ form_end(form) }}
<script>
(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');
if (organizationSelect && typeof window.Choices === 'function') {
new window.Choices(organizationSelect, {
searchEnabled: true,
shouldSort: false,
removeItemButton: true,
itemSelectText: '',
placeholder: true,
placeholderValue: organizationSelect.dataset.placeholder || 'Veranstalter suchen …',
noResultsText: 'Keine Treffer',
noChoicesText: 'Keine Optionen verfügbar'
});
}
if (locationSelect && typeof window.Choices === 'function') {
new window.Choices(locationSelect, {
searchEnabled: true,
shouldSort: false,
itemSelectText: '',
placeholder: true,
placeholderValue: locationSelect.dataset.placeholder || 'Suchen …',
noResultsText: 'Keine Treffer',
noChoicesText: 'Keine Optionen verfügbar'
});
}
if (typeSelect && typeof window.Choices === 'function') {
new window.Choices(typeSelect, {
searchEnabled: false,
shouldSort: false,
removeItemButton: true,
itemSelectText: '',
placeholder: true,
placeholderValue: typeSelect.dataset.placeholder || 'Typen suchen …',
noResultsText: 'Keine Treffer',
noChoicesText: 'Keine Optionen verfügbar'
});
}
if (typeof window.flatpickr === 'function') {
const dateInputs = document.querySelectorAll('input.js-flatpickr-date');
dateInputs.forEach(function (input) {
window.flatpickr(input, {
locale: 'de',
dateFormat: 'Y-m-d',
allowInput: true
});
});
const timeInputs = document.querySelectorAll('input.js-flatpickr-time');
timeInputs.forEach(function (input) {
window.flatpickr(input, {
locale: 'de',
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
time_24hr: true,
allowInput: true
});
});
}
const addImageToggle = document.querySelector('input.js-add-image-toggle');
const addTimeToggle = document.querySelector('input.js-add-time-toggle');
const timeWrap = document.getElementById('event-time-wrap');
const startTimeInput = document.getElementById('{{ form.startTime.vars.id }}');
const imageWrap = document.getElementById('event-image-upload-wrap');
const imageInput = document.querySelector('input.js-event-image-upload');
const photographerInput = document.querySelector('input.js-photographer');
const removeImageInput = document.getElementById('remove_image');
const currentImagePath = {{ (currentImagePath is defined and currentImagePath) ? ('/' ~ currentImagePath)|json_encode|raw : 'null' }};
const hadInitialImage = !!currentImagePath;
let pondDirty = false;
const form = document.querySelector('form');
const saveButton = document.getElementById('save-btn');
const saveBackButton = document.getElementById('save-back-btn');
if (!form || !saveButton || !saveBackButton) {
return;
}
const initial = new FormData(form);
const hasChanges = () => {
const current = new FormData(form);
for (const [key, value] of current.entries()) {
if (initial.getAll(key).join(',') !== current.getAll(key).join(',')) {
return true;
}
}
return false;
};
const updateButtons = () => {
const changed = hasChanges() || pondDirty;
saveButton.disabled = !changed;
saveBackButton.disabled = !changed;
};
const setImageVisibility = () => {
if (!addImageToggle || !imageWrap) {
return;
}
addImageToggle.setAttribute('aria-controls', 'event-image-upload-wrap');
addImageToggle.setAttribute('aria-expanded', addImageToggle.checked ? 'true' : 'false');
imageWrap.style.display = addImageToggle.checked ? '' : 'none';
imageWrap.hidden = !addImageToggle.checked;
imageWrap.setAttribute('aria-hidden', addImageToggle.checked ? 'false' : 'true');
if (photographerInput) {
photographerInput.required = !!addImageToggle.checked;
}
if (!addImageToggle.checked && removeImageInput) {
removeImageInput.value = hadInitialImage ? '1' : '0';
}
updateButtons();
};
const setTimeVisibility = () => {
if (!addTimeToggle || !timeWrap) {
return;
}
addTimeToggle.setAttribute('aria-controls', 'event-time-wrap');
addTimeToggle.setAttribute('aria-expanded', addTimeToggle.checked ? 'true' : 'false');
timeWrap.style.display = addTimeToggle.checked ? '' : 'none';
timeWrap.hidden = !addTimeToggle.checked;
timeWrap.setAttribute('aria-hidden', addTimeToggle.checked ? 'false' : 'true');
if (startTimeInput) {
startTimeInput.required = !!addTimeToggle.checked;
}
updateButtons();
};
if (addTimeToggle) {
addTimeToggle.addEventListener('change', setTimeVisibility);
setTimeVisibility();
}
if (addImageToggle) {
addImageToggle.addEventListener('change', setImageVisibility);
setImageVisibility();
}
if (imageInput && typeof window.FilePond !== 'undefined') {
if (typeof window.FilePondPluginImagePreview !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImagePreview);
}
const filePondOptions = {
instantUpload: false,
storeAsFile: true,
allowMultiple: false,
allowReplace: true,
credits: false,
acceptedFileTypes: ['image/*'],
labelIdle: 'Bild hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>'
};
if (currentImagePath) {
filePondOptions.files = [
{
source: currentImagePath,
options: {
type: 'local'
}
}
];
filePondOptions.server = {
load: (source, load, error, progress, abort) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', source);
xhr.responseType = 'blob';
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
load(xhr.response);
} else {
error('Laden fehlgeschlagen');
}
};
xhr.onerror = function () {
error('Laden fehlgeschlagen');
};
xhr.send();
return {
abort: () => {
xhr.abort();
abort();
}
};
}
};
}
const pond = window.FilePond.create(imageInput, filePondOptions);
const syncPondState = () => {
const files = pond.getFiles();
const hasLocalFile = files.some((file) => window.FilePond.FileOrigin && file.origin === window.FilePond.FileOrigin.LOCAL);
const hasNewFile = files.some((file) => window.FilePond.FileOrigin && file.origin !== window.FilePond.FileOrigin.LOCAL);
const imageEnabled = addImageToggle ? addImageToggle.checked : true;
pondDirty = imageEnabled && (hasNewFile || (hadInitialImage && !hasLocalFile));
if (removeImageInput) {
if (!imageEnabled) {
removeImageInput.value = hadInitialImage ? '1' : '0';
} else {
removeImageInput.value = hadInitialImage && !hasLocalFile && !hasNewFile ? '1' : '0';
}
}
updateButtons();
};
syncPondState();
pond.on('removefile', function () {
syncPondState();
});
pond.on('addfile', function () {
syncPondState();
});
pond.on('updatefiles', function () {
syncPondState();
});
}
form.addEventListener('input', function () {
updateButtons();
});
form.addEventListener('change', function () {
updateButtons();
});
})();
</script>
{% else %}
<p>Kein Formular verfügbar.</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,101 @@
{% extends "@Contao/frontend_module/_base.html.twig" %}
{% block content %}
<h2>Kommende Veranstaltungen</h2>
{% if upcomingEvents is empty %}
<p>Keine kommenden Veranstaltungen gefunden.</p>
{% else %}
<ul class="member-events member-events-upcoming" aria-label="Liste kommender Veranstaltungen">
{% for item in upcomingEvents %}
<li>
<span>{{ item.title }}</span>
<span>
{% if item.startDate %}
<time datetime="{{ item.startDate|date('Y-m-d') }}">{{ item.startDate|date('d.m.Y') }}</time>
{% endif %}
</span>
{% if isEditor %}
<form method="post" style="display:inline;" aria-label="Sichtbarkeit für {{ item.title }} ändern">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="toggle_published">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button>
</form>
{% if item.editUrl %}
<a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a>
{% endif %}
<form method="post" style="display:inline;" aria-label="{{ item.title }} duplizieren">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="duplicate">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button>
</form>
<form method="post" style="display:inline;" onsubmit="return confirm('wirklich löschen?');" aria-label="{{ item.title }} löschen">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} löschen">Löschen</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% if canCreateEvent is defined and canCreateEvent %}
<form method="post" aria-label="Neue Veranstaltung erstellen">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="create">
<button type="submit" aria-label="Neue Veranstaltung erstellen">Neue Veranstaltung erstellen</button>
</form>
{% endif %}
<h2>Vergangene Veranstaltungen</h2>
{% if pastEvents is empty %}
<p>Keine vergangenen Veranstaltungen gefunden.</p>
{% else %}
<ul class="member-events member-events-past" aria-label="Liste vergangener Veranstaltungen">
{% for item in pastEvents %}
<li>
<span>{{ item.title }}</span>
<span>
{% if item.startDate %}
<time datetime="{{ item.startDate|date('Y-m-d') }}">{{ item.startDate|date('d.m.Y') }}</time>
{% endif %}
</span>
{% if isEditor %}
<form method="post" style="display:inline;" aria-label="Sichtbarkeit für {{ item.title }} ändern">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="toggle_published">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.published ? 'Auf inaktiv setzen' : 'Auf aktiv setzen' }} für {{ item.title }}">{{ item.published ? 'aktiv' : 'inaktiv' }}</button>
</form>
{% if item.editUrl %}
<a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a>
{% endif %}
<form method="post" style="display:inline;" aria-label="{{ item.title }} duplizieren">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="duplicate">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} duplizieren">Duplizieren</button>
</form>
<form method="post" style="display:inline;" onsubmit="return confirm('wirklich löschen?');" aria-label="{{ item.title }} löschen">
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="event_id" value="{{ item.id }}">
<button type="submit" aria-label="{{ item.title }} löschen">Löschen</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "@Contao/frontend_module/_base.html.twig" %}
{% block content %}
<h2>Organisationen</h2>
{% if organizations is empty %}
<p>Keine Organisationen gefunden.</p>
{% else %}
<ul class="member-organizations" aria-label="Liste der Organisationen">
{% for item in organizations %}
<li>
<span>{{ item.title }}</span>
{% if item.editUrl %}
<a href="{{ item.editUrl }}" aria-label="{{ item.title }} bearbeiten">Bearbeiten</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
@@ -0,0 +1,169 @@
{% extends "@Contao/frontend_module/_base.html.twig" %}
{% block content %}
{% if error is defined and error %}
<p role="alert">{{ error }}</p>
<p><a href="{{ backUrl }}">Zurück</a></p>
{% elseif form is defined and form %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css">
<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
<script src="https://unpkg.com/filepond/dist/filepond.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js"></script>
{{ form_start(form, { attr: { 'aria-live': 'polite' } }) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ requestToken }}">
<input type="hidden" id="remove_logo" name="remove_logo" value="0">
{{ form_widget(form) }}
<div class="actions" aria-label="Formularaktionen">
<button type="submit" id="save-btn" disabled>Speichern</button>
<button type="submit" id="save-back-btn" name="save_back" value="1" disabled>Speichern und Zurück</button>
<a href="{{ backUrl }}" aria-label="Zurück zur vorherigen Seite">Zurück</a>
</div>
{{ form_end(form) }}
<script>
(function () {
const typeSelect = document.querySelector('select.js-organization-type-choice');
if (typeSelect && typeof window.Choices === 'function') {
new window.Choices(typeSelect, {
searchEnabled: false,
shouldSort: false,
removeItemButton: true,
itemSelectText: '',
placeholder: true,
placeholderValue: typeSelect.dataset.placeholder || 'Typ auswählen …',
noResultsText: 'Keine Treffer',
noChoicesText: 'Keine Optionen verfügbar'
});
}
const currentLogoPath = {{ (currentLogoPath is defined and currentLogoPath) ? ('/' ~ currentLogoPath)|json_encode|raw : 'null' }};
const logoInput = document.querySelector('input.js-logo-upload');
const removeLogoInput = document.getElementById('remove_logo');
const hadInitialLogo = !!currentLogoPath;
let pondDirty = false;
const form = document.querySelector('form');
const saveButton = document.getElementById('save-btn');
const saveBackButton = document.getElementById('save-back-btn');
if (!form || !saveButton || !saveBackButton) {
return;
}
const initial = new FormData(form);
const hasChanges = () => {
const current = new FormData(form);
for (const [key, value] of current.entries()) {
if (initial.getAll(key).join(',') !== current.getAll(key).join(',')) {
return true;
}
}
return false;
};
const updateButtons = () => {
const changed = hasChanges() || pondDirty;
saveButton.disabled = !changed;
saveBackButton.disabled = !changed;
};
if (logoInput && typeof window.FilePond !== 'undefined') {
if (typeof window.FilePondPluginImagePreview !== 'undefined') {
window.FilePond.registerPlugin(window.FilePondPluginImagePreview);
}
const filePondOptions = {
instantUpload: false,
storeAsFile: true,
allowMultiple: false,
allowReplace: true,
credits: false,
acceptedFileTypes: ['image/*'],
labelIdle: 'Logo hierher ziehen oder <span class="filepond--label-action">durchsuchen</span>'
};
if (currentLogoPath) {
filePondOptions.files = [
{
source: currentLogoPath,
options: {
type: 'local'
}
}
];
filePondOptions.server = {
load: (source, load, error, progress, abort) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', source);
xhr.responseType = 'blob';
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
load(xhr.response);
} else {
error('Laden fehlgeschlagen');
}
};
xhr.onerror = function () {
error('Laden fehlgeschlagen');
};
xhr.send();
return {
abort: () => {
xhr.abort();
abort();
}
};
}
};
}
const pond = window.FilePond.create(logoInput, filePondOptions);
const syncPondState = () => {
const files = pond.getFiles();
const hasLocalFile = files.some((file) => window.FilePond.FileOrigin && file.origin === window.FilePond.FileOrigin.LOCAL);
const hasNewFile = files.some((file) => window.FilePond.FileOrigin && file.origin !== window.FilePond.FileOrigin.LOCAL);
pondDirty = hasNewFile || (hadInitialLogo && !hasLocalFile);
if (removeLogoInput) {
removeLogoInput.value = hadInitialLogo && !hasLocalFile && !hasNewFile ? '1' : '0';
}
updateButtons();
};
syncPondState();
pond.on('removefile', function (error, file) {
if (error) {
return;
}
syncPondState();
});
pond.on('addfile', function () {
syncPondState();
});
pond.on('updatefiles', function () {
syncPondState();
});
}
form.addEventListener('input', function () {
updateButtons();
});
form.addEventListener('change', function () {
updateButtons();
});
})();
</script>
{% else %}
<p>Kein Formular verfügbar.</p>
{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Contao\Manager;
use Contao\CalendarBundle\ContaoCalendarBundle;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use MummertMedia\EventManagerBundle\MummertMediaEventManagerBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(MummertMediaEventManagerBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
];
}
}
@@ -0,0 +1,384 @@
<?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\Dbafs;
use Contao\FilesModel;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Form\EventType;
use MummertMedia\EventManagerBundle\Service\EventRepository;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'event_edit', category: 'eventmanager', template: 'frontend/event_edit')]
class EventEditController extends AbstractFrontendModuleController
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
$backUrl = $this->resolveBackUrl($request, $model);
$eventParam = $request->query->get('event');
$isCreateMode = '1' === (string) $request->query->get('create', '0') && (null === $eventParam || (int) $eventParam <= 0);
if (!$user instanceof FrontendUser) {
$template->set('error', 'Bitte zuerst als Mitglied einloggen.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
$eventId = $isCreateMode ? 0 : (int) $eventParam;
$event = null;
if (!$isCreateMode) {
if ($eventId <= 0 || !$this->eventRepository->memberHasEvent((int) $user->id, $eventId)) {
$template->set('error', 'Keine Berechtigung für diese Veranstaltung oder ungültige ID.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
$event = $this->eventRepository->findById($eventId);
if (null === $event) {
$template->set('error', 'Veranstaltung nicht gefunden.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
}
if ($isCreateMode) {
$event = [
'title' => '',
'startDate' => null,
'endDate' => null,
'addTime' => '',
'startTime' => null,
'endTime' => null,
'location_id' => 0,
'type' => null,
'teaser' => '',
'description' => '',
'url' => '',
'photographer' => '',
'addImage' => '',
'termsAccepted' => '',
'isSoldOut' => '',
'isCanceled' => '',
'published' => '',
'singleSRC' => null,
];
}
$memberOrganizationIds = $this->eventRepository->getOrganizationIdsForMember((int) $user->id);
$organizationChoices = $this->eventRepository->getOrganizationChoicesForMember((int) $user->id);
$showOrganization = count($memberOrganizationIds) > 1;
$currentOrganizationIds = $this->eventRepository->getOrganizationIdsForEvent($eventId);
$formData = [
'title' => (string) ($event['title'] ?? ''),
'startDate' => !empty($event['startDate']) ? date('Y-m-d', (int) $event['startDate']) : '',
'endDate' => !empty($event['endDate']) ? date('Y-m-d', (int) $event['endDate']) : '',
'addTime' => '1' === (string) ($event['addTime'] ?? ''),
'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),
'teaser' => (string) ($event['teaser'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'url' => (string) ($event['url'] ?? ''),
'photographer' => (string) ($event['photographer'] ?? ''),
'addImage' => '1' === (string) ($event['addImage'] ?? ''),
'termsAccepted' => '1' === (string) ($event['termsAccepted'] ?? ''),
'isSoldOut' => '1' === (string) ($event['isSoldOut'] ?? ''),
'isCanceled' => '1' === (string) ($event['isCanceled'] ?? ''),
'published' => '1' === (string) ($event['published'] ?? ''),
];
if ($showOrganization) {
$allowedOrganizationIds = array_values(array_unique(array_map('intval', $memberOrganizationIds)));
$selectedOrganizationIds = array_values(array_intersect($currentOrganizationIds, $allowedOrganizationIds));
if ([] === $selectedOrganizationIds && [] !== $allowedOrganizationIds) {
$selectedOrganizationIds = [(int) $allowedOrganizationIds[0]];
}
$formData['organization_ids'] = $selectedOrganizationIds;
}
$currentImagePath = null;
if (!empty($event['singleSRC'])) {
$imageModel = FilesModel::findByUuid((string) $event['singleSRC']);
if (null !== $imageModel) {
$currentImagePath = $imageModel->path;
}
}
$form = $this->createForm(EventType::class, $formData, [
'location_choices' => $this->eventRepository->getLocationChoices(),
'organization_choices' => $organizationChoices,
'selected_organization_ids' => $showOrganization ? ($formData['organization_ids'] ?? []) : [],
'show_organization' => $showOrganization,
'terms_page_url' => $this->resolveTermsPageUrl($model),
]);
$form->handleRequest($request);
if ($form->isSubmitted()) {
$submittedData = (array) $form->getData();
if (!empty($submittedData['addImage']) && '' === trim((string) ($submittedData['photographer'] ?? ''))) {
$form->get('photographer')->addError(new FormError('Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.'));
}
if (!empty($submittedData['addTime']) && '' === trim((string) ($submittedData['startTime'] ?? ''))) {
$form->get('startTime')->addError(new FormError('Bitte geben Sie eine Startzeit an.'));
}
}
if ($form->isSubmitted() && $form->isValid()) {
$submittedData = (array) $form->getData();
$selectedOrganizationIds = [];
if ($showOrganization) {
$selectedOrganizationIds = array_map('intval', (array) $form->get('organization_ids')->getData());
} elseif ([] !== $memberOrganizationIds) {
$selectedOrganizationIds = [(int) $memberOrganizationIds[0]];
}
$allowedOrganizationIds = array_values(array_unique(array_map('intval', $memberOrganizationIds)));
$selectedOrganizationIds = array_values(array_intersect($selectedOrganizationIds, $allowedOrganizationIds));
if ([] === $selectedOrganizationIds) {
if ($showOrganization) {
$form->get('organization_ids')->addError(new FormError('Bitte mindestens eine Organisation auswählen.'));
} else {
$form->addError(new FormError('Keine gültige Veranstalter-Zuordnung vorhanden.'));
}
}
if ($form->isSubmitted() && !$form->isValid()) {
$template->set('form', $form->createView());
$template->set('backUrl', $backUrl);
$template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue());
$template->set('currentImagePath', $currentImagePath);
return $template->getResponse();
}
if ($isCreateMode) {
$createdEventId = $this->eventRepository->createForMember(
(int) $user->id,
(int) ($model->frontendAuthorId ?? 0),
(int) ($model->frontendArchiveId ?? 0),
$submittedData,
$selectedOrganizationIds,
);
if (null === $createdEventId) {
$form->addError(new FormError('Die Veranstaltung konnte nicht erstellt werden.'));
$template->set('form', $form->createView());
$template->set('backUrl', $backUrl);
$template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue());
$template->set('currentImagePath', $currentImagePath);
return $template->getResponse();
}
$eventId = $createdEventId;
} else {
if ($showOrganization) {
$this->eventRepository->assignEventToOrganizations($eventId, $selectedOrganizationIds);
}
$this->eventRepository->update($eventId, $submittedData);
}
$useImage = !empty($submittedData['addImage']);
$removeImage = '1' === (string) $request->request->get('remove_image', '0');
$uploadedImage = $form->get('eventUpload')->getData();
if (!$useImage) {
$this->eventRepository->updateImageFields($eventId, false, null);
} elseif ($uploadedImage instanceof UploadedFile) {
$newImageUuid = $this->storeEventImage($uploadedImage, $model, $eventId);
if (null !== $newImageUuid) {
$this->eventRepository->updateImageFields($eventId, true, $newImageUuid);
}
} elseif ($removeImage) {
$this->eventRepository->updateImageFields($eventId, true, null);
}
if ($request->request->has('save_back')) {
return new RedirectResponse($backUrl);
}
if ($isCreateMode) {
return new RedirectResponse($this->buildEventEditUrl($eventId, $backUrl));
}
return new RedirectResponse($request->getUri());
}
$template->set('form', $form->createView());
$template->set('backUrl', $backUrl);
$template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue());
$template->set('currentImagePath', $currentImagePath);
return $template->getResponse();
}
private function storeEventImage(UploadedFile $uploadedFile, ModuleModel $model, int $eventId): ?string
{
if (empty($model->eventFolder)) {
return null;
}
$folderModel = FilesModel::findByUuid((string) $model->eventFolder);
if (null === $folderModel) {
return null;
}
$organization = $this->eventRepository->findPrimaryOrganizationForEvent($eventId);
if (null === $organization) {
return null;
}
$titleSlug = strtolower(StringUtil::generateAlias((string) $organization['title']));
$titleSlug = preg_replace('/[^a-z0-9-]+/', '-', $titleSlug ?? '') ?? '';
$titleSlug = trim($titleSlug, '-');
$titleSlug = substr($titleSlug, 0, 12);
if ('' === $titleSlug) {
$titleSlug = 'organization';
}
$uploadBasePath = trim((string) $folderModel->path, '/');
if ('' === $uploadBasePath) {
return null;
}
$targetRelativeDir = sprintf('%s/org-%d-%s', $uploadBasePath, (int) $organization['id'], $titleSlug);
$projectDir = (string) $this->getParameter('kernel.project_dir');
$targetAbsoluteDir = rtrim($projectDir, '/').'/'.$targetRelativeDir;
if (!is_dir($targetAbsoluteDir) && !mkdir($targetAbsoluteDir, 0775, true) && !is_dir($targetAbsoluteDir)) {
return null;
}
$originalName = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = preg_replace('/[^a-zA-Z0-9_-]+/', '-', (string) $originalName);
$safeName = trim((string) $safeName, '-');
if ('' === $safeName) {
$safeName = 'event-image';
}
$extension = $uploadedFile->guessExtension() ?: $uploadedFile->getClientOriginalExtension() ?: 'bin';
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), strtolower((string) $extension));
$uploadedFile->move($targetAbsoluteDir, $filename);
$relativePath = $targetRelativeDir.'/'.$filename;
$fileModel = Dbafs::addResource($relativePath);
return null !== $fileModel ? (string) $fileModel->uuid : null;
}
private function resolveBackUrl(Request $request, ModuleModel $model): string
{
if ((int) ($model->listPage ?? 0) > 0) {
$listPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->listPage);
if ($listPage instanceof PageModel) {
return $this->generateContentUrl($listPage);
}
}
$ref = (string) $request->query->get('ref', '');
if ('' !== $ref) {
$decoded = base64_decode($ref, true);
if (false !== $decoded && str_starts_with($decoded, '/')) {
return $decoded;
}
}
$page = $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page) : '/';
}
private function resolveTermsPageUrl(ModuleModel $model): ?string
{
if ((int) ($model->termsPage ?? 0) <= 0) {
return null;
}
$termsPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->termsPage);
if (!$termsPage instanceof PageModel) {
return null;
}
return $this->generateContentUrl($termsPage);
}
private function buildEventEditUrl(int $eventId, string $backUrl): string
{
$page = $this->getPageModel();
if (!$page instanceof PageModel) {
return '/';
}
return $this->generateContentUrl($page, [
'event' => (string) $eventId,
'ref' => base64_encode($backUrl),
]);
}
private function resolveFormEndTime(array $event): string
{
if ('1' !== (string) ($event['addTime'] ?? '')) {
return '';
}
$startTime = (int) ($event['startTime'] ?? 0);
$endTime = (int) ($event['endTime'] ?? 0);
if ($endTime <= 0 || $startTime <= 0) {
return '';
}
if ($endTime === $startTime || $endTime - $startTime === 86399) {
return '';
}
return date('H:i', $endTime);
}
}
@@ -0,0 +1,133 @@
<?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\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Service\EventRepository;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_events', category: 'eventmanager', template: 'frontend/member_events')]
class MemberEventsController extends AbstractFrontendModuleController
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('upcomingEvents', []);
$template->set('pastEvents', []);
$template->set('isEditor', false);
return $template->getResponse();
}
$isEditor = $this->isEditor($user);
$editPage = null;
if ((int) ($model->editPage ?? 0) > 0) {
$editPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->editPage);
}
$currentPage = $this->getPageModel();
$backUrl = $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/';
if ($isEditor && 'POST' === $request->getMethod() && $request->request->has('action')) {
$action = (string) $request->request->get('action', '');
$eventId = (int) $request->request->get('event_id', 0);
if (!in_array($action, ['create', 'toggle_published', 'duplicate', 'delete'], true)) {
return $template->getResponse();
}
if ('create' === $action && $editPage instanceof PageModel) {
return new RedirectResponse($this->generateContentUrl($editPage, [
'create' => '1',
'ref' => base64_encode($backUrl),
]));
}
if ($eventId > 0 && $this->eventRepository->memberHasEvent((int) $user->id, $eventId)) {
if ('toggle_published' === $action) {
$this->eventRepository->togglePublished($eventId);
} elseif ('duplicate' === $action) {
$this->eventRepository->duplicate($eventId);
} elseif ('delete' === $action) {
$this->eventRepository->delete($eventId);
}
}
return new RedirectResponse($request->getUri());
}
$events = $this->eventRepository->findByMemberId((int) $user->id);
$upcomingItems = [];
$pastItems = [];
$today = strtotime('today');
foreach ($events as $event) {
$editUrl = null;
if ($isEditor && $editPage instanceof PageModel) {
$editUrl = $this->generateContentUrl($editPage, [
'event' => (string) $event['id'],
'ref' => base64_encode($backUrl),
]);
}
$item = [
'id' => (int) $event['id'],
'title' => (string) ($event['title'] ?? ''),
'startDate' => (int) ($event['startDate'] ?? 0),
'published' => '1' === (string) ($event['published'] ?? ''),
'editUrl' => $editUrl,
];
if ((int) $item['startDate'] >= $today) {
$upcomingItems[] = $item;
} else {
$pastItems[] = $item;
}
}
usort(
$upcomingItems,
static fn (array $a, array $b): int => ((int) $b['startDate']) <=> ((int) $a['startDate']),
);
usort(
$pastItems,
static fn (array $a, array $b): int => ((int) $b['startDate']) <=> ((int) $a['startDate']),
);
$template->set('upcomingEvents', $upcomingItems);
$template->set('pastEvents', $pastItems);
$template->set('isEditor', $isEditor);
$template->set('canCreateEvent', $isEditor && $editPage instanceof PageModel);
$template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue());
return $template->getResponse();
}
private function isEditor(FrontendUser $user): bool
{
$groups = is_array($user->groups) ? $user->groups : StringUtil::deserialize($user->groups, true);
return in_array(1, array_map('intval', $groups), true);
}
}
@@ -0,0 +1,77 @@
<?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\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Service\OrganizationRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_organizations', category: 'eventmanager', template: 'frontend/member_organizations')]
class MemberOrganizationsController extends AbstractFrontendModuleController
{
public function __construct(
private readonly OrganizationRepository $organizationRepository,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('organizations', []);
return $template->getResponse();
}
$organizations = $this->organizationRepository->findByMemberId((int) $user->id);
$isEditor = $this->isEditor($user);
$editPage = null;
if ((int) ($model->editPage ?? 0) > 0) {
$editPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->editPage);
}
$currentPage = $this->getPageModel();
$backUrl = $currentPage instanceof PageModel ? $this->generateContentUrl($currentPage) : '/';
$items = [];
foreach ($organizations as $organization) {
$editUrl = null;
if ($isEditor && $editPage instanceof PageModel) {
$editUrl = $this->generateContentUrl($editPage, [
'organization' => (string) $organization['id'],
'ref' => base64_encode($backUrl),
]);
}
$items[] = [
'id' => (int) $organization['id'],
'title' => (string) ($organization['title'] ?? ''),
'editUrl' => $editUrl,
];
}
$template->set('organizations', $items);
return $template->getResponse();
}
private function isEditor(FrontendUser $user): bool
{
$groups = is_array($user->groups) ? $user->groups : StringUtil::deserialize($user->groups, true);
return in_array(1, array_map('intval', $groups), true);
}
}
@@ -0,0 +1,212 @@
<?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\Dbafs;
use Contao\FilesModel;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Contao\StringUtil;
use MummertMedia\EventManagerBundle\Form\OrganizationType;
use MummertMedia\EventManagerBundle\Service\OrganizationRepository;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'organization_edit', category: 'eventmanager', template: 'frontend/organization_edit')]
class OrganizationEditController extends AbstractFrontendModuleController
{
public function __construct(
private readonly OrganizationRepository $organizationRepository,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
$backUrl = $this->resolveBackUrl($request, $model);
$organizationParam = $request->query->get('organization');
if (null === $organizationParam) {
$template->set('error', 'Ungültiger Aufruf: Parameter "organization" fehlt.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
if (!$user instanceof FrontendUser) {
$template->set('error', 'Bitte zuerst als Mitglied einloggen.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
$organizationId = (int) $organizationParam;
if ($organizationId <= 0 || !$this->organizationRepository->memberHasOrganization((int) $user->id, $organizationId)) {
$template->set('error', 'Keine Berechtigung für diese Organisation oder ungültige ID.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
$organization = $this->organizationRepository->findById($organizationId);
if (null === $organization) {
$template->set('error', 'Organisation nicht gefunden.');
$template->set('backUrl', $backUrl);
return $template->getResponse();
}
$formData = [
'title' => (string) ($organization['title'] ?? ''),
'street' => (string) ($organization['street'] ?? ''),
'postal' => (string) ($organization['postal'] ?? ''),
'city' => (string) ($organization['city'] ?? ''),
'state' => (string) ($organization['state'] ?? ''),
'country' => (string) ($organization['country'] ?? ''),
'phone' => (string) ($organization['phone'] ?? ''),
'email' => (string) ($organization['email'] ?? ''),
'website' => (string) ($organization['website'] ?? ''),
'description' => (string) ($organization['description'] ?? ''),
'type' => StringUtil::deserialize($organization['type'] ?? null, true),
];
$currentLogoPath = null;
if (!empty($organization['logo'])) {
$logoModel = FilesModel::findByUuid((string) $organization['logo']);
if (null !== $logoModel) {
$currentLogoPath = $logoModel->path;
}
}
$form = $this->createForm(OrganizationType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$submittedData = (array) $form->getData();
$this->organizationRepository->update($organizationId, $submittedData);
$deleteLogo = '1' === (string) $request->request->get('remove_logo', '0');
$uploadedLogo = $form->get('logoUpload')->getData();
if ($uploadedLogo instanceof UploadedFile) {
$newLogoUuid = $this->storeOrganizationLogo(
$uploadedLogo,
$model,
$organizationId,
(string) ($submittedData['title'] ?? ''),
);
if (null !== $newLogoUuid) {
$this->organizationRepository->updateLogo($organizationId, $newLogoUuid);
}
} elseif ($deleteLogo) {
$this->organizationRepository->updateLogo($organizationId, null);
}
if ($request->request->has('save_back')) {
return new RedirectResponse($backUrl);
}
return new RedirectResponse($request->getUri());
}
$template->set('form', $form->createView());
$template->set('backUrl', $backUrl);
$template->set('requestToken', $this->container->get('contao.csrf.token_manager')->getDefaultTokenValue());
$template->set('currentLogoPath', $currentLogoPath);
return $template->getResponse();
}
private function storeOrganizationLogo(UploadedFile $uploadedFile, ModuleModel $model, int $organizationId, string $organizationTitle): ?string
{
if (empty($model->logoFolder)) {
return null;
}
$folderModel = FilesModel::findByUuid((string) $model->logoFolder);
if (null === $folderModel) {
return null;
}
$uploadBasePath = trim((string) $folderModel->path, '/');
if ('' === $uploadBasePath) {
return null;
}
$titleSlug = strtolower(StringUtil::generateAlias($organizationTitle));
$titleSlug = preg_replace('/[^a-z0-9-]+/', '-', $titleSlug ?? '') ?? '';
$titleSlug = trim($titleSlug, '-');
$titleSlug = substr($titleSlug, 0, 12);
if ('' === $titleSlug) {
$titleSlug = 'organization';
}
$targetRelativeDir = sprintf('%s/org-%d-%s', $uploadBasePath, $organizationId, $titleSlug);
$projectDir = (string) $this->getParameter('kernel.project_dir');
$targetAbsoluteDir = rtrim($projectDir, '/').'/'.$targetRelativeDir;
if (!is_dir($targetAbsoluteDir) && !mkdir($targetAbsoluteDir, 0775, true) && !is_dir($targetAbsoluteDir)) {
return null;
}
$originalName = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = preg_replace('/[^a-zA-Z0-9_-]+/', '-', (string) $originalName);
$safeName = trim((string) $safeName, '-');
if ('' === $safeName) {
$safeName = 'logo';
}
$extension = $uploadedFile->guessExtension() ?: $uploadedFile->getClientOriginalExtension() ?: 'bin';
$filename = sprintf('%s-%s.%s', $safeName, uniqid('', true), strtolower((string) $extension));
$uploadedFile->move($targetAbsoluteDir, $filename);
$relativePath = $targetRelativeDir.'/'.$filename;
$fileModel = Dbafs::addResource($relativePath);
return null !== $fileModel ? (string) $fileModel->uuid : null;
}
private function resolveBackUrl(Request $request, ModuleModel $model): string
{
if ((int) ($model->listPage ?? 0) > 0) {
$listPage = $this->getContaoAdapter(PageModel::class)->findById((int) $model->listPage);
if ($listPage instanceof PageModel) {
return $this->generateContentUrl($listPage);
}
}
$ref = (string) $request->query->get('ref', '');
if ('' !== $ref) {
$decoded = base64_decode($ref, true);
if (false !== $decoded && str_starts_with($decoded, '/')) {
return $decoded;
}
}
$page = $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page) : '/';
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class MummertMediaEventManagerExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yaml');
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener\DataContainer;
use Contao\DataContainer;
class SetDateAddedCallback
{
public function onBeforeSubmit(array $values, DataContainer $dc): array
{
if (null !== $dc->activeRecord && (int) ($dc->activeRecord->dateAdded ?? 0) > 0) {
return $values;
}
if (!isset($values['dateAdded']) || 0 === (int) $values['dateAdded']) {
$values['dateAdded'] = time();
}
return $values;
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TimeType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Url;
class EventType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLabel = 'Ich stimme den Nutzungsbedingungen zu.';
if (null !== $options['terms_page_url']) {
$termsLabel = sprintf(
'Ich stimme den <a href="%s" target="_blank" rel="noopener noreferrer">Nutzungsbedingungen</a> zu.',
htmlspecialchars($options['terms_page_url'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
);
}
$showOrganizationSelect = true === $options['show_organization'];
$builder
->add('title', TextType::class, [
'label' => 'Titel',
'required' => true,
])
->add('startDate', DateType::class, [
'label' => 'Startdatum',
'required' => false,
'widget' => 'single_text',
'input' => 'string',
'html5' => false,
'format' => 'yyyy-MM-dd',
'attr' => [
'class' => 'js-flatpickr-date',
],
])
->add('endDate', DateType::class, [
'label' => 'Enddatum',
'required' => false,
'widget' => 'single_text',
'input' => 'string',
'html5' => false,
'format' => 'yyyy-MM-dd',
'help' => 'Lassen Sie das Feld leer, um ein eintägiges Event zu erstellen.',
'attr' => [
'class' => 'js-flatpickr-date',
],
])
->add('addTime', CheckboxType::class, [
'label' => 'Dem Event eine Start- und Endzeit hinzufügen.',
'required' => false,
'attr' => [
'class' => 'js-add-time-toggle',
],
])
->add('startTime', TimeType::class, [
'label' => 'Startzeit',
'required' => false,
'input' => 'string',
'widget' => 'single_text',
'html5' => false,
'input_format' => 'H:i',
'attr' => [
'class' => 'js-flatpickr-time',
],
])
->add('endTime', TimeType::class, [
'label' => 'Endzeit',
'required' => false,
'input' => 'string',
'widget' => 'single_text',
'html5' => false,
'input_format' => 'H:i',
'help' => 'Lassen Sie das Feld leer, um ein Event mit offenem Ende zu erstellen.',
'attr' => [
'class' => 'js-flatpickr-time',
],
]);
if ($showOrganizationSelect) {
$builder->add('organization_ids', ChoiceType::class, [
'label' => 'Veranstalter',
'choices' => $options['organization_choices'],
'required' => true,
'mapped' => false,
'multiple' => true,
'data' => $options['selected_organization_ids'],
'attr' => [
'class' => 'js-organization-choice',
'data-placeholder' => 'Veranstalter suchen …',
],
]);
}
$builder
->add('location_id', ChoiceType::class, [
'label' => 'Veranstaltungsort',
'choices' => $options['location_choices'],
'required' => false,
'choice_value' => static fn ($value) => null !== $value ? (string) $value : '',
'placeholder' => 'Bitte auswählen',
'attr' => [
'class' => 'js-location-choice',
'data-placeholder' => 'Veranstaltungsort suchen …',
],
])
->add('type', ChoiceType::class, [
'label' => 'Typ',
'choices' => [
'Unterkunft' => 'accommodation',
'Einkaufen' => 'shopping',
'Kultur' => 'culture',
],
'multiple' => true,
'required' => false,
'attr' => [
'class' => 'js-event-type-choice',
'data-placeholder' => 'Typen suchen …',
],
])
->add('teaser', TextareaType::class, ['label' => 'Teaser', 'required' => false])
->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('url', UrlType::class, [
'label' => 'Link (extern)',
'required' => false,
'default_protocol' => 'https',
'invalid_message' => 'Bitte eine gültige URL mit http:// oder https:// eingeben (z. B. https://example.org).',
'constraints' => [
new Url([
'protocols' => ['http', 'https'],
'message' => 'Bitte eine gültige URL mit http:// oder https:// eingeben (z. B. https://example.org).',
]),
],
])
->add('addImage', CheckboxType::class, [
'label' => 'Dem Event ein Bild hinzufügen',
'required' => false,
'attr' => [
'class' => 'js-add-image-toggle',
],
])
->add('eventUpload', FileType::class, [
'label' => 'Bild hochladen',
'required' => false,
'mapped' => false,
'attr' => [
'class' => 'js-event-image-upload',
'accept' => 'image/*',
],
])
->add('photographer', TextType::class, [
'label' => 'Urheber/Fotograf',
'required' => false,
'attr' => [
'class' => 'js-photographer',
'maxlength' => '255',
],
])
->add('termsAccepted', CheckboxType::class, [
'label' => $termsLabel,
'label_html' => true,
'required' => true,
])
->add('isSoldOut', CheckboxType::class, [
'label' => 'Diese Veranstaltung ist ausverkauft.',
'required' => false,
])
->add('isCanceled', CheckboxType::class, [
'label' => 'Diese Veranstaltung wurde abgesagt.',
'required' => false,
])
->add('published', CheckboxType::class, ['label' => 'Veröffentlicht', 'required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_protection' => true,
'location_choices' => [],
'organization_choices' => [],
'selected_organization_ids' => [],
'show_organization' => false,
'terms_page_url' => null,
]);
$resolver->setAllowedTypes('location_choices', 'array');
$resolver->setAllowedTypes('organization_choices', 'array');
$resolver->setAllowedTypes('selected_organization_ids', 'array');
$resolver->setAllowedTypes('show_organization', 'bool');
$resolver->setAllowedTypes('terms_page_url', ['null', 'string']);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class OrganizationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, ['label' => 'Titel', 'required' => true])
->add('street', TextType::class, ['label' => 'Straße', 'required' => false])
->add('postal', TextType::class, ['label' => 'PLZ', 'required' => false])
->add('city', TextType::class, ['label' => 'Ort', 'required' => false])
->add('state', TextType::class, ['label' => 'Bundesland', 'required' => false])
->add('country', TextType::class, ['label' => 'Land', 'required' => false])
->add('phone', TextType::class, ['label' => 'Telefon', 'required' => false])
->add('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',
],
'multiple' => true,
'required' => false,
'attr' => [
'class' => 'js-organization-type-choice',
'data-placeholder' => 'Typ auswählen …',
],
])
->add('logoUpload', FileType::class, [
'label' => 'Logo hochladen',
'required' => false,
'mapped' => false,
'attr' => [
'class' => 'js-logo-upload',
'accept' => 'image/*',
],
]);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Model;
use Contao\Model;
class LocationModel extends Model
{
protected static $strTable = 'tl_location';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Model;
use Contao\Model;
class OrganizationModel extends Model
{
protected static $strTable = 'tl_organization';
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MummertMediaEventManagerBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}
+619
View File
@@ -0,0 +1,619 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Service;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
class EventRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function findByMemberId(int $memberId): array
{
return $this->connection->createQueryBuilder()
->select('DISTINCT e.*')
->from('tl_calendar_events', 'e')
->innerJoin('e', 'tl_calendar_events_organization', 'ceo', 'ceo.event_id = e.id')
->innerJoin('ceo', 'tl_member_organization', 'mo', 'mo.organization_id = ceo.organization_id')
->where('mo.member_id = :memberId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->orderBy('e.startDate', 'DESC')
->executeQuery()
->fetchAllAssociative();
}
public function memberHasEvent(int $memberId, int $eventId): bool
{
$exists = $this->connection->createQueryBuilder()
->select('1')
->from('tl_calendar_events', 'e')
->innerJoin('e', 'tl_calendar_events_organization', 'ceo', 'ceo.event_id = e.id')
->innerJoin('ceo', 'tl_member_organization', 'mo', 'mo.organization_id = ceo.organization_id')
->where('e.id = :eventId')
->andWhere('mo.member_id = :memberId')
->setParameter('eventId', $eventId, ParameterType::INTEGER)
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchOne();
return false !== $exists;
}
public function togglePublished(int $eventId): void
{
$published = $this->connection->createQueryBuilder()
->select('published')
->from('tl_calendar_events')
->where('id = :id')
->setParameter('id', $eventId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchOne();
$isPublished = '1' === (string) $published;
$this->connection->update(
'tl_calendar_events',
[
'published' => $isPublished ? 0 : 1,
'tstamp' => time(),
],
['id' => $eventId],
['id' => ParameterType::INTEGER],
);
}
public function duplicate(int $eventId): ?int
{
$row = $this->findById($eventId);
if (null === $row) {
return null;
}
unset($row['id']);
if (array_key_exists('title', $row)) {
$row['title'] = trim((string) $row['title'].' (Kopie)');
}
if (array_key_exists('alias', $row)) {
$row['alias'] = '';
}
if (array_key_exists('published', $row)) {
$row['published'] = 0;
}
$now = time();
if (array_key_exists('dateAdded', $row)) {
$row['dateAdded'] = $now;
}
if (array_key_exists('tstamp', $row)) {
$row['tstamp'] = $now;
}
$this->connection->insert('tl_calendar_events', $row);
$newEventId = (int) $this->connection->lastInsertId();
$organizationIds = $this->connection->createQueryBuilder()
->select('organization_id')
->from('tl_calendar_events_organization')
->where('event_id = :eventId')
->setParameter('eventId', $eventId, ParameterType::INTEGER)
->executeQuery()
->fetchFirstColumn();
foreach ($organizationIds as $organizationId) {
$this->connection->insert(
'tl_calendar_events_organization',
[
'event_id' => $newEventId,
'organization_id' => (int) $organizationId,
],
[
'event_id' => ParameterType::INTEGER,
'organization_id' => ParameterType::INTEGER,
],
);
}
return $newEventId;
}
public function createDraftForMember(int $memberId, int $authorId, int $archiveId): ?int
{
$resolvedArchiveId = $this->resolveArchiveId($archiveId);
if ($resolvedArchiveId <= 0) {
return null;
}
$organizationId = $this->connection->createQueryBuilder()
->select('organization_id')
->from('tl_member_organization')
->where('member_id = :memberId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->orderBy('organization_id', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchOne();
if (false === $organizationId) {
return null;
}
$now = time();
$this->connection->insert(
'tl_calendar_events',
[
'pid' => $resolvedArchiveId,
'author' => max(0, $authorId),
'title' => '',
'alias' => '',
'startDate' => 0,
'endDate' => null,
'published' => 0,
'tstamp' => $now,
],
[
'pid' => ParameterType::INTEGER,
'author' => ParameterType::INTEGER,
],
);
$eventId = (int) $this->connection->lastInsertId();
if ($eventId <= 0) {
return null;
}
$this->connection->insert(
'tl_calendar_events_organization',
[
'tstamp' => $now,
'event_id' => $eventId,
'organization_id' => (int) $organizationId,
],
[
'event_id' => ParameterType::INTEGER,
'organization_id' => ParameterType::INTEGER,
],
);
return $eventId;
}
/** @return array{authorId:int,archiveId:int} */
public function findCreationDefaultsByListPage(int $listPageId): array
{
if ($listPageId <= 0) {
return ['authorId' => 0, 'archiveId' => 0];
}
$row = $this->connection->createQueryBuilder()
->select('frontendAuthorId', 'frontendArchiveId')
->from('tl_module')
->where('type = :type')
->andWhere('listPage = :listPage')
->setParameter('type', 'event_edit')
->setParameter('listPage', $listPageId, ParameterType::INTEGER)
->orderBy('id', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (false === $row || null === $row) {
return ['authorId' => 0, 'archiveId' => 0];
}
return [
'authorId' => (int) ($row['frontendAuthorId'] ?? 0),
'archiveId' => (int) ($row['frontendArchiveId'] ?? 0),
];
}
private function resolveArchiveId(int $archiveId): int
{
if ($archiveId > 0) {
$exists = $this->connection->createQueryBuilder()
->select('id')
->from('tl_calendar')
->where('id = :id')
->setParameter('id', $archiveId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchOne();
if (false !== $exists) {
return $archiveId;
}
}
$fallback = $this->connection->createQueryBuilder()
->select('id')
->from('tl_calendar')
->orderBy('id', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchOne();
return false !== $fallback ? (int) $fallback : 0;
}
public function delete(int $eventId): void
{
$this->connection->delete(
'tl_calendar_events_organization',
['event_id' => $eventId],
['event_id' => ParameterType::INTEGER],
);
$this->connection->delete(
'tl_calendar_events',
['id' => $eventId],
['id' => ParameterType::INTEGER],
);
}
/** @return array<string, mixed>|null */
public function findById(int $eventId): ?array
{
$row = $this->connection->createQueryBuilder()
->select('*')
->from('tl_calendar_events')
->where('id = :id')
->setParameter('id', $eventId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return false === $row ? null : $row;
}
/** @return array{id:int,title:string}|null */
public function findPrimaryOrganizationForEvent(int $eventId): ?array
{
$row = $this->connection->createQueryBuilder()
->select('o.id', 'o.title')
->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')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (false === $row || null === $row) {
return null;
}
return [
'id' => (int) $row['id'],
'title' => (string) ($row['title'] ?? ''),
];
}
/** @return array<int> */
public function getOrganizationIdsForEvent(int $eventId): array
{
$rows = $this->connection->createQueryBuilder()
->select('organization_id')
->from('tl_calendar_events_organization')
->where('event_id = :eventId')
->setParameter('eventId', $eventId, ParameterType::INTEGER)
->orderBy('organization_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $rows)));
}
/**
* @return array<string, int>
*/
public function getLocationChoices(): array
{
$rows = $this->connection->createQueryBuilder()
->select('id', 'title')
->from('tl_location')
->where('published = :published')
->setParameter('published', '1')
->orderBy('title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$choices = [];
foreach ($rows as $row) {
$choices[(string) $row['title']] = (int) $row['id'];
}
return $choices;
}
/**
* @return array<string, int>
*/
public function getOrganizationChoicesForMember(int $memberId): array
{
$rows = $this->connection->createQueryBuilder()
->select('o.id', 'o.title')
->from('tl_member_organization', 'mo')
->innerJoin('mo', 'tl_organization', 'o', 'o.id = mo.organization_id')
->where('mo.member_id = :memberId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->orderBy('o.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$choices = [];
$seenLabels = [];
foreach ($rows as $row) {
$id = (int) $row['id'];
$label = trim((string) ($row['title'] ?? ''));
if ('' === $label) {
$label = 'Organisation '.$id;
}
if (isset($seenLabels[$label])) {
$label .= ' (#'.$id.')';
}
$seenLabels[$label] = true;
$choices[$label] = $id;
}
return $choices;
}
/** @return array<int> */
public function getOrganizationIdsForMember(int $memberId): array
{
$ids = $this->connection->createQueryBuilder()
->select('organization_id')
->from('tl_member_organization')
->where('member_id = :memberId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->orderBy('organization_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $ids)));
}
/** @param array<int> $organizationIds */
public function assignEventToOrganizations(int $eventId, array $organizationIds): void
{
$this->connection->delete(
'tl_calendar_events_organization',
['event_id' => $eventId],
['event_id' => ParameterType::INTEGER],
);
$organizationIds = array_values(array_unique(array_map('intval', $organizationIds)));
foreach ($organizationIds as $organizationId) {
if ($organizationId <= 0) {
continue;
}
$this->connection->insert(
'tl_calendar_events_organization',
[
'tstamp' => time(),
'event_id' => $eventId,
'organization_id' => $organizationId,
],
[
'event_id' => ParameterType::INTEGER,
'organization_id' => ParameterType::INTEGER,
],
);
}
}
/**
* @param array<string, mixed> $data
*/
public function update(int $eventId, array $data): void
{
$eventData = $this->buildEventData($data);
$this->connection->update(
'tl_calendar_events',
$eventData,
['id' => $eventId],
[
'id' => ParameterType::INTEGER,
],
);
}
/**
* @param array<string, mixed> $data
* @param array<int> $organizationIds
*/
public function createForMember(int $memberId, int $authorId, int $archiveId, array $data, array $organizationIds): ?int
{
if ($memberId <= 0) {
return null;
}
$resolvedArchiveId = $this->resolveArchiveId($archiveId);
if ($resolvedArchiveId <= 0) {
return null;
}
$eventData = $this->buildEventData($data);
$eventData['pid'] = $resolvedArchiveId;
$eventData['author'] = max(0, $authorId);
$this->connection->insert(
'tl_calendar_events',
$eventData,
[
'pid' => ParameterType::INTEGER,
'author' => ParameterType::INTEGER,
],
);
$eventId = (int) $this->connection->lastInsertId();
if ($eventId <= 0) {
return null;
}
$memberOrganizationIds = $this->getOrganizationIdsForMember($memberId);
$allowedOrganizationIds = array_values(array_intersect(array_map('intval', $organizationIds), array_map('intval', $memberOrganizationIds)));
if ([] === $allowedOrganizationIds) {
$this->connection->delete('tl_calendar_events', ['id' => $eventId], ['id' => ParameterType::INTEGER]);
return null;
}
$this->assignEventToOrganizations($eventId, $allowedOrganizationIds);
return $eventId;
}
/**
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function buildEventData(array $data): array
{
$startDate = !empty($data['startDate']) ? strtotime((string) $data['startDate']) : false;
$endDate = !empty($data['endDate']) ? strtotime((string) $data['endDate']) : false;
$url = trim((string) ($data['url'] ?? ''));
$addTime = !empty($data['addTime']);
$alias = $this->buildAlias($startDate, (string) ($data['title'] ?? ''));
$startDateTimestamp = false !== $startDate ? (int) $startDate : 0;
$endDateTimestamp = false !== $endDate ? (int) $endDate : 0;
$timeBaseTimestamp = $endDateTimestamp > 0 ? $endDateTimestamp : $startDateTimestamp;
$startTimeTimestamp = $startDateTimestamp;
$endTimeTimestamp = $timeBaseTimestamp > 0 ? $timeBaseTimestamp + 86399 : 0;
if ($addTime) {
$startTimeCandidate = $this->combineDateAndTime($startDateTimestamp, (string) ($data['startTime'] ?? ''));
$startTimeTimestamp = null !== $startTimeCandidate ? $startTimeCandidate : $startDateTimestamp;
$endTimeCandidate = $this->combineDateAndTime($timeBaseTimestamp, (string) ($data['endTime'] ?? ''));
$endTimeTimestamp = null !== $endTimeCandidate ? $endTimeCandidate : $startTimeTimestamp;
}
return [
'title' => $data['title'] ?? '',
'alias' => $alias,
'startDate' => false !== $startDate ? $startDate : 0,
'endDate' => false !== $endDate ? $endDate : null,
'location_id' => (int) ($data['location_id'] ?? 0),
'addTime' => $addTime ? 1 : 0,
'startTime' => $startTimeTimestamp,
'endTime' => $endTimeTimestamp,
'type' => serialize($data['type'] ?? []),
'teaser' => $data['teaser'] ?? null,
'description' => $data['description'] ?? null,
'url' => $url,
'source' => '' !== $url ? 'external' : 'default',
'photographer' => $data['photographer'] ?? '',
'addImage' => !empty($data['addImage']) ? 1 : 0,
'termsAccepted' => !empty($data['termsAccepted']) ? '1' : '',
'isSoldOut' => !empty($data['isSoldOut']) ? '1' : '',
'isCanceled' => !empty($data['isCanceled']) ? '1' : '',
'published' => !empty($data['published']) ? 1 : 0,
'tstamp' => time(),
];
}
private function buildAlias(false|int $startDate, string $title): string
{
if (false === $startDate || $startDate <= 0) {
return '';
}
$normalizedTitle = trim($title);
if ('' === $normalizedTitle) {
return '';
}
$normalizedTitle = strtolower($normalizedTitle);
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalizedTitle);
if (false !== $transliterated) {
$normalizedTitle = strtolower($transliterated);
}
$normalizedTitle = preg_replace('/[^a-z0-9]+/', '', $normalizedTitle) ?? '';
$normalizedTitle = substr($normalizedTitle, 0, 20);
if ('' === $normalizedTitle) {
return '';
}
return date('Y-m-d', $startDate).'_'.$normalizedTitle;
}
private function combineDateAndTime(int $dateTimestamp, string $time): ?int
{
if ($dateTimestamp <= 0) {
return null;
}
$normalizedTime = trim($time);
if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $normalizedTime, $matches)) {
return null;
}
$hours = (int) $matches[1];
$minutes = (int) $matches[2];
return $dateTimestamp + ($hours * 3600) + ($minutes * 60);
}
public function updateImageFields(int $eventId, bool $addImage, ?string $singleSrcUuid): void
{
$types = [
'id' => ParameterType::INTEGER,
];
if (null !== $singleSrcUuid) {
$types['singleSRC'] = ParameterType::BINARY;
}
$this->connection->update(
'tl_calendar_events',
[
'addImage' => $addImage ? 1 : 0,
'singleSRC' => $singleSrcUuid,
'tstamp' => time(),
],
['id' => $eventId],
$types,
);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Service;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
class OrganizationRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function findByMemberId(int $memberId): array
{
return $this->connection->createQueryBuilder()
->select('o.*')
->from('tl_organization', 'o')
->innerJoin('o', 'tl_member_organization', 'mo', 'mo.organization_id = o.id')
->where('mo.member_id = :memberId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->orderBy('o.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
public function memberHasOrganization(int $memberId, int $organizationId): bool
{
$exists = $this->connection->createQueryBuilder()
->select('1')
->from('tl_member_organization', 'mo')
->where('mo.member_id = :memberId')
->andWhere('mo.organization_id = :organizationId')
->setParameter('memberId', $memberId, ParameterType::INTEGER)
->setParameter('organizationId', $organizationId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchOne();
return false !== $exists;
}
/** @return array<string, mixed>|null */
public function findById(int $organizationId): ?array
{
$row = $this->connection->createQueryBuilder()
->select('*')
->from('tl_organization')
->where('id = :id')
->setParameter('id', $organizationId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return false === $row ? null : $row;
}
/**
* @param array<string, mixed> $data
*/
public function update(int $organizationId, array $data): void
{
$this->connection->update(
'tl_organization',
[
'title' => $data['title'] ?? '',
'street' => $data['street'] ?? '',
'postal' => $data['postal'] ?? '',
'city' => $data['city'] ?? '',
'state' => $data['state'] ?? '',
'country' => $data['country'] ?? '',
'phone' => $data['phone'] ?? '',
'email' => $data['email'] ?? '',
'website' => $data['website'] ?? '',
'description' => $data['description'] ?? null,
'type' => serialize($data['type'] ?? []),
'tstamp' => time(),
],
['id' => $organizationId],
[
'id' => ParameterType::INTEGER,
],
);
}
public function updateLogo(int $organizationId, ?string $logoUuid): void
{
$types = [
'id' => ParameterType::INTEGER,
];
if (null !== $logoUuid) {
$types['logo'] = ParameterType::BINARY;
}
$this->connection->update(
'tl_organization',
[
'logo' => $logoUuid,
'tstamp' => time(),
],
['id' => $organizationId],
$types,
);
}
}