Beispiel: Testimonial Widget
Ein komplettes Pagebuilder-Widget von null — nach Copy-Paste und Cache-Rebuild einsetzbar. Das testimonial-Widget zeigt ein Zitat mit Autor-Name, Position und Foto. Es demonstriert alle wichtigen Konzepte: bootstrap.php im Plugin, Widget-Unterordner mit Klasse, widget_menu-Felder, icon.png, Vue-Template mit Props-Binding.
Ziel
Ein Widget mit vier Feldern:
| Feld | Typ | Verwendung |
|---|---|---|
| Name | text (title) | Autor des Testimonials |
| Position | text (subtitle) | Berufsbezeichnung, Firma |
| Quote | textarea (text) | Das Zitat selbst |
| Foto | filemanager (image) | Avatar-Bild |
Struktur
Wir erweitern ein bestehendes Plugin (widgetsBase als Beispiel) um dieses Widget:
_public/extensions/core/backend/widgetsBase/
├── bootstrap.php # Plugin-bootstrap (Widget-Registrierung hier ergaenzen)
└── widgets/
└── testimonial/ # Neuer Widget-Ordner
├── bootstrap.php # Widget-Klasse
├── icon.png # Picker-Icon
└── template/
└── index.vue # Frontend-KomponenteEigenes Plugin fuer Widgets
Im Projektalltag legt man Widgets meist in einem eigenen Plugin ab (z. B. testimonials/), damit bei De-/Installation alles zusammen wandert. Fuer das Beispiel nutzen wir der Einfachheit halber widgetsBase — in der Produktion waere ein separates Plugin sauberer. Siehe Plugins › Hello World.
1. Widget-Registrierung in widgetsBase/bootstrap.php
Im install()-Block des Plugins wird das pagebuilder_widgets-Array erweitert. Den neuen Eintrag am Ende der Liste einfuegen:
// Bestehendes $this->pagebuilder_widgets = [...] wird erweitert:
$this->pagebuilder_widgets[] = '{
"widget_title": "Testimonial",
"widget_icon": "icon.png",
"widget_subtitle": "Quote with author name and photo",
"widget_template": "testimonial",
"widget_menu": [
{"row": "title", "type": "text", "placeholder": "Author name"},
{"row": "subtitle", "type": "text", "placeholder": "Position / Company"},
{"row": "text", "type": "textarea", "placeholder": "Quote"},
{"row": "image", "type": "filemanager", "placeholder": "Photo"}
]
}';2. Widget-Klasse: widgets/testimonial/bootstrap.php
<?php
class testimonial_PageBuilderPlugin
{
public static function getVersion(): string
{
return "1.0.0";
}
public static function getName(): string
{
return "Testimonial";
}
public static function getWidgetContent($widget_array, $backend = false)
{
// Bild-URL serverseitig aufloesen — das Formular-Feld enthaelt nur
// die Media-ID als URL-encoded JSON [{"id":"123"}], nicht die URL selbst.
$imageField = $widget_array['widget_image'] ?? '';
$imageUrl = !empty($imageField) ? files::loadURL($imageField) : '';
return ['imageUrl' => $imageUrl];
}
}Drei statische Methoden, keine Vererbung. getWidgetContent() ist nicht null, weil wir die Media-ID aus widget_image serverseitig zu einer echten URL aufloesen muessen — files::loadURL() parst das URL-encoded JSON-Format [{"id":"123"}] und liefert den absoluten Asset-Pfad. Die aufgeloeste URL steht im Vue-Template unter widget.content.imageUrl.
Text-Felder (title, subtitle, text) stehen dagegen direkt im Vue-Template unter widget.data.* bereit — die brauchen keine Server-Aufloesung.
3. Icon: widgets/testimonial/icon.png
Ein einfaches 64×64-PNG-Icon im Widget-Root — zeigt sich im Picker des Pagebuilders neben widget_title und widget_subtitle.
Font-Awesome statt PNG
Wer lieber Font-Awesome-Icons nutzt: icon.png trotzdem als Platzhalter ablegen (der Loader erwartet die Datei), und im template/index.vue das Icon rendern. Ein Mechanismus fuer SVG- oder FA-Picker-Icons existiert aktuell nicht im Core.
4. Vue-Template: widgets/testimonial/template/index.vue
<template>
<div class="testimonial-widget">
<div class="testimonial-content">
<blockquote v-if="widget.data?.text" v-html="widget.data.text"></blockquote>
<div class="testimonial-author">
<img
v-if="widget.content?.imageUrl"
:src="widget.content.imageUrl"
:alt="widget.data?.title || 'Testimonial'"
class="testimonial-photo"
/>
<div class="testimonial-author-meta">
<strong v-if="widget.data?.title">{{ widget.data.title }}</strong>
<span v-if="widget.data?.subtitle">{{ widget.data.subtitle }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface WidgetData {
title?: string
subtitle?: string
text?: string
}
interface WidgetContent {
imageUrl?: string
}
defineProps<{
widget: { data?: WidgetData; content?: WidgetContent }
}>()
</script>
<style scoped>
.testimonial-widget {
padding: 2rem;
border-radius: 0.75rem;
background: #f8fafc;
}
.testimonial-content blockquote {
font-size: 1.25rem;
line-height: 1.6;
font-style: italic;
color: #111827;
margin: 0 0 1.5rem 0;
}
.testimonial-author {
display: flex;
align-items: center;
gap: 1rem;
}
.testimonial-photo {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
.testimonial-author-meta {
display: flex;
flex-direction: column;
}
.testimonial-author-meta strong {
font-weight: 600;
color: #111827;
}
.testimonial-author-meta span {
font-size: 0.875rem;
color: #6b7280;
}
</style>Hinweise zum Template
widget.data?.title— Formular-Wert direkt. Optional Chaining (?.), falls Formular noch leer ist.widget.content?.imageUrl— die vongetWidgetContent()serverseitig aufgeloeste Media-URL. Text-Felder kommen auswidget.data, aufgeloeste Bilder/externe Daten auswidget.content.v-htmlfuertext— Anfuehrungszeichen und Zeilenumbrueche werden korrekt gerendert. Nur sicher, weil der Content vom Redakteur kommt (CMS-trusted). Siehe Pitfall-Sektion unten.- Scoped Styles — lokal auf diese Komponente beschraenkt, vermeidet CSS-Kollisionen mit anderen Widgets.
5. Widget-ID fuer Custom-CSS
Im Pagebuilder-Properties-Panel kann der Redakteur zusaetzlich Custom-CSS eingeben. Der Wrapper ist .widget_{base_id}:
/* Custom-CSS-Eingabe fuer dieses Widget — im Editor */
background: #4F46E5;
blockquote {
color: white;
font-size: 1.5rem;
}
.testimonial-author-meta strong {
color: white;
}Kompiliert zu:
.widget_217 { background: #4F46E5; }
.widget_217 blockquote { color: white; font-size: 1.5rem; }
.widget_217 .testimonial-author-meta strong { color: white; }Details zum Wrapper-System: Custom-CSS-Konvention.
6. Plugin re-installieren und Cache rebuilden
1. /admin/plugin-manager → "widgetsBase" deinstallieren → neu installieren
(damit das neue Widget in page_widgets landet)
2. /admin/cache → "Cache leeren"
(damit plugin-registry.js mit der neuen testimonial-Komponente neu generiert wird)7. Testen
- Beliebige Seite im Pagebuilder oeffnen (
/admin/pagebuilder?id=…) - Widget-Picker oeffnen (Neues Widget) → "Testimonial" sollte neben Title/HTML/Image auftauchen, mit deiner
icon.png - Widget auf die Seite droppen
- Felder befuellen (Name, Position, Quote, Photo)
- Page publishen
- Frontend-Seite aufrufen — das Testimonial rendert
Erweiterungen
Was das Widget als naechstes koennen koennte:
- Mehrere Testimonials →
items-Feldtyp statt einzelnen Feldern, siehe Widget-Items / Repeater - Multi-Language → automatisch, sobald das Pagebuilder
multilanguageunterstuetzt (gilt aber nur auf Widget-Item-Ebene, nicht pro Feld) - Responsive Bilder / Srcset → zusaetzlich
files::loadURL()in mehreren Groessen aufrufen und alsimageSrcsetinwidget.contentbereitstellen — siehe getWidgetContent() - Scroll-Animation → GSAP-Preset im Properties-Panel aktivieren (Tab "Animations"), Details in CLAUDE.md-Abschnitt "Pagebuilder — Scroll Animations"
Haeufige Fehler beim Nachbauen
Widget nicht im Picker
Nach Hinzufuegen eines neuen Widgets: Plugin re-installieren (oder page_widgets manuell erweitern) und Cache unter /admin/cache rebuilden. Ohne beides ist das Widget nicht sichtbar.
Klassenname ≠ widget_template
Ordner widgets/testimonial/ → JSON "widget_template": "testimonial" → Klasse testimonial_PageBuilderPlugin. Alles drei muss exakt uebereinstimmen.
Asymmetrische Keys bei "row": "text"
Bei "row": "text" im widget_menu:
- PHP (
getWidgetContent): Zugriff via$widget_array['widget_html']—PageService.phpaliased dietext-Spalte zuwidget_html. - Vue (
template/index.vue): Zugriff viawidget.data.text— unveraendert dierow-Spalte.
Das ist asymmetrisch: der Vue-Key sieht die DB-Spalte direkt, der PHP-Key heisst anders. Wer im PHP $widget_array['widget_text'] liest oder im Vue widget.data.html liest, bekommt null.
v-html bei nicht-trusted Content
v-html rendert HTML unescapt — fuer Pagebuilder-Widgets ist das ok, weil der Content vom Redakteur (CMS-trusted) kommt. Niemals bei User-Input aus dem Frontend (Form-Submits, Comments) — XSS-Risiko.
Icon-Datei fehlt
widget_icon: "icon.png" erwartet die Datei im Widget-Root. Ohne icon.png zeigt der Picker ein Broken-Image.
Custom-CSS mit .widget_217 { ... } (Selektor mit Wrapper)
Der Editor erwartet nur den Wrapper-Inhalt. Siehe Custom-CSS-Konvention › Haeufige Fehler.
Siehe auch
- Widgets-Uebersicht — Konzept und Lebenszyklus
- Widget-Anatomie —
_PageBuilderPlugin-Klasse im Detail - widget_menu-Feldtypen — alle Feldtypen
- getWidgetContent() — fuer Produktions-Ausbau mit server-seitiger Bild-URL
- Widget-Items / Repeater — Erweiterung zu mehreren Testimonials
- Custom-CSS-Konvention — Styling im Properties-Panel
- Plugins › Hello World — separates Plugin fuer das Widget bauen