C3: Tutorial Extension Entwicklung 1.Teil: Grundlagen

Aus Contao Community Documentation


Einleitung

Seit 2010 arbeite ich nun schon mit Contao. Vorher habe ich die CMS Joomla, Typo3 und Drupal ausprobiert und muss sagen: Contao rocks! Leider hat das CMS aus meiner Sicht jedoch einen entscheidenden Nachteil gegenüber anderen Systemen: Es gibt nur wenige Tutorials, die "semiprofessionellen" Programmierern einen schrittweisen und zusammenhängenden Einstieg in die Extension-Entwicklung mit Contao 3 anhand eines Praxisbeispiels ermöglichen.

Die Screencast-Reihe von Tristan Lins auf Youtube stellt dabei eine Ausnahme dar. Ich persönlich mag es aber, Tutorials auch in geschriebener Form vor mir zu haben. Daher habe ich damit begonnen, die Videos in dieses Wiki zu übertragen. Viel Spaß damit!

Videolink: Basics - der Contao Aufbau, DCA

An wen richtet sich dieses Tutorial?

Dieses Tutorial ist für all diejenigen gedacht, die ein Step-by-Step Tutorial zum Einstieg in die Extension-Entwicklung mit Contao 3 suchen.

Lernziel

In diesem Tutorial lernt ihr die Grundlagen der Extension-Entwicklung mit Contao 3 anhand eines praktischen Beispiels. Es wird ein Modul programmiert, welches verschiedene Screencast-URLs inkl. Titel auflistet.

So sieht das fertige Modul aus:

Fertiges Modul

Was man wissen sollte

Grundlagen in PHP, objektorientierter Programmierung und SQL sind nötig. Natürlich sollte man sich auch mit den Grundlagen von Contao 3 auskennen.

Vorbereitung

Eine Version von Contao 3.0.4 oder höher sollte installiert sein. Optional: Da die Extension in das bestehende „Music Academy“-Theme integriert wird, empfiehlt es sich, selbiges zu installieren. Ferner sollte folgende Ordnerstruktur in contao/system/modules angelegt werden:

Verzeichnisstruktur der Erweiterung

Zum Verständnis: Im Verzeichnis modules liegen die Module, die bereits von Contao mitgeliefert werden, wie z.B. FAQ, Calendar etc. Auch unser Erweiterungsmodul 'screencast' gehört in dieses Verzeichnis.

Source-Code

Den Quellcode könnt ihr hier: https://github.com/bit3/contao-screencast3 herunterladen.

Hinweis.png Hinweis: Es kann sein, dass der Quellcode auf github bereits einer fortgeschritteneren Version entspricht. Passt die Dateien daher ggf. dem Code dieses Tutorials an oder (am besten) programmiert alles selber!


Einen Menüeintrag im Inhaltsbereich der Backend-Module erzeugen.

  • Erstellt im Verzeichnis config die Datei config.php.
    In der config.php werden z.B. Frontend- und Backend-Module registriert.
  • Schreibt in die config.php folgenden Code:
<?php
 
/**
 * Back end modules
 */
$GLOBALS['BE_MOD']['content']['screencast'] = array(
	'tables' => array('tl_screencast'),
	'icon'   => 'system/modules/screencast/assets/images/screencast.png'
);

Dabei steht ['BE_MOD']['content'] für die Elemente der Backend-Module im Inhaltsbereich (Array). Durch die Erweiterung des Arrays um ['screencast'] wird unser neues Modul referenziert. Als Wert wird dem Array ein assoziatives Array mit zwei Einträgen (Verweis auf die Tabelle in der Datenbank, sowie auf die Bilddatei) übergeben.

  • Fügt in den Ordner screencast/assets/images ein Icon für euer Modul ein.
  • Da das assets-Verzeichnis geschützt ist, müsst ihr noch eine .htaccess Datei in screencast/assets/ speichern. Darin muss stehen:
<IfModule !mod_authz_core.c>
  Order allow,deny
  Allow from all
</IfModule>
<IfModule mod_authz_core.c>
  Require all granted
</IfModule>

Achtung: Hier besteht eine Abweichung vom Screencast, da ab Apache-Version 2.4 die Anweisung anders lauten muss.

Nun soll in der Auflistung der Elemente im Backend-Modul auch eine entsprechende Übersetzung ausgegeben werden.

  • Erstellt hierzu ein Verzeichnis de in eurem Verzeichnis screencast/languages/
  • Erstellt in diesem Verzeichnis eine Datei modules.php
  • In diese Datei gebt ihr folgenden Code ein:
<?php
 
$GLOBALS['TL_LANG']['MOD']['screencast'][0] = 'Screencasts';
$GLOBALS['TL_LANG']['MOD']['screencast'][1] = 'Screencasts verwalten';
  • Öffnet eure Contao-Backend und navigiert dort zu System->Einstellungen
  • Setzt dort einen Haken unter den Sicherheitseinstellungen->Internen Cache (ab v 3.1. Globale Einstellungen->Internen Cache umgehen) umgehen. Und
    Sicherheitseinstellungen->Fehlermeldungen anzeigen.
  • Eure Seitenleiste sollte nach einer Aktualisierung der Seite nun so aussehen:

Die Backend-Module sind nun um den Eintrag "Screencasts" erweitert

Heureka! Der Eintrag erscheint in der Liste. Leider wirft Contao jedoch nach Klick auf Screencast noch eine Fehlermeldung.

Was fehlt? Contao kann noch kein DCA-Objekt erstellen.

Was macht ein DCA?

"Data Container Arrays (DCAs) dienen zur Speicherung von Tabellen-Metadaten. Jedes DCA beschreibt die Konfiguration einer bestimmten Tabelle, ihre Beziehungen zu anderen Tabellen sowie die einzelnen Felder. Die Contao Core-Engine erkennt anhand dieser Metadaten, wie Datensätze aufgelistet, bearbeitet und gespeichert werden."

(Quelle: https://contao.org/de/manual/3.0/data-container-arrays.html, Stand: 6.4.13)


Achtung.png Achtung: Momentan gibt es in Abhängigkeit der Version mehrere DCA-Dokus. Schaut daher bitte vorher, welche Doku für eure Contao Version zutrifft.


Wie ist ein DCA aufgebaut?

"Ein Data Container Array ist in 6 Sektionen unterteilt. Die erste Sektion speichert globale Informationen wie z.B. Relationen zu anderen Tabellen. Die zweite und dritte Sektion legt fest, wie Datensätze aufgelistet werden und welche Aktionen ein Benutzer ausführen kann. Die vierte Sektion definiert verschiedene Gruppen von Eingabefelder (Paletten) und die letzten beiden Sektionen beschreiben die Eingabefelder im Detail."

(Quelle: https://contao.org/de/manual/3.0/data-container-arrays.html, Stand: 6.4.13)

Neu in Contao 3: In der DCA-Datei werden auch die SQL-Befehle eingetragen. Eine separate Datei ist nicht mehr notwendig.

Alles klar? Nicht? Ok...dann Schritt für Schritt.

Das Formular zur Eintragung der Daten erstellen, sowie die Datenbank-Tabelle anlegen.

Erstellt im Verzeichnis screencast/dca/ eine Datei mit Namen tl_screencast.php.

Die Tabellenkonfiguartion

Anmerkung: Ab jetzt ist "Mut zur Lücke" gefordert. Einige Sachverhalte sind mir in dem Screencast nicht ganz klar geworden. Leider ist auch die Dokumentation auf Contao.org in diesem Bereich noch recht rudimentär. Ich schreib daher auf, was ich verstanden habe.

Auf der Contao-Seite kann man sich über die möglichen Einträge in den DCA informieren. https://contao.org/de/manual/3.0/data-container-arrays.html

Leider ist hier derzeit noch nicht aufgeführt, welchem Array-Abschnitt welche Einträge zugeordnet sind. Tristan empfiehlt, sich die Verwendungsmöglichkeiten in bestehenden Modulen abzugucken. Für die nachfolgenden Erklärungen empfiehlt es sich jedoch trotzdem, den DCA-Teil der Online-Doku aufzufrufen, um scih über die Bedeutung der Arrayeinträge zu informieren.

Wer nicht alles selber schreiben möchte, kopiert sich nun erstmal den gesamten Inhalt der Datei sytem/modules/core/dca/tl_theme.php in die tl_screencast.php und modifiziert diese nur entsprechend der folgenden Beispiele.

  • Schreibt in die Datei tl_screencast.php folgende Zeilen Code:
<?php
 
 
/**
 * Table tl_screencast
 */
$GLOBALS['TL_DCA']['tl_screencast'] = array
(
 
	// Config
	'config'   => array
	(
		'dataContainer'    => 'Table',
		'enableVersioning' => true,
		'sql'              => array
		(
			'keys' => array
			(
				'id' => 'primary'
			)
		),
	),

Was passiert hier?

In diesem Abschnitt legen wir die Grundeinstellungen für unser Modul fest, so z.B. woher die Daten kommen. Ergänzt die Datei mit folgendem Code (Video: 17:47):

// List
	'list'     => array
	(
		'sorting'           => array
		(
			'mode'        => 2,
			'fields'      => array('title'),
			'flag'        => 1,
			'panelLayout' => 'filter;sort,search,limit'
		),

Was passiert hier?

Im diesem Abschnitt wird festgelegt, wie die Datensätze aufgelistet werden und welche Sortieroptionen dem Nutzer zur Verfügung stehen sollen. So steht panelLayout z.B. dafür, welche Optionen in der Kopzeile erscheinen sollen. Semiokolon oder Komma stehen dafür, ob das Layout (ein- oder mehrzeilig). Fragt mich aber nicht, warum filter in Tristans Beispiel nicht auftaucht.

Die Elemente des Panel Layouts

Schaut bzgl. der anderen Optionen der Online-Doku unter "Datensätze auflisten" nach.

  • Ergänzt die Datei mit folgendem Code (Video: 20:45):
'label'             => array
		(
			'fields' => array('title'),
			'format' => '%s',
		),

Was passiert hier?

Hier werden die Bezeichnungen gesetzt, die später in der Listenansicht erscheinen sollen. In unserem Fall also immer der Titel der Screencasts.

Die Titel der Screencasts

  • Ergänzt die Datei mit folgendem Code (Video: 21:25):
'global_operations' => array
		(
			'all' => array
			(
				'label'      => &$GLOBALS['TL_LANG']['MSC']['all'],
				'href'       => 'act=select',
				'class'      => 'header_edit_all',
				'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"'
			)
		),

Was passiert hier?

Bei den 'global operations' handelt es sich um die Bearbeitungsfelder unterhalb der Filterfelder.

Die Global Operations

  • Ergänzt die Datei mit folgendem Code (Video: 21:44):
'operations'        => array
		(
			'edit'   => array
			(
				'label' => &$GLOBALS['TL_LANG']['tl_screencast']['edit'],
				'href'  => 'act=edit',
				'icon'  => 'edit.gif'
			),
			'delete' => array
			(
				'label'      => &$GLOBALS['TL_LANG']['tl_screencast']['delete'],
				'href'       => 'act=delete',
				'icon'       => 'delete.gif',
				'attributes' => 'onclick="if(!confirm(\'' . $GLOBALS['TL_LANG']['MSC']['deleteConfirm'] . '\'))return false;Backend.getScrollOffset()"'
			),
			'show'   => array
			(
				'label'      => &$GLOBALS['TL_LANG']['tl_screencast']['show'],
				'href'       => 'act=show',
				'icon'       => 'show.gif',
				'attributes' => 'style="margin-right:3px"'
			),
		)
	),

Was passiert hier?

Bei den 'operations' handelt es sich um die Bearbeitungsfelder zu jedem Listeneintrag.

Die Operations

Nun kommen wir zum eigentlichen Herzstück einer jeden Erweiterung: Das BE-Formular mit den Eingabefeldern. Im DCA wird dies definiert unter dem Bereich palettes.

  • Ergänzt die Datei mit folgendem Code (Video: 22:55):
// Palettes
	'palettes' => array
	(
		'default'       => '{title_legend},type,title,{screencast_legend},url'
),

Was passiert hier?

Die Grobstruktur des Formulars wird angelegt, also das, was später dem BE-User zur Dateneingabe präsentiert wird. Dabei stehen in den geschweifen Klammern immer die Abschnittsnamen. Es folgen die Felder. Ein Semikolon leitet einen neuen Abschnitt ein.

Das BE Formular des Screencast Moduls


  • Ergänzt die Datei mit folgendem Code (Video: 24:30)
// Fields
	'fields'   => array
	(
		'id'     => array
		(
			'sql' => "int(10) unsigned NOT NULL auto_increment"
		),
		'tstamp' => array
		(
			'sql' => "int(10) unsigned NOT NULL default '0'"
		),
		'title'  => array
		(
			'label'     => &$GLOBALS['TL_LANG']['tl_screencast']['title'],
			'inputType' => 'text',
			'exclude'   => true,
			'sorting'   => true,
			'flag'      => 1,
                        'search'    => true,
			'eval'      => array(
				'mandatory'   => true,
                                'unique'         => true,
                                'maxlength'   => 255,
				'tl_class'        => 'w50',
 
			),
			'sql'       => "varchar(255) NOT NULL default ''"
		),
                'url'    => array
		(
			'label'         => &$GLOBALS['TL_LANG']['tl_screencast']['url'],
			'inputType' => 'text',
			'exclude'     => true,
			'sql'            => "text NULL"
		)
       )
);

Was passiert hier?

Im Abschnitt fields werden alle Felder definiert, die in der Datenbank-Tabelle angelegt werden sollen. Dies sind meist mehr Felder, als im Formular des Backend-Moduls ausgegeben werden (z.B. wird man die id wohl fast immer automatisch setzen). Die Felder id und tstamp sind übrigens immer Pflicht. sql ist (Überraschung) immer das SQL-Statement.

Kümmern wir uns der um die einzelnen Felder: 'title' und 'url' sind die beiden Eingabefelder im BE-Modul. Diese haben wir auch in den 'palettes' definiert. Beide felder werden noch mit einigen weiteren Eigenschaften konfikuriert:

  • label: Ist eine Referenz auf die Sprachvariable, die noch erstellt wird.
  • inputType: (s. Contao-Docs) in userem Fall ein Textfeld
  • exclude: Erlaubt dem Admin, dieses Feld später für Redakteure zu sperren.
  • sorting: Bestimmt, dass dieses Feld oben in der Sortierpalette angewählt werden kann
  • flag: Regelt die Sortierreihenfolge
  • search: Bestimmt, dass nach dieses Feld oben in der Sortierpalette gesucht werden kann
  • eval: Konfiguriert ein Eingabefeld im Detail (s. Contao-Docs). In unserem Fall ob es ein Pflichtfeld ist, ob es einzigartig ist, die Textfeldlänge sowie die Darstellung, wie dieses Feld im BE-Modul angezeigt wird. Schaut euch zu tl_class auch das Video an oder den Abschnitt Felder ausrichten in den Docs.
  • Diejenigen, die den Code aus der tl_theme.php kopiert haben:

Löscht bitte den nachfolgenden Code aus eurer Datei. Die Klassendefinition wird für diese Beispiel nicht benötigt.

  • Der große Moment ist nah: Navigiert im Contao-BE zu System->Erweiterungsverwaltung und klickt auf Datenbank aktualisieren.

Wenn ihr alles richtig gemacht habt, solltet ihr nun eine Meldung von Contao darüber erhalten, dass eine neue Tabelle angelegt wird. Klickt auf Aktualisieren.

  • Jetzt könntet ihr eigentlich schon einen Screencast anlegen....aber es fehlen noch die Sprachvariablen.
  • Erstellt daher eine Datei tl_screencast.php im Verzeichnis screencast/languages/de.
  • Gebt folgenden Code in diese Datei ein (Video: 32:30):
<?php
 
$GLOBALS['TL_LANG']['tl_screencast']['title_legend'] = 'Titel';
 
$GLOBALS['TL_LANG']['tl_screencast']['type'][0] = 'Typ';
$GLOBALS['TL_LANG']['tl_screencast']['type'][1] = 'Wählen Sie hier aus, ob es sich um ein externes oder lokales Video handelt.';
 
$GLOBALS['TL_LANG']['tl_screencast']['title'][0] = 'Titel';
$GLOBALS['TL_LANG']['tl_screencast']['title'][1] = 'Geben Sie hier den Screencast Titel ein.';
 
$GLOBALS['TL_LANG']['tl_screencast']['screencast_legend'] = 'Screencast';
 
$GLOBALS['TL_LANG']['tl_screencast']['url'][0] = 'Video URL';
$GLOBALS['TL_LANG']['tl_screencast']['url'][1] = 'Geben Sie hier die Video URL ein.';
 
$GLOBALS['TL_LANG']['tl_screencast']['new'][0] = 'Neuer Screencast';
$GLOBALS['TL_LANG']['tl_screencast']['new'][1] = 'Einen neuen Screencast anlegen';
 
$GLOBALS['TL_LANG']['tl_screencast']['edit'][0] = 'Screencast bearbeiten';
$GLOBALS['TL_LANG']['tl_screencast']['edit'][1] = 'Screencast ID %s bearbeiten';
 
$GLOBALS['TL_LANG']['tl_screencast']['delete'][0] = 'Screencast löschen';
$GLOBALS['TL_LANG']['tl_screencast']['delete'][1] = 'Screencast ID %s löschen';
 
$GLOBALS['TL_LANG']['tl_screencast']['show'][0] = 'Screencastdetails';
$GLOBALS['TL_LANG']['tl_screencast']['show'][1] = 'Details des Screencast ID %s anzeigen';

Was passiert hier?

Es werden alle Übersetzungen angelegt, die für das Backend notwendig sind. Die notwendigen Keys der Arrays findet ihr z.T. in der DCA-Datei unter paletts bzw. im Bereich global operations und operations. Die Beschreibungen der Felder liegen immer auf dem zweiten Arrayeintrag.

Beispiel:

['title'] bezieht sich auf das den paletts-Eintrag title. [0] ist dabei der Titel des BE-Feldes.[1] ist die Beschreibung unterhalb des Feldes. Diese wird automatisch hinzugefügt.

Die Übersetzungen zu den Feldern

Anlegen der Klasse für die Ausgabe-Logik

(Video:39:00)
  • Legt im Verzeichnis screencast/modules/ die Datei ModuleScreencastList.php
  • Legt im Verzeichnis screencast/templates die Datei mod_screencast_list.html5 an. Wer noch mit xhtml arbeitet: Legt auch noch eine mod_screencast_list.xhtml an. Ich gehe im Folgenden aber nur auf die .html5 Datei ein.
  • Fügt dort folgenden Code in die Datei ModuleScreencastList.php ein:
<?php
 
class ModuleScreencastList extends Module
{
	/**
	 * Template
	 * @var string
	 */
	protected $strTemplate = 'mod_screencast_list';
 
	/**
	 * Compile the current element
	 */
	protected function compile()
	{
		/** @var \Contao\Database\Result $rs */
		$rs = Database::getInstance()
			->query('SELECT * FROM tl_screencast ORDER BY title');
 
		$this->Template->screencasts = $rs->fetchAllAssoc();
	}
}


Was passiert hier? In dieser Datei werden die Datensätze aus der Datenbank gelesen und an das FE-Template übergeben. Die hierzu notwendige Klasse muss den selben Namen erhalten, wie der Dateiname. Ferner muss die Klasse von der übergeordneten Klasse Module erben.

Die folgenden Anweisungen kann man eigentlich nur richtig verstehen, wenn man sich die Datei module.php in system/modules/core/modules anschaut, bzw. einen Blick in die weiter übergeordnetetn Klassen wirft.

Ich versuch's mal ohne Referenz auf die übergeordneten Klassen:

  1. Zunächst wird Contao die Template-Datei übergeben ($strTemplate).
  2. Anschließend folgt die Methode compile(), welche für die Daten für die Übergabe an das Template vorbereitet.
  3. Nun wird über die statische Methode getInstance() ein Contao-Datenbankobjekt erzeugt (system/modules/core/library/Contao/Database.php). Dieses Objekt hat die Methoden query und liefert ein Contao-DB-Ergebnisobjekt zurück (system/modules/core/library/Contao/Database/Result.php). Dieses Objekt wiederum hat die Methode fetchAllAssoc(), welche ein assoziatives Array zurückgibt (Key=Feldnamen). Diese speichern wir in der Variablen screencasts, welche durch die übergeordneten Klassen von ModuleScreencastList einem FE-Templateobjekt zugeordnet wird.

Das FE-Template erstellen

(Video 43:00)

Wer faul ist, kopiert sich den Code aus der mod_flash.html5 (system/modules/core/templates) in seine Datei mod_screencast_list.html5.

Am Ende sollte der Code wie folgt aussehen:

<div class="<?php echo $this->class; ?> block"<?php echo $this->cssID; ?><?php if ($this->style): ?> style="<?php echo $this->style; ?>"<?php endif; ?>>
<?php if ($this->headline): ?>
 
<<?php echo $this->hl; ?>><?php echo $this->headline; ?></<?php echo $this->hl; ?>>
<?php endif; ?>
 
<?php foreach ($this->screencasts as $screencast): ?>
	<h2><?php echo $screencast['title']; ?></h2>
	<p>
		<iframe width="560" height="315" src="http://www.youtube-nocookie.com/embed/<?php echo str_replace('http://youtu.be/', '', $screencast['url']); ?>?rel=0" frameborder="0" allowfullscreen></iframe>
	</p>
<?php endforeach; ?>
 
</div>

Was passiert hier?

Das Template wird im Kontext der FE-Templateklasse ausgegeben. Daher kann mit $this auf die Attribute und Methoden des Objekts zugegriffen werden. Dies haben wir in der compile()-Methode in ModuleScreencastList.php definiert.

Das FE-Template im System registrieren (Video 44:00)

(Video 43:00) Ergänzt die Datei config.php um folgenden Code

/**
 * Front end modules
 */
$GLOBALS['FE_MOD']['screencast'] = array
(
	'screencast_list'     => 'ModuleScreencastList',
);

Was passiert hier? Unser FE-Template muss noch dem Contao-System bekannt gemacht werden. Jetzt sollte man das Modul bereits im Backend in der Modulliste auswählen können. Es fehlt jedoch noch die Übersetzung.

Die Übersetzung für das FE-Template erstellen (Video 44:00)

Öffnet die Datei modules.php in screencast/languages/de/ und ergänzt folgende Zeilen Code:

$GLOBALS['TL_LANG']['FMD']['screencast_list'][0] = 'Screencast Liste';
$GLOBALS['TL_LANG']['FMD']['screencast_list'][1] = 'Auflistung der Screencasts';

Was passiert hier? Nun....in der Modulliste steht künftig "Screencast Liste".

Erstellung der autoloader-Dateien

Der Autoloader sorgt z.B. dafür, dass alle notwendigen Templates bzw. Klassen des Moduls im Contao-System registriert werden. Praktischerweise können die autoloader-Dateien automatisch generiert werden. Navigiert hierzu im Backend auf Entwickler-Tools->Autoload-Creator. Wählt das Modul screencast aus und klickt auf Autoload-Dateien erstellen. Prüft, ob die Dateien autoload.php und autiload.ini in eurem config-Verzeichnis erstellt wurden. autoload.php:

<?php
 
/**
 * Contao Open Source CMS
 * 
 * Copyright (C) 2005-2013 Leo Feyer
 * 
 * @package Screencast
 * @link    https://contao.org
 * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL
 */
 
 
/**
 * Register the classes
 */
ClassLoader::addClasses(array
(
	// Modules
	'ModuleScreencastList' => 'system/modules/screencast/modules/ModuleScreencastList.php',
));
 
 
/**
 * Register the templates
 */
TemplateLoader::addFiles(array
(
	'mod_screencast_list' => 'system/modules/screencast/templates',
));

autoload.ini

;;
; Configure what you want the autoload creator to register
;;
register_namespaces = true
register_classes    = true
register_templates  = true

Geschafft!

Wenn ich mich nicht verschrieben habe und ihr alles richtig gemacht habt, könnt ihr eure Screencast-Modul nun einsetzen.

Ausblick

Im folgenden Teil wird unser Modul erweitert. Dabei geht es schwerpunktsmässig um das Hinzufügen von sog. Subpaletten, die eine kontextsensitive (schönes Wort) Auswahl von Einstellungen im Modulbereich erlauben. Zu Deutsch: Wenn der Nutzer die Wahl zwischen verschiedenen Screencast-Quellen (z.B. Youtube, Vimeo etc.) hat, verändern sich die folgenden Eingabe-/Auswahlfelder.

Das Video könnt ihr euch hier schon ansehen: Videolink : DCA Paletten und Subpaletten, MetaPalettes

So...ich habe erstmal fertig. Bis demnächst. Grüße von kobajashi

Weiterführende Links

2. Teil Selective DCA Paletten und Subpaletten

Ansichten
Meine Werkzeuge

Contao Community Documentation

irgendwie ist das Leben nicht fair...ich mache eine Webseite über Toilettenreinigung und Martin stellt Fotos für eine Schönheitswebseite frei...

Leo Unglaub
Navigation
Verstehen
Verwenden
Entwickeln
Verschiedenes
Werkzeuge