10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View file

@ -0,0 +1,39 @@
{
"name": "acme/cookie-consent",
"description": "Ein DSGVO-konformer Cookie Consent Manager für Laravel mit Alpine.js und Google Analytics Support",
"type": "library",
"license": "MIT",
"keywords": [
"laravel",
"cookie",
"consent",
"gdpr",
"dsgvo",
"google-analytics",
"alpine.js"
],
"autoload": {
"psr-4": {
"Acme\\CookieConsent\\": "src/"
}
},
"authors": [
{
"name": "Acme",
"email": "info@acme.de"
}
],
"require": {
"php": "^8.1",
"illuminate/support": "^10.0|^11.0|^12.0"
},
"extra": {
"laravel": {
"providers": [
"Acme\\CookieConsent\\CookieConsentServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View file

@ -0,0 +1,127 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cookie Consent aktivieren
|--------------------------------------------------------------------------
|
| Hilfreich, um den Manager in bestimmten Umgebungen komplett auszuschalten.
|
*/
'enabled' => env('COOKIE_CONSENT_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Google Analytics ID
|--------------------------------------------------------------------------
|
| Hier trägst du deine Tracking ID ein (z.B. G-XXXXXXXXXX).
| Wenn der Wert leer ist, wird der Analytics-Toggle nicht angezeigt.
|
*/
'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''),
/*
|--------------------------------------------------------------------------
| Google Tag Manager ID
|--------------------------------------------------------------------------
|
| Optional: Wenn du den Google Tag Manager statt direktem Analytics nutzt,
| trage hier deine GTM-ID ein (z.B. GTM-XXXXXXXX).
|
| Wenn sowohl GTM als auch Analytics-ID gesetzt sind, wird GTM bevorzugt.
| Der Tag Manager lädt dann alle weiteren Tags (inkl. GA4) selbst.
|
*/
'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''),
/*
|--------------------------------------------------------------------------
| Cookie Lebensdauer
|--------------------------------------------------------------------------
|
| Wie viele Tage soll die Entscheidung des Nutzers gespeichert bleiben?
| Standard: 365 Tage (1 Jahr). DSGVO empfiehlt max. 12 Monate.
|
*/
'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365),
/*
|--------------------------------------------------------------------------
| Cookie Name
|--------------------------------------------------------------------------
|
| Der Name des Cookies, in dem die Einwilligung gespeichert wird.
|
*/
'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'),
/*
|--------------------------------------------------------------------------
| Links
|--------------------------------------------------------------------------
|
| URLs für Datenschutz und Impressum.
|
*/
'links' => [
'privacy' => '/datenschutz',
'imprint' => '/impressum',
],
/*
|--------------------------------------------------------------------------
| Position des Floating Buttons
|--------------------------------------------------------------------------
|
| Wo soll der Cookie-Settings-Button angezeigt werden?
| Optionen: 'bottom-left', 'bottom-right'
|
*/
'button_position' => 'bottom-left',
/*
|--------------------------------------------------------------------------
| IP-Anonymisierung
|--------------------------------------------------------------------------
|
| Google Analytics IP-Anonymisierung aktivieren (empfohlen für DSGVO).
|
*/
'anonymize_ip' => true,
/*
|--------------------------------------------------------------------------
| Farben (Theme)
|--------------------------------------------------------------------------
|
| Passe die Farben an dein Projekt-Design an.
| Du kannst Tailwind-Klassen oder CSS-Farbwerte verwenden.
|
| Für Tailwind-Projekte: Nutze deine definierten Farben wie 'primary', 'secondary'
| Für Standard-CSS: Nutze HEX-Werte wie '#009bdd'
|
*/
'colors' => [
// Primärfarbe für Akzente, aktive Toggles, Icons
'primary' => env('COOKIE_CONSENT_COLOR_PRIMARY', '#0088cc'),
// Sekundärfarbe / Hover-Zustand der Primärfarbe
'primary_hover' => env('COOKIE_CONSENT_COLOR_PRIMARY_HOVER', '#006699'),
// Akzeptieren-Button (grün)
'accept' => env('COOKIE_CONSENT_COLOR_ACCEPT', '#16a34a'),
'accept_hover' => env('COOKIE_CONSENT_COLOR_ACCEPT_HOVER', '#15803d'),
// Einstellungen speichern Button
'save' => env('COOKIE_CONSENT_COLOR_SAVE', '#a5a5a5'),
'save_hover' => env('COOKIE_CONSENT_COLOR_SAVE_HOVER', '#b3b3b3'),
// Floating Button
'button_bg' => env('COOKIE_CONSENT_COLOR_BUTTON_BG', '#0088cc'),
'button_hover' => env('COOKIE_CONSENT_COLOR_BUTTON_HOVER', '#006699'),
],
];

View file

@ -0,0 +1,138 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cookie Consent - Deutsche Übersetzungen
|--------------------------------------------------------------------------
*/
// Modal
'modal' => [
'title' => 'Datenschutzeinstellungen',
'description' => 'Wir nutzen Cookies, um unsere Website für Sie zu optimieren. Einige sind essenziell, andere helfen uns, die Nutzung zu analysieren.',
],
// Cookie-Kategorien
'categories' => [
'essential' => [
'title' => 'Technisch notwendig',
'description' => 'Für die Grundfunktion der Seite.',
],
'analytics' => [
'title' => 'Analyse & Statistik',
'description' => 'Google Analytics / Tag Manager (IP-Anonymisiert).',
],
],
// Buttons
'buttons' => [
'accept_all' => 'Alle akzeptieren',
'save_selection' => 'Auswahl speichern',
'reject_all' => 'Nur essenzielle',
'change_settings' => 'Cookie-Einstellungen ändern',
],
// Links
'links' => [
'privacy' => 'Datenschutzerklärung',
'imprint' => 'Impressum',
],
// Accessibility
'aria' => [
'open_settings' => 'Cookie-Einstellungen öffnen',
'essential_always_active' => 'Technisch notwendige Cookies sind immer aktiv',
'toggle_analytics' => 'Analytics Cookies aktivieren oder deaktivieren',
],
// Status
'status' => [
'active' => 'Aktiv',
'inactive' => 'Deaktiviert',
'no_consent' => 'Sie haben noch keine Cookie-Einstellungen vorgenommen.',
],
// Privacy Info Komponente
'privacy_info' => [
'current_settings_title' => 'Ihre aktuellen Cookie-Einstellungen',
'essential_cookies' => 'Technisch notwendige Cookies',
'analytics_cookies' => 'Analyse & Statistik (Google Analytics / Tag Manager)',
'analytics_marketing_cookies' => 'Analyse- und Marketing-Cookies',
],
// Google Analytics Informationen
'google_analytics' => [
'title' => 'Google Analytics',
'intro' => 'Diese Website nutzt Google Analytics, einen Webanalysedienst der Google Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.',
'technical_details' => [
'title' => 'Technische Details',
'tracking_id' => 'Tracking-ID',
'ip_anonymization' => 'IP-Anonymisierung',
'ip_anonymization_value' => 'Aktiviert (anonymize_ip)',
'consent_mode' => 'Consent Mode',
'consent_mode_value' => 'Google Consent Mode v2',
'cookie_duration' => 'Cookie-Speicherdauer',
'cookie_duration_value' => ':days Tage',
],
'purpose' => [
'title' => 'Zweck der Verarbeitung',
'text' => 'Google Analytics verwendet Cookies, die eine Analyse der Benutzung der Website ermöglichen. Die durch das Cookie erzeugten Informationen über Ihre Benutzung dieser Website werden in der Regel an einen Server von Google in den USA übertragen und dort gespeichert.',
],
'ip_anonymization' => [
'title' => 'IP-Anonymisierung',
'text' => 'Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt.',
],
'legal_basis' => [
'title' => 'Rechtsgrundlage',
'text' => 'Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie die Cookie-Einstellungen ändern.',
],
'cookies' => [
'title' => 'Von Google Analytics gesetzte Cookies',
'table' => [
'name' => 'Cookie',
'purpose' => 'Zweck',
'duration' => 'Speicherdauer',
],
'list' => [
'_ga' => [
'purpose' => 'Unterscheidung von Nutzern',
'duration' => '2 Jahre',
],
'_ga_*' => [
'purpose' => 'Speicherung des Sitzungsstatus',
'duration' => '2 Jahre',
],
'_gid' => [
'purpose' => 'Unterscheidung von Nutzern',
'duration' => '24 Stunden',
],
'_gat' => [
'purpose' => 'Drosselung der Anfragerate',
'duration' => '1 Minute',
],
],
],
'objection' => [
'title' => 'Widerspruchsmöglichkeit',
'text' => 'Sie können die Erfassung der durch das Cookie erzeugten und auf Ihre Nutzung der Website bezogenen Daten (inkl. Ihrer IP-Adresse) an Google sowie die Verarbeitung dieser Daten durch Google verhindern, indem Sie:',
'options' => [
'revoke' => 'Ihre Einwilligung in den Cookie-Einstellungen widerrufen',
'addon' => 'Das Browser-Add-on zur Deaktivierung von Google Analytics installieren:',
],
],
'more_info' => [
'title' => 'Weitere Informationen',
'text' => 'Weitere Informationen zum Umgang mit Nutzerdaten bei Google Analytics finden Sie in der Datenschutzerklärung von Google:',
],
],
];

View file

@ -0,0 +1,138 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cookie Consent - English Translations
|--------------------------------------------------------------------------
*/
// Modal
'modal' => [
'title' => 'Privacy Settings',
'description' => 'We use cookies to optimize our website for you. Some are essential, others help us analyze usage.',
],
// Cookie Categories
'categories' => [
'essential' => [
'title' => 'Strictly Necessary',
'description' => 'Required for basic site functionality.',
],
'analytics' => [
'title' => 'Analytics & Statistics',
'description' => 'Google Analytics / Tag Manager (IP anonymized).',
],
],
// Buttons
'buttons' => [
'accept_all' => 'Accept All',
'save_selection' => 'Save Selection',
'reject_all' => 'Essential Only',
'change_settings' => 'Change Cookie Settings',
],
// Links
'links' => [
'privacy' => 'Privacy Policy',
'imprint' => 'Legal Notice',
],
// Accessibility
'aria' => [
'open_settings' => 'Open cookie settings',
'essential_always_active' => 'Strictly necessary cookies are always active',
'toggle_analytics' => 'Enable or disable analytics cookies',
],
// Status
'status' => [
'active' => 'Active',
'inactive' => 'Disabled',
'no_consent' => 'You have not yet made any cookie settings.',
],
// Privacy Info Component
'privacy_info' => [
'current_settings_title' => 'Your Current Cookie Settings',
'essential_cookies' => 'Strictly Necessary Cookies',
'analytics_cookies' => 'Analytics & Statistics (Google Analytics / Tag Manager)',
'analytics_marketing_cookies' => 'Analytics & Marketing Cookies',
],
// Google Analytics Information
'google_analytics' => [
'title' => 'Google Analytics',
'intro' => 'This website uses Google Analytics, a web analytics service provided by Google Ireland Limited ("Google"), Gordon House, Barrow Street, Dublin 4, Ireland.',
'technical_details' => [
'title' => 'Technical Details',
'tracking_id' => 'Tracking ID',
'ip_anonymization' => 'IP Anonymization',
'ip_anonymization_value' => 'Enabled (anonymize_ip)',
'consent_mode' => 'Consent Mode',
'consent_mode_value' => 'Google Consent Mode v2',
'cookie_duration' => 'Cookie Duration',
'cookie_duration_value' => ':days days',
],
'purpose' => [
'title' => 'Purpose of Processing',
'text' => 'Google Analytics uses cookies that enable an analysis of your use of the website. The information generated by the cookie about your use of this website is usually transmitted to a Google server in the USA and stored there.',
],
'ip_anonymization' => [
'title' => 'IP Anonymization',
'text' => 'We have activated the IP anonymization function on this website. This means that your IP address is truncated by Google within member states of the European Union or in other contracting states of the Agreement on the European Economic Area before being transmitted to the USA.',
],
'legal_basis' => [
'title' => 'Legal Basis',
'text' => 'Processing is based on your consent pursuant to Art. 6 (1) lit. a GDPR. You can revoke your consent at any time by changing your cookie settings.',
],
'cookies' => [
'title' => 'Cookies Set by Google Analytics',
'table' => [
'name' => 'Cookie',
'purpose' => 'Purpose',
'duration' => 'Duration',
],
'list' => [
'_ga' => [
'purpose' => 'Distinguishing users',
'duration' => '2 years',
],
'_ga_*' => [
'purpose' => 'Storing session state',
'duration' => '2 years',
],
'_gid' => [
'purpose' => 'Distinguishing users',
'duration' => '24 hours',
],
'_gat' => [
'purpose' => 'Throttling request rate',
'duration' => '1 minute',
],
],
],
'objection' => [
'title' => 'Right to Object',
'text' => 'You can prevent the collection of data generated by the cookie and related to your use of the website (including your IP address) by Google and the processing of this data by Google by:',
'options' => [
'revoke' => 'Revoking your consent in the cookie settings',
'addon' => 'Installing the browser add-on to disable Google Analytics:',
],
],
'more_info' => [
'title' => 'More Information',
'text' => 'For more information on how Google Analytics handles user data, please see Google\'s privacy policy:',
],
],
];

View file

@ -0,0 +1,365 @@
# Laravel GDPR/DSGVO Cookie Consent Manager
Ein leichtgewichtiges, DSGVO-konformes Cookie Consent Management System für Laravel. Es nutzt **Alpine.js** für die Frontend-Logik und ist speziell für die einfache Integration von Google Analytics 4 und Google Tag Manager (mit Google Consent Mode v2) entwickelt.
## Features
- 🇪🇺 **DSGVO Konform:** Skripte werden erst nach expliziter Zustimmung geladen (Prior Consent)
- 🔒 **Google Consent Mode v2:** Vollständige Integration mit Default-Einstellungen
- 📊 **Google Analytics 4:** Direktes GA4-Tracking mit IP-Anonymisierung
- 🏷️ **Google Tag Manager:** Alternativ GTM-Integration für komplexere Tag-Setups
- 🌍 **Mehrsprachig:** Deutsch und Englisch integriert, weitere Sprachen leicht hinzufügbar
- 🚀 **Performance:** Basiert auf Alpine.js, keine Server-Requests
- 🎨 **Flexibel:** Anpassbar über Blade-Komponenten und Tailwind CSS
- ⚙️ **Konfigurierbar:** Alle Optionen über Config steuerbar
- 📱 **Responsive:** Mobile-optimiertes Modal mit Animationen
- ♿ **Barrierefrei:** ARIA-Labels, Focus-Trap, Keyboard-Navigation
- 🍪 **Echte Cookies:** Speicherung als HTTP-Cookie mit konfigurierbarer Laufzeit
## Voraussetzungen
- PHP 8.1+
- Laravel 10+ oder 11+
- Alpine.js (via Livewire oder manuell eingebunden)
- Tailwind CSS (für das Standard-Styling)
## Installation
### 1. Package installieren
```bash
composer require acme/cookie-consent
```
Falls das Package lokal entwickelt wird, füge es in der `composer.json` hinzu:
```json
{
"repositories": [
{
"type": "path",
"url": "dev/acme/CookieConsent"
}
],
"require": {
"acme/cookie-consent": "@dev"
}
}
composer update acme/cookie-consent
composer update acme/cookie-consent && php artisan package:discover && php artisan config:clear && php artisan view:clear && php artisan cache:clear
```
### 2. Environment Variables
Füge deine Tracking-ID in die `.env` Datei ein. Du kannst entweder Google Analytics direkt **oder** den Google Tag Manager nutzen:
**Option A: Google Analytics direkt**
```dotenv
GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
```
**Option B: Google Tag Manager (empfohlen für komplexere Setups)**
```dotenv
GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
```
> **Hinweis:** Wenn beide IDs gesetzt sind, wird der Google Tag Manager bevorzugt. Der GTM lädt dann alle weiteren Tags (inkl. GA4) selbstständig über die GTM-Konfiguration.
Optionale Einstellungen:
```dotenv
COOKIE_CONSENT_ENABLED=true
COOKIE_CONSENT_LIFETIME=365
COOKIE_CONSENT_NAME=cookie_consent
```
### 3. Config veröffentlichen (Optional)
```bash
php artisan vendor:publish --tag=cookie-consent-config
```
Dies erstellt `config/cookie-consent.php` mit allen Einstellungsmöglichkeiten.
## Nutzung
### Basis-Einbindung (Google Analytics)
Füge die Komponente in dein Hauptlayout ein (idealerweise vor `</body>`):
```blade
<body>
{{-- Dein Content --}}
<x-cookie-consent::components.manager />
@livewireScripts
</body>
```
### Mit Google Tag Manager
Bei Nutzung des Google Tag Managers musst du zusätzlich die `gtm-noscript` Komponente **direkt nach dem öffnenden `<body>` Tag** einbinden:
```blade
<body>
{{-- GTM noscript (nur erforderlich bei GTM-Nutzung) --}}
<x-cookie-consent::components.gtm-noscript />
{{-- Dein Content --}}
{{-- Cookie Consent Manager (vor </body>) --}}
<x-cookie-consent::components.manager />
@livewireScripts
</body>
```
> **Wichtig:** Der `gtm-noscript` iframe wird von Google empfohlen und ermöglicht grundlegendes Tracking auch bei deaktiviertem JavaScript. Er wird nur geladen, wenn der Nutzer dem Tracking zugestimmt hat.
### Parameter überschreiben
Du kannst alle Parameter direkt bei der Einbindung überschreiben:
```blade
{{-- Mit Google Analytics --}}
<x-cookie-consent::components.manager
analytics-id="G-SPECIAL-ID"
cookie-lifetime="180"
button-position="bottom-right"
/>
{{-- Mit Google Tag Manager --}}
<x-cookie-consent::components.manager
gtm-id="GTM-SPECIAL-ID"
cookie-lifetime="180"
button-position="bottom-right"
/>
{{-- GTM noscript kann auch überschrieben werden --}}
<x-cookie-consent::components.gtm-noscript
gtm-id="GTM-SPECIAL-ID"
/>
```
## Mehrsprachigkeit
Das Package unterstützt automatische Spracherkennung über `app()->getLocale()`.
### Verfügbare Sprachen
- 🇩🇪 **Deutsch** (de) - Standardsprache
- 🇬🇧 **Englisch** (en)
### Sprachdateien veröffentlichen
Um die Übersetzungen anzupassen oder weitere Sprachen hinzuzufügen:
```bash
php artisan vendor:publish --tag=cookie-consent-lang
```
Die Dateien werden nach `lang/vendor/cookie-consent/` in deinem Laravel-Projekt kopiert.
### Eigene Sprache hinzufügen
1. Kopiere `lang/vendor/cookie-consent/de/cookie-consent.php`
2. Erstelle z.B. `lang/vendor/cookie-consent/fr/cookie-consent.php`
3. Übersetze alle Texte
### Sprache setzen
Die Sprache wird automatisch aus Laravel übernommen:
```php
// In Middleware, Controller oder ServiceProvider
app()->setLocale('en'); // Wechselt zu Englisch
// Oder basierend auf Benutzereinstellung
app()->setLocale(auth()->user()->language ?? 'de');
```
## Cookie-Einstellungen manuell öffnen
Laut DSGVO muss der Nutzer seine Entscheidung jederzeit ändern können. Das Package stellt dafür einen Event-Listener bereit:
```html
<a href="#" @click.prevent="$dispatch('open-cookie-settings')">
Cookie-Einstellungen bearbeiten
</a>
```
Oder mit Vanilla JavaScript:
```html
<a href="#" onclick="window.dispatchEvent(new CustomEvent('open-cookie-settings')); return false;">
Cookie-Einstellungen
</a>
```
## Datenschutzerklärung Komponente
Das Package enthält eine spezielle Komponente für deine Datenschutzerklärung, die:
- Die aktuellen Cookie-Einstellungen des Nutzers anzeigt
- Einen Button zum Ändern der Einstellungen bietet
- Ausführliche Informationen zu Google Analytics bereitstellt
### Einbindung
```blade
{{-- In deiner Datenschutz-Seite --}}
<x-cookie-consent::components.privacy-info />
```
### Parameter
```blade
<x-cookie-consent::components.privacy-info
:show-analytics-info="true" {{-- GA-Infos anzeigen --}}
:show-current-settings="true" {{-- Aktuelle Einstellungen anzeigen --}}
:show-change-button="true" {{-- Ändern-Button anzeigen --}}
/>
```
### Nur bestimmte Teile anzeigen
```blade
{{-- Nur die Cookie-Übersicht mit Button --}}
<x-cookie-consent::components.privacy-info
:show-analytics-info="false"
/>
{{-- Nur die GA-Infos (ohne Einstellungs-Box) --}}
<x-cookie-consent::components.privacy-info
:show-current-settings="false"
:show-change-button="false"
/>
```
Die Komponente aktualisiert sich automatisch, wenn der Nutzer seine Cookie-Einstellungen ändert (via `cookie-consent-updated` Event).
## Konfiguration
Die vollständige Konfigurationsdatei (`config/cookie-consent.php`):
```php
<?php
return [
// Cookie Manager aktivieren/deaktivieren
'enabled' => env('COOKIE_CONSENT_ENABLED', true),
// Google Analytics 4 Tracking ID
'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''),
// Google Tag Manager ID (hat Priorität über analytics_id)
'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''),
// Cookie Lebensdauer in Tagen (DSGVO: max. 12 Monate empfohlen)
'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365),
// Name des Consent-Cookies
'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'),
// Links für Datenschutz und Impressum
'links' => [
'privacy' => '/datenschutz',
'imprint' => '/impressum',
],
// Position des Floating-Buttons: 'bottom-left' oder 'bottom-right'
'button_position' => 'bottom-left',
// IP-Anonymisierung für Google Analytics (bei GTM in GTM konfigurieren)
'anonymize_ip' => true,
];
```
## Google Consent Mode v2
Das Package implementiert den Google Consent Mode v2 vollständig:
1. **Default-Einstellungen** werden VOR dem Laden von GA/GTM gesetzt (alles `denied`)
2. Bei Zustimmung wird ein **Consent Update** gesendet
3. GA4/GTM-Script wird erst NACH Zustimmung dynamisch geladen
Diese Implementierung entspricht den aktuellen Google-Anforderungen (März 2024).
## Google Tag Manager
Für komplexere Tracking-Setups empfehlen wir den Google Tag Manager statt direktem GA4:
### Vorteile von GTM
- **Zentrale Verwaltung:** Alle Tags (GA4, Ads, Meta, etc.) über eine Oberfläche
- **Keine Code-Änderungen:** Neue Tags können ohne Deployment hinzugefügt werden
- **Debugging:** Eingebauter Vorschau-Modus zum Testen
- **Trigger & Variablen:** Komplexe Regeln ohne Programmierung
### Einrichtung
1. Setze `GOOGLE_TAG_MANAGER_ID` in der `.env`
2. Füge `<x-cookie-consent::components.gtm-noscript />` direkt nach `<body>` ein
3. Konfiguriere GA4 und andere Tags im GTM-Interface
### Consent Mode im GTM
Der Consent Mode wird automatisch übermittelt. Im GTM solltest du:
1. **Consent-Einstellungen aktivieren:** Admin > Container-Einstellungen > Consent-Übersicht aktivieren
2. **Tags konfigurieren:** Bei jedem Tag die "Einwilligungsprüfungen" entsprechend setzen
3. **GA4-Tag:** "analytics_storage" auf "Erforderlich" setzen
## Design Anpassen
Views veröffentlichen:
```bash
php artisan vendor:publish --tag=cookie-consent-views
```
Die Dateien liegen dann unter `resources/views/vendor/cookie-consent/`.
## Server-seitige Prüfung
Da der Consent als echtes Cookie gespeichert wird, kannst du ihn auch serverseitig prüfen:
```php
// In einem Controller oder Middleware
$consent = json_decode(request()->cookie('cookie_consent'), true);
if ($consent && $consent['analytics'] === true) {
// Analytics-Tracking erlaubt
}
```
## Alle Assets veröffentlichen
```bash
# Config
php artisan vendor:publish --tag=cookie-consent-config
# Views
php artisan vendor:publish --tag=cookie-consent-views
# Sprachdateien
php artisan vendor:publish --tag=cookie-consent-lang
# Alles auf einmal
php artisan vendor:publish --provider="Acme\CookieConsent\CookieConsentServiceProvider"
```
## Rechtlicher Hinweis
Dieses Package bietet eine technische Grundlage für DSGVO-konformes Tracking ("Privacy by Design"). **Es ersetzt keine anwaltliche Beratung.** Der Betreiber der Website ist dafür verantwortlich, dass:
- Die Datenschutzerklärung vollständig und korrekt ist
- Alle eingesetzten Tracking-Dienste dort aufgeführt sind
- Die Cookie-Einwilligung nachweisbar dokumentiert wird
## Lizenz
MIT License

View file

@ -0,0 +1,108 @@
@props([
'gtmId' => config('cookie-consent.gtm_id'),
'enabled' => config('cookie-consent.enabled', true),
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
])
{{--
Google Tag Manager (noscript) Komponente
Diese Komponente muss direkt nach dem öffnenden <body> Tag eingefügt werden:
<body>
<x-cookie-consent::components.gtm-noscript />
...
</body>
Der noscript-Teil wird nur angezeigt, wenn:
1. GTM aktiviert ist (gtm_id gesetzt)
2. Der Cookie-Consent bereits erteilt wurde (analytics: true)
Hinweis: Bei JavaScript-deaktivierten Browsern funktioniert GTM ohnehin nicht vollständig,
aber der noscript-iframe ermöglicht grundlegendes Tracking.
--}}
@if ($enabled && $gtmId)
{{--
Alpine.js Wrapper für dynamische Anzeige basierend auf Cookie-Consent
Der noscript-Teil wird erst nach Zustimmung in den DOM eingefügt
--}}
<div x-data="{
gtmId: '{{ $gtmId }}',
cookieName: '{{ $cookieName }}',
hasConsent: false,
init() {
this.checkConsent();
// Auf Consent-Updates hören
window.addEventListener('cookie-consent-updated', (e) => {
this.hasConsent = e.detail && e.detail.analytics === true;
});
// Auf GTM-Load-Event hören
window.addEventListener('gtm-consent-granted', () => {
this.hasConsent = true;
});
},
checkConsent() {
const cookie = this.getCookie(this.cookieName);
if (cookie) {
try {
const settings = JSON.parse(cookie);
this.hasConsent = settings.analytics === true;
} catch (e) {
this.hasConsent = false;
}
}
},
getCookie(name) {
const nameEQ = name + '=';
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
}">
{{-- GTM noscript iframe - wird nur bei Consent angezeigt --}}
<template x-if="hasConsent">
<noscript>
<iframe x-bind:src="'https://www.googletagmanager.com/ns.html?id=' + gtmId" height="0" width="0"
style="display:none;visibility:hidden" title="Google Tag Manager"></iframe>
</noscript>
</template>
</div>
{{--
Fallback für Server-Side Rendering:
Wenn der Cookie bereits existiert und Analytics erlaubt,
können wir den noscript-Teil direkt rendern
--}}
@php
$cookieValue = request()->cookie($cookieName);
$serverSideConsent = false;
if ($cookieValue) {
try {
$decoded = json_decode($cookieValue, true);
$serverSideConsent = isset($decoded['analytics']) && $decoded['analytics'] === true;
} catch (\Exception $e) {
$serverSideConsent = false;
}
}
@endphp
@if ($serverSideConsent)
<!-- Google Tag Manager (noscript) -->
<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id={{ $gtmId }}" height="0" width="0"
style="display:none;visibility:hidden" title="Google Tag Manager"></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
@endif
@endif

View file

@ -0,0 +1,503 @@
@props([
'analyticsId' => config('cookie-consent.analytics_id'),
'gtmId' => config('cookie-consent.gtm_id'),
'enabled' => config('cookie-consent.enabled', true),
'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365),
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
'links' => config('cookie-consent.links', []),
'buttonPosition' => config('cookie-consent.button_position', 'bottom-left'),
'anonymizeIp' => config('cookie-consent.anonymize_ip', true),
'colors' => config('cookie-consent.colors', []),
])
@php
$links = array_merge(
[
'privacy' => '/datenschutz',
'imprint' => '/impressum',
],
$links ?? [],
);
$colors = array_merge(
[
'primary' => '#009bdd',
'primary_hover' => '#0071a8',
'accept' => '#16a34a',
'accept_hover' => '#15803d',
'save' => '#1f2937',
'save_hover' => '#111827',
'button_bg' => '#1f2937',
'button_hover' => '#374151',
],
$colors ?? [],
);
$positionClasses = $buttonPosition === 'bottom-right' ? 'right-4' : 'left-4';
// Bestimme ob GTM oder Analytics verwendet wird (GTM hat Priorität)
$useGtm = !empty($gtmId);
$useAnalytics = !empty($analyticsId) && !$useGtm;
$hasTracking = $useGtm || $useAnalytics;
@endphp
@if ($enabled)
{{-- CSS-Variablen für Farben --}}
<style>
.cookie-consent-wrapper {
--cc-primary: {{ $colors['primary'] }};
--cc-primary-hover: {{ $colors['primary_hover'] }};
--cc-accept: {{ $colors['accept'] }};
--cc-accept-hover: {{ $colors['accept_hover'] }};
--cc-save: {{ $colors['save'] }};
--cc-save-hover: {{ $colors['save_hover'] }};
--cc-button-bg: {{ $colors['button_bg'] }};
--cc-button-hover: {{ $colors['button_hover'] }};
}
.cookie-consent-wrapper .cc-btn-accept {
background-color: var(--cc-accept);
}
.cookie-consent-wrapper .cc-btn-accept:hover {
background-color: var(--cc-accept-hover);
}
.cookie-consent-wrapper .cc-btn-save {
background-color: var(--cc-save);
}
.cookie-consent-wrapper .cc-btn-save:hover {
background-color: var(--cc-save-hover);
}
.cookie-consent-wrapper .cc-floating-btn {
background-color: var(--cc-button-bg);
}
.cookie-consent-wrapper .cc-floating-btn:hover {
background-color: var(--cc-button-hover);
}
.cookie-consent-wrapper .cc-icon-bg {
background-color: color-mix(in srgb, var(--cc-primary) 15%, transparent);
}
.cookie-consent-wrapper .cc-icon-color {
color: var(--cc-primary);
}
.cookie-consent-wrapper .cc-toggle-active {
background-color: var(--cc-primary);
}
.cookie-consent-wrapper .cc-toggle-inactive {
background-color: #d1d5db;
}
.cookie-consent-wrapper .cc-link:hover {
color: var(--cc-primary);
}
</style>
{{-- Google Consent Mode v2: Default-Einstellungen VOR dem Laden von GA --}}
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
// Consent Mode v2 Defaults - alles verweigert bis Nutzer entscheidet
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'functionality_storage': 'granted',
'personalization_storage': 'denied',
'security_storage': 'granted',
'wait_for_update': 500
});
</script>
<div x-data="cookieConsentManager({
analyticsId: '{{ $analyticsId }}',
gtmId: '{{ $gtmId }}',
useGtm: {{ $useGtm ? 'true' : 'false' }},
cookieLifetime: {{ $cookieLifetime }},
cookieName: '{{ $cookieName }}',
anonymizeIp: {{ $anonymizeIp ? 'true' : 'false' }}
})" x-init="init()" @open-cookie-settings.window="openModal()"
class="cookie-consent-wrapper">
{{-- Floating Button --}}
<button x-show="!isOpen && hasConsented" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-75" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-75" @click="openModal()"
class="cc-floating-btn fixed bottom-2 {{ $positionClasses }} z-40 text-white p-3 rounded-full shadow-lg hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-300 cursor-pointer"
style="--tw-ring-color: {{ $colors['primary'] }}"
aria-label="{{ __('cookie-consent::cookie-consent.aria.open_settings') }}"
title="{{ __('cookie-consent::cookie-consent.modal.title') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
{{-- Modal Backdrop + Dialog --}}
<div x-show="isOpen" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 overflow-y-auto" style="display: none;" role="dialog" aria-modal="true"
aria-labelledby="cookie-consent-title">
{{-- Backdrop --}}
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="closeIfAllowed()"></div>
{{-- Dialog Container --}}
<div class="flex min-h-full items-center justify-center p-4">
<div x-show="isOpen" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative bg-white rounded-xl shadow-2xl max-w-lg w-full overflow-hidden"
@click.away="closeIfAllowed()" x-trap.noscroll="isOpen">
{{-- Header --}}
<div class="px-6 pt-6 pb-4">
<div class="flex items-start gap-4">
<div
class="cc-icon-bg flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="cc-icon-color h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h2 id="cookie-consent-title" class="text-xl font-bold text-gray-900">
{{ __('cookie-consent::cookie-consent.modal.title') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('cookie-consent::cookie-consent.modal.description') }}
</p>
</div>
</div>
</div>
{{-- Cookie Options --}}
<div class="px-6 py-4 bg-gray-50 border-y border-gray-200 space-y-4">
{{-- Essenzielle Cookies (immer aktiv) --}}
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<label class="font-medium text-gray-900">
{{ __('cookie-consent::cookie-consent.categories.essential.title') }}
</label>
<p class="text-xs text-gray-500 mt-0.5">
{{ __('cookie-consent::cookie-consent.categories.essential.description') }}
</p>
</div>
<div class="flex-shrink-0">
<button type="button" disabled
class="cc-toggle-active relative inline-flex h-6 w-11 flex-shrink-0 cursor-not-allowed rounded-full opacity-60 cursor-pointer"
role="switch" aria-checked="true"
aria-label="{{ __('cookie-consent::cookie-consent.aria.essential_always_active') }}">
<span
class="translate-x-5 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5"></span>
</button>
</div>
</div>
{{-- Analytics Cookies (optional, nur wenn Analytics ID oder GTM ID vorhanden) --}}
@if ($hasTracking)
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<label for="analytics-toggle" class="font-medium text-gray-900 cursor-pointer">
{{ __('cookie-consent::cookie-consent.categories.analytics.title') }}
</label>
<p class="text-xs text-gray-500 mt-0.5">
{{ __('cookie-consent::cookie-consent.categories.analytics.description') }}
</p>
</div>
<div class="flex-shrink-0">
<button type="button" id="analytics-toggle"
@click="tempSettings.analytics = !tempSettings.analytics"
:class="tempSettings.analytics ? 'cc-toggle-active' : 'cc-toggle-inactive'"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer"
:style="tempSettings.analytics ? '--tw-ring-color: {{ $colors['primary'] }}' : ''"
role="switch" :aria-checked="tempSettings.analytics.toString()"
aria-label="{{ __('cookie-consent::cookie-consent.aria.toggle_analytics') }}">
<span :class="tempSettings.analytics ? 'translate-x-5' : 'translate-x-0.5'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5"></span>
</button>
</div>
</div>
@endif
</div>
{{-- Footer Links --}}
<div class="px-6 py-3 bg-gray-50 border-b border-gray-200">
<div class="flex items-center justify-center gap-4 text-xs text-gray-500">
<a href="{{ $links['privacy'] }}" class="cc-link hover:underline transition-colors">
{{ __('cookie-consent::cookie-consent.links.privacy') }}
</a>
<span class="text-gray-300">|</span>
<a href="{{ $links['imprint'] }}" class="cc-link hover:underline transition-colors">
{{ __('cookie-consent::cookie-consent.links.imprint') }}
</a>
</div>
</div>
{{-- Action Buttons --}}
<div class="px-6 py-4 bg-white">
<div class="flex flex-col sm:flex-row gap-3">
<button @click="acceptAll()"
class="cc-btn-accept flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer"
style="--tw-ring-color: {{ $colors['accept'] }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7" />
</svg>
{{ __('cookie-consent::cookie-consent.buttons.accept_all') }}
</button>
<button @click="saveSettings()"
class="cc-btn-save flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200 cursor-pointer"
style="--tw-ring-color: {{ $colors['save'] }}">
{{ __('cookie-consent::cookie-consent.buttons.save_selection') }}
</button>
<button @click="rejectAll()"
class="flex-1 inline-flex justify-center items-center px-4 py-2.5 border border-gray-300 text-sm font-semibold rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors duration-200 cursor-pointer">
{{ __('cookie-consent::cookie-consent.buttons.reject_all') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- Cookie Consent Manager Script --}}
<script>
function cookieConsentManager(config) {
return {
isOpen: false,
hasConsented: false,
analyticsLoaded: false,
gtmLoaded: false,
config: config,
settings: {
analytics: false,
},
tempSettings: {
analytics: false,
},
init() {
const saved = this.getCookie(this.config.cookieName);
if (saved) {
try {
this.settings = JSON.parse(saved);
this.tempSettings = {
...this.settings
};
this.hasConsented = true;
this.isOpen = false;
// Consent Mode aktualisieren und ggf. Analytics laden
this.applyConsent();
} catch (e) {
console.error('Cookie Consent: Error parsing settings', e);
this.isOpen = true;
}
} else {
// Erstbesuch -> Modal öffnen
this.isOpen = true;
}
// Prüfen ob ein Cookie-Blocker-Plugin das Modal versteckt hat
this.$nextTick(() => {
this.checkVisibility();
});
},
checkVisibility() {
if (!this.isOpen) return;
const wrapper = this.$root;
if (!wrapper) return;
// Prüfen ob das Element oder ein Eltern-Element versteckt wurde
const computedStyle = window.getComputedStyle(wrapper);
const isHidden = computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden' ||
wrapper.offsetParent === null;
if (isHidden) {
console.log('Cookie Consent: Banner wurde durch externes Plugin versteckt, Scroll freigeben');
this.isOpen = false;
this.hasConsented = true; // Verhindert erneutes Öffnen
}
},
openModal() {
this.tempSettings = {
...this.settings
};
this.isOpen = true;
},
closeIfAllowed() {
// Modal nur schließen wenn bereits eine Entscheidung getroffen wurde
if (this.hasConsented) {
this.isOpen = false;
}
},
acceptAll() {
this.settings.analytics = true;
this.save();
},
rejectAll() {
this.settings.analytics = false;
this.save();
},
saveSettings() {
this.settings = {
...this.tempSettings
};
this.save();
},
save() {
this.setCookie(
this.config.cookieName,
JSON.stringify(this.settings),
this.config.cookieLifetime
);
this.hasConsented = true;
this.isOpen = false;
this.applyConsent();
// Event für andere Komponenten (z.B. Privacy-Info) dispatchen
window.dispatchEvent(new CustomEvent('cookie-consent-updated', {
detail: this.settings
}));
},
applyConsent() {
const analyticsStatus = this.settings.analytics ? 'granted' : 'denied';
// Google Consent Mode v2 Update
if (typeof gtag === 'function') {
gtag('consent', 'update', {
'analytics_storage': analyticsStatus,
'ad_storage': analyticsStatus,
'ad_user_data': analyticsStatus,
'ad_personalization': analyticsStatus
});
}
// Tracking laden wenn zugestimmt
if (this.settings.analytics) {
// GTM hat Priorität über direktes Analytics
if (this.config.useGtm && this.config.gtmId && !this.gtmLoaded) {
this.loadGoogleTagManager();
} else if (!this.config.useGtm && this.config.analyticsId && !this.analyticsLoaded) {
this.loadGoogleAnalytics();
}
}
},
loadGoogleAnalytics() {
if (this.analyticsLoaded || !this.config.analyticsId) return;
// gtag.js Script laden
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.config.analyticsId}`;
script.onload = () => {
gtag('js', new Date());
const configOptions = {
'send_page_view': true
};
if (this.config.anonymizeIp) {
configOptions['anonymize_ip'] = true;
}
gtag('config', this.config.analyticsId, configOptions);
this.analyticsLoaded = true;
console.log('Cookie Consent: Google Analytics loaded');
};
document.head.appendChild(script);
},
loadGoogleTagManager() {
if (this.gtmLoaded || !this.config.gtmId) return;
// Google Tag Manager Script laden
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', this.config.gtmId);
this.gtmLoaded = true;
// Event für GTM noscript-Komponente dispatchen
window.dispatchEvent(new CustomEvent('gtm-consent-granted', {
detail: {
gtmId: this.config.gtmId
}
}));
console.log('Cookie Consent: Google Tag Manager loaded');
},
// Cookie Helper Functions
setCookie(name, value, days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = "expires=" + date.toUTCString();
const sameSite = "SameSite=Lax";
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${encodeURIComponent(value)}; ${expires}; path=/; ${sameSite}${secure}`;
},
getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
},
deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
}
}
</script>
@endif

View file

@ -0,0 +1,279 @@
@props([
'analyticsId' => config('cookie-consent.analytics_id'),
'cookieName' => config('cookie-consent.cookie_name', 'cookie_consent'),
'cookieLifetime' => config('cookie-consent.cookie_lifetime', 365),
'showAnalyticsInfo' => true,
'showCurrentSettings' => true,
'showChangeButton' => true,
'colors' => config('cookie-consent.colors', []),
])
@php
$colors = array_merge(
[
'primary' => '#009bdd',
'primary_hover' => '#0071a8',
],
$colors ?? [],
);
@endphp
<div x-data="cookiePrivacyInfo({ cookieName: '{{ $cookieName }}' })" x-init="init()"
style="--cc-primary: {{ $colors['primary'] }}; --cc-primary-hover: {{ $colors['primary_hover'] }}"
{{ $attributes->merge(['class' => 'cookie-privacy-info']) }}>
{{-- Aktuelle Cookie-Einstellungen --}}
@if ($showCurrentSettings)
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<h4 class="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ __('cookie-consent::cookie-consent.privacy_info.current_settings_title') }}
</h4>
<div class="space-y-2">
{{-- Technisch notwendig --}}
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-700">
{{ __('cookie-consent::cookie-consent.privacy_info.essential_cookies') }}
</span>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
{{ __('cookie-consent::cookie-consent.status.active') }}
</span>
</div>
{{-- Analyse- und Marketing-Cookies (immer anzeigen) --}}
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
<span class="text-gray-700">
{{ __('cookie-consent::cookie-consent.privacy_info.analytics_marketing_cookies') }}
</span>
<span x-show="settings.analytics"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
{{ __('cookie-consent::cookie-consent.status.active') }}
</span>
<span x-show="!settings.analytics"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
{{ __('cookie-consent::cookie-consent.status.inactive') }}
</span>
</div>
</div>
{{-- Keine Einstellung vorhanden --}}
<div x-show="!hasConsented" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p class="text-sm text-yellow-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline mr-1" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ __('cookie-consent::cookie-consent.status.no_consent') }}
</p>
</div>
{{-- Button zum Ändern --}}
@if ($showChangeButton)
<div class="mt-4">
<button @click="$dispatch('open-cookie-settings')" type="button"
class="inline-flex items-center px-4 py-2 border shadow-sm text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200"
style="background-color: var(--cc-primary); border-color: var(--cc-primary); --tw-ring-color: var(--cc-primary)"
onmouseover="this.style.backgroundColor='var(--cc-primary-hover)'"
onmouseout="this.style.backgroundColor='var(--cc-primary)'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ __('cookie-consent::cookie-consent.buttons.change_settings') }}
</button>
</div>
@endif
</div>
@endif
{{-- Google Analytics Informationen --}}
@if ($showAnalyticsInfo && $analyticsId)
<div class="prose prose-gray max-w-none">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.title') }}
</h3>
<p class="text-gray-600 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.intro') }}
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<h4 class="font-medium text-blue-900 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.title') }}
</h4>
<ul class="text-sm text-blue-800 space-y-1">
<li>
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.tracking_id') }}:</strong>
<code class="bg-blue-100 px-1 rounded">{{ $analyticsId }}</code>
</li>
<li>
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization') }}:</strong>
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.ip_anonymization_value') }}
</li>
<li>
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode') }}:</strong>
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.consent_mode_value') }}
</li>
<li>
<strong>{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration') }}:</strong>
{{ __('cookie-consent::cookie-consent.google_analytics.technical_details.cookie_duration_value', ['days' => $cookieLifetime]) }}
</li>
</ul>
</div>
<h4 class="font-medium text-gray-900 mt-4 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.purpose.title') }}
</h4>
<p class="text-gray-600 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.purpose.text') }}
</p>
<h4 class="font-medium text-gray-900 mt-4 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.title') }}
</h4>
<p class="text-gray-600 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.ip_anonymization.text') }}
</p>
<h4 class="font-medium text-gray-900 mt-4 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.title') }}
</h4>
<p class="text-gray-600 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.legal_basis.text') }}
</p>
<h4 class="font-medium text-gray-900 mt-4 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.title') }}
</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.name') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.purpose') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ __('cookie-consent::cookie-consent.google_analytics.cookies.table.duration') }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach (__('cookie-consent::cookie-consent.google_analytics.cookies.list') as $cookieName => $cookie)
<tr>
<td class="px-4 py-3 text-sm font-mono text-gray-900">{{ $cookieName }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $cookie['purpose'] }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $cookie['duration'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<h4 class="font-medium text-gray-900 mt-6 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.objection.title') }}
</h4>
<p class="text-gray-600 mb-4">
{{ __('cookie-consent::cookie-consent.google_analytics.objection.text') }}
</p>
<ul class="list-disc list-inside text-gray-600 mb-4 space-y-1">
<li>{{ __('cookie-consent::cookie-consent.google_analytics.objection.options.revoke') }}</li>
<li>
{{ __('cookie-consent::cookie-consent.google_analytics.objection.options.addon') }}
<a href="https://tools.google.com/dlpage/gaoptout" target="_blank" rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 underline">
tools.google.com/dlpage/gaoptout
</a>
</li>
</ul>
<h4 class="font-medium text-gray-900 mt-4 mb-2">
{{ __('cookie-consent::cookie-consent.google_analytics.more_info.title') }}
</h4>
<p class="text-gray-600">
{{ __('cookie-consent::cookie-consent.google_analytics.more_info.text') }}
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 underline">
policies.google.com/privacy
</a>
</p>
</div>
@endif
</div>
<script>
function cookiePrivacyInfo(config) {
return {
hasConsented: false,
settings: {
analytics: false,
},
config: config,
init() {
this.loadSettings();
// Auf Änderungen lauschen (wenn Modal geschlossen wird)
window.addEventListener('cookie-consent-updated', () => {
this.loadSettings();
});
},
loadSettings() {
const saved = this.getCookie(this.config.cookieName);
if (saved) {
try {
this.settings = JSON.parse(saved);
this.hasConsented = true;
} catch (e) {
console.error('Cookie Privacy Info: Error parsing settings', e);
}
} else {
this.hasConsented = false;
this.settings = {
analytics: false
};
}
},
getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
}
}
</script>

View file

@ -0,0 +1,46 @@
<?php
namespace Acme\CookieConsent;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class CookieConsentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/cookie-consent.php',
'cookie-consent'
);
}
public function boot(): void
{
// Views laden
$this->loadViewsFrom(__DIR__.'/../resources/views', 'cookie-consent');
// Anonyme Blade-Komponenten registrieren (ermöglicht <x-cookie-consent::manager />)
Blade::anonymousComponentPath(__DIR__.'/../resources/views/components', 'cookie-consent');
// Übersetzungen laden
$this->loadTranslationsFrom(__DIR__.'/../lang', 'cookie-consent');
if ($this->app->runningInConsole()) {
// Config veröffentlichen
$this->publishes([
__DIR__.'/../config/cookie-consent.php' => config_path('cookie-consent.php'),
], 'cookie-consent-config');
// Views veröffentlichen
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/cookie-consent'),
], 'cookie-consent-views');
// Übersetzungen veröffentlichen
$this->publishes([
__DIR__.'/../lang' => lang_path('vendor/cookie-consent'),
], 'cookie-consent-lang');
}
}
}

View file

@ -0,0 +1,303 @@
# Acme Contact Form
Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire. Bietet Honeypot, zeitbasierte Prüfung, Inhaltsanalyse, Rate Limiting und mehr ohne externe Dienste, DSGVO-konform.
## Features
- **Spam-Schutz**: Honeypot, Zeitprüfung, Inhaltsanalyse, Wegwerf-E-Mails, Rate Limiting, IP-Blacklist, Bot-User-Agent-Erkennung
- **Konfigurierbare Felder**: Presets (simple, full) oder eigene Felddefinitionen
- **Livewire**: Reaktives Formular ohne Page-Reload
- **Flexibel**: Einfache Formulare bis komplexe Multi-Feld-Formulare
- **DSGVO-konform**: Keine externen Services, alles in-house
## Voraussetzungen
- PHP 8.2+
- Laravel 10+ / 11+ / 12+
- Livewire 3+
## Installation
### 1. Package installieren
```bash
composer require acme/contact-form
```
Für lokale Entwicklung (Path-Repository):
```json
{
"repositories": [
{
"type": "path",
"url": "package/acme/contact-form"
}
],
"require": {
"acme/contact-form": "@dev"
}
}
```
```bash
composer update acme/contact-form
```
### 2. Konfiguration
```bash
php artisan vendor:publish --tag=contact-form-config
```
In `.env`:
```env
CONTACT_FORM_RECIPIENT=kontakt@ihre-domain.de
CONTACT_FORM_MAX_ATTEMPTS=3
CONTACT_FORM_DECAY_MINUTES=15
CONTACT_FORM_MIN_FILL_TIME=3
```
### 3. Übersetzungen (optional)
```bash
php artisan vendor:publish --tag=contact-form-lang
```
## Verwendung
### Einfaches Formular (Preset: simple)
Name, E-Mail, Nachricht, Datenschutz-Checkbox:
```blade
<livewire:contact-form />
```
Oder mit explizitem Preset:
```blade
<livewire:contact-form preset="simple" subject="Kontaktanfrage" />
```
### Vollständiges Formular (Preset: full)
Vorname, Nachname, E-Mail, Telefon, Unternehmen, Nachricht, Datenschutz:
```blade
<livewire:contact-form preset="full" subject="Neue Kontaktanfrage" />
```
### Eigenes Formular mit benutzerdefinierten Feldern
Sie können beliebige Felder definieren. Jedes Feld benötigt:
- `type`: `text`, `email`, `tel`, `textarea`, `select`, `checkbox`, `honeypot`
- `rules`: Laravel-Validierungsregeln (Array)
- `label`: Anzeigename (optional)
- `placeholder`: Platzhalter (optional)
- `required`: true/false (optional, wird aus rules abgeleitet)
- `name`: Formularfeld-Name (optional, Standard: Array-Key)
- `options`: Für `select` Array [value => label] (optional)
**Beispiel: Minimales Formular (nur E-Mail + Nachricht)**
```blade
<livewire:contact-form
:fields="[
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns', 'max:150'],
'label' => 'Ihre E-Mail',
'placeholder' => 'name@beispiel.de',
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Ich stimme der Datenschutzerklärung zu.',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'website',
'rules' => ['nullable', 'string', 'max:0'],
],
]"
subject="Feedback"
/>
```
**Beispiel: Formular mit Select-Feld**
```blade
<livewire:contact-form
:fields="[
'name' => [
'type' => 'text',
'rules' => ['required', 'string', 'max:120'],
'label' => 'Name',
],
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns'],
'label' => 'E-Mail',
],
'topic' => [
'type' => 'select',
'rules' => ['required', 'string', 'in:general,support,sales'],
'label' => 'Betreff',
'placeholder' => 'Bitte wählen...',
'options' => [
'general' => 'Allgemeine Anfrage',
'support' => 'Technischer Support',
'sales' => 'Verkauf',
],
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Datenschutz akzeptiert',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'company_check',
'rules' => ['nullable', 'string', 'max:0'],
],
]"
subject="Anfrage über Kontaktformular"
/>
```
**Wichtig**: Jedes Formular sollte ein Honeypot-Feld enthalten. Das Feld wird für Nutzer unsichtbar dargestellt; Bots füllen es oft aus und werden so erkannt.
### Eigene Presets in der Config
In `config/contact-form.php` können Sie eigene Presets definieren:
```php
'presets' => [
'mein-formular' => [
'name' => [
'type' => 'text',
'rules' => ['required', 'string', 'max:120'],
'label' => 'Name',
],
'email' => [
'type' => 'email',
'rules' => ['required', 'email:rfc,dns'],
'label' => 'E-Mail',
],
'message' => [
'type' => 'textarea',
'rules' => ['required', 'string', 'max:2000'],
'label' => 'Nachricht',
],
'privacy' => [
'type' => 'checkbox',
'rules' => ['accepted'],
'label' => 'Datenschutz',
],
'honeypot' => [
'type' => 'honeypot',
'name' => 'website',
'rules' => ['nullable', 'string', 'max:0'],
],
],
],
```
Verwendung:
```blade
<livewire:contact-form preset="mein-formular" />
```
## Spam-Schutz im Detail
| Maßnahme | Beschreibung |
|----------|--------------|
| **Honeypot** | Verstecktes Feld wenn ausgefüllt → Spam |
| **Zeitprüfung** | Formular < 3 Sek. ausgefüllt Spam |
| **Inhaltsanalyse** | Spam-Keywords, URLs, XSS-Muster → Spam |
| **Wegwerf-E-Mails** | tempmail.com, mailinator.com etc. → Spam |
| **Rate Limiting** | Max. 3 Anfragen/IP in 15 Min. (konfigurierbar) |
| **IP-Blacklist** | Blockierte IPs in Config |
| **Bot-User-Agent** | curl, wget, python-requests etc. → Spam |
Bei Spam wird **immer** eine Erfolgsmeldung angezeigt (Täuschung von Bots), aber **keine E-Mail** versendet. Alle Anfragen werden geloggt.
## Service und SpamDetector direkt nutzen
Falls Sie einen eigenen Controller oder eine eigene Livewire-Komponente verwenden möchten:
```php
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
// Spam prüfen
$spamDetector = SpamDetector::fromConfig();
$isSpam = $spamDetector->detect($validatedData, $formLoadedAt);
// Anfrage verarbeiten
$service = app(ContactFormService::class);
$service->handle([
'name' => $request->input('name'),
'email' => $request->input('email'),
'message' => $request->input('message'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'is_spam' => $isSpam,
], 'Betreff der E-Mail', 'Log-Kontext');
```
## Konfiguration
| Option | Beschreibung | Standard |
|--------|--------------|----------|
| `recipient` | E-Mail-Empfänger | aus .env |
| `rate_limit.max_attempts` | Max. Anfragen pro IP | 3 |
| `rate_limit.decay_minutes` | Zeitfenster in Minuten | 15 |
| `blacklisted_ips` | IP-Adressen blockieren | [] |
| `honeypot_fields` | Namen der Honeypot-Felder | company_check, website, url |
| `spam.min_fill_time_seconds` | Min. Zeit zum Ausfüllen | 3 |
| `spam.suspicious_patterns` | Regex für Spam-Erkennung | siehe Config |
| `spam.disposable_email_domains` | Wegwerf-Domains | siehe Config |
## Styling anpassen
Die Standard-Views nutzen generische Klassen (`border-gray-300`, `focus:ring-primary`). Passen Sie sie an Ihr Design an:
```bash
php artisan vendor:publish --tag=contact-form-views
```
Die Views liegen dann unter `resources/views/vendor/contact-form/`.
## Event nach Absenden
Nach erfolgreichem Absenden wird das Event `contact-form-submitted` dispatched. Sie können darauf reagieren:
```blade
<div x-data="{ submitted: false }"
x-on:contact-form-submitted.window="submitted = true">
<livewire:contact-form />
<template x-if="submitted">
<p>Vielen Dank für Ihre Nachricht!</p>
</template>
</div>
```
## Lizenz
MIT License

View file

@ -0,0 +1,38 @@
{
"name": "acme/contact-form",
"description": "Ein wiederverwendbares, spam-geschütztes Kontaktformular-Package für Laravel mit Livewire",
"type": "library",
"license": "MIT",
"keywords": [
"laravel",
"livewire",
"contact-form",
"spam-protection",
"honeypot"
],
"autoload": {
"psr-4": {
"Acme\\ContactForm\\": "src/"
}
},
"authors": [
{
"name": "Acme",
"email": "info@acme.de"
}
],
"require": {
"php": "^8.2",
"illuminate/support": "^10.0|^11.0|^12.0",
"livewire/livewire": "^3.0|^4.0"
},
"extra": {
"laravel": {
"providers": [
"Acme\\ContactForm\\ContactFormServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View file

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Email Recipient
|--------------------------------------------------------------------------
|
| The email address that contact form submissions will be sent to.
|
*/
'recipient' => env('CONTACT_FORM_RECIPIENT', 'contact@example.com'),
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| Maximum number of submissions per IP within the given time window.
|
*/
'rate_limit' => [
'max_attempts' => env('CONTACT_FORM_MAX_ATTEMPTS', 3),
'decay_minutes' => env('CONTACT_FORM_DECAY_MINUTES', 15),
],
/*
|--------------------------------------------------------------------------
| IP Blacklist
|--------------------------------------------------------------------------
|
| IP addresses that will always be blocked as spam.
|
*/
'blacklisted_ips' => [],
/*
|--------------------------------------------------------------------------
| Spam Detection
|--------------------------------------------------------------------------
|
| Configuration for the built-in spam detection.
|
*/
'honeypot_fields' => ['company_check', 'website', 'url', 'website_url'],
'spam' => [
'min_fill_time_seconds' => env('CONTACT_FORM_MIN_FILL_TIME', 3),
'suspicious_patterns' => [
'/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto)\b/i',
'/\b[A-Z]{10,}\b/',
'/<script|<iframe|javascript:/i',
],
'max_links_in_message' => 2,
'max_repeated_chars' => 10,
'disposable_email_domains' => [
'tempmail.com',
'guerrillamail.com',
'10minutemail.com',
'mailinator.com',
'throwaway.email',
'yopmail.com',
],
],
/*
|--------------------------------------------------------------------------
| Form Presets
|--------------------------------------------------------------------------
|
| Predefined field configurations for different form types.
| See README for customization and defining your own forms.
|
*/
'presets' => [
'simple' => [
'name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:120'], 'label' => 'Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'website', 'rules' => ['nullable', 'string', 'max:0']],
],
'full' => [
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
],
'business' => [
'first_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'First Name', 'required' => true],
'last_name' => ['type' => 'text', 'rules' => ['required', 'string', 'max:100'], 'label' => 'Last Name', 'required' => true],
'email' => ['type' => 'email', 'rules' => ['required', 'email:rfc,dns', 'max:150'], 'label' => 'Email', 'required' => true],
'phone' => ['type' => 'tel', 'rules' => ['nullable', 'string', 'max:80'], 'label' => 'Phone', 'required' => false],
'company' => ['type' => 'text', 'rules' => ['required', 'string', 'max:150'], 'label' => 'Company', 'required' => true],
'project_type' => [
'type' => 'select',
'rules' => ['nullable', 'string', 'max:150'],
'label' => 'Project Type',
'options' => [
'consulting' => 'Consulting',
'implementation' => 'Implementation',
'support' => 'Support',
],
],
'message' => ['type' => 'textarea', 'rules' => ['required', 'string', 'max:2000'], 'label' => 'Message', 'required' => true],
'timeline' => [
'type' => 'select',
'rules' => ['nullable', 'string', 'max:150'],
'label' => 'Timeline',
'options' => [
'asap' => 'As soon as possible',
'3months' => 'Within 3 months',
'6months' => 'Within 6 months',
],
],
'privacy' => ['type' => 'checkbox', 'rules' => ['accepted'], 'label' => 'Privacy Policy', 'required' => true],
'honeypot' => ['type' => 'honeypot', 'name' => 'company_check', 'rules' => ['nullable', 'string', 'max:0']],
],
],
];

View file

@ -0,0 +1,9 @@
<?php
return [
'default_subject' => 'Neue Kontaktanfrage',
'success_message' => 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.',
'submit' => 'Absenden',
'sending' => 'Wird gesendet...',
'select_placeholder' => 'Bitte wählen...',
];

View file

@ -0,0 +1,9 @@
<?php
return [
'default_subject' => 'New Contact Request',
'success_message' => 'Thank you! Your message has been sent successfully.',
'submit' => 'Submit',
'sending' => 'Sending...',
'select_placeholder' => 'Please select...',
];

View file

@ -0,0 +1,92 @@
<div>
<form wire:submit="submit" class="space-y-6">
@foreach ($fields as $key => $field)
@php
$name = $field['name'] ?? $key;
$label = $field['label'] ?? ucfirst(str_replace('_', ' ', $name));
$required = $field['required'] ?? (in_array('required', $field['rules'] ?? []));
$placeholder = $field['placeholder'] ?? '';
@endphp
@if ($field['type'] === 'honeypot')
<div class="opacity-0 absolute top-0 left-0 h-0 w-0 -z-10 overflow-hidden" aria-hidden="true">
<label for="{{ $name }}">{{ $label }}</label>
<input type="text" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
tabindex="-1" autocomplete="off">
</div>
@elseif ($field['type'] === 'textarea')
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<textarea id="{{ $name }}" name="{{ $name }}" rows="5"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ $placeholder }}"
@if ($required) required @endif></textarea>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@elseif ($field['type'] === 'checkbox')
<div>
<div class="flex items-center gap-2">
<input type="checkbox" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="rounded border-gray-300"
@if ($required) required @endif>
<label for="{{ $name }}" class="text-sm">{{ $label }}</label>
</div>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@elseif ($field['type'] === 'select')
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<select id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
@if ($required) required @endif>
<option value="">{{ $placeholder ?: __('contact-form::contact-form.select_placeholder') }}</option>
@foreach ($field['options'] ?? [] as $value => $optionLabel)
<option value="{{ $value }}">{{ $optionLabel }}</option>
@endforeach
</select>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@else
<div>
<label for="{{ $name }}" class="block text-sm font-medium mb-2">
{{ $label }}@if ($required) <span aria-hidden="true">*</span>@endif
</label>
<input type="{{ $field['type'] ?? 'text' }}" id="{{ $name }}" name="{{ $name }}"
wire:model="formData.{{ $name }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ $placeholder }}"
@if ($required) required @endif>
@error("formData.{$name}")
<p class="text-sm text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
@endif
@endforeach
@if ($success)
<div class="p-4 rounded-lg border border-green-200 bg-green-50 text-green-800 text-sm">
{{ __('contact-form::contact-form.success_message') }}
</div>
@endif
<button type="submit" wire:loading.attr="disabled"
class="w-full inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-70 disabled:cursor-not-allowed">
<span wire:loading.remove>{{ __('contact-form::contact-form.submit') }}</span>
<span wire:loading>{{ __('contact-form::contact-form.sending') }}</span>
</button>
</form>
</div>

View file

@ -0,0 +1,135 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class ContactFormService
{
public function __construct(
protected array $config = []
) {
$this->config = array_merge(
config('contact-form', []),
$config
);
}
/**
* Processes a contact form submission.
*/
public function handle(array $payload, string $subject = '', string $logContext = 'contact-form'): void
{
$isSpam = $payload['is_spam'] ?? false;
if (! $isSpam) {
$isSpam = $this->performServerSideSpamCheck($payload);
$payload['is_spam'] = $isSpam;
}
if ($isSpam) {
Log::info("SPAM detected - {$logContext}", $payload);
Log::info("Email NOT sent (spam detected - {$logContext})", [
'ip' => $payload['ip'] ?? 'unknown',
'email' => $payload['email'] ?? 'unknown',
]);
} else {
Log::info("{$logContext} received", $payload);
$this->notify($subject ?: __('contact-form::contact-form.default_subject'), $payload);
}
}
protected function notify(string $subject, array $payload): void
{
$recipient = $this->config['recipient'] ?? null;
if (empty($recipient)) {
return;
}
$body = $this->formatMailBody($subject, $payload);
try {
Mail::raw($body, function ($message) use ($recipient, $subject): void {
$message->to($recipient)->subject($subject);
});
} catch (\Throwable $e) {
Log::warning('Contact form email could not be sent', [
'error' => $e->getMessage(),
]);
}
}
protected function formatMailBody(string $subject, array $payload): string
{
$lines = [$subject, str_repeat('-', 40)];
foreach ($payload as $key => $value) {
if ($key === 'is_spam') {
continue;
}
$value = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
$label = ucfirst(str_replace('_', ' ', $key));
$lines[] = "{$label}: {$value}";
}
return implode(PHP_EOL, $lines);
}
protected function performServerSideSpamCheck(array $payload): bool
{
$ip = $payload['ip'] ?? 'unknown';
$config = $this->config;
$maxAttempts = $config['rate_limit']['max_attempts'] ?? 3;
$decayMinutes = $config['rate_limit']['decay_minutes'] ?? 15;
$cacheKey = 'contact_form_'.md5($ip);
$attempts = cache()->get($cacheKey, 0);
if ($attempts >= $maxAttempts) {
Log::warning('Contact form rate limit exceeded', [
'ip' => $ip,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
]);
return true;
}
cache()->put($cacheKey, $attempts + 1, now()->addMinutes($decayMinutes));
$blacklistedIPs = $config['blacklisted_ips'] ?? [];
if (in_array($ip, $blacklistedIPs)) {
Log::warning('Blacklisted IP detected', ['ip' => $ip]);
return true;
}
$userAgent = $payload['user_agent'] ?? '';
$botPatterns = [
'/bot/i',
'/crawler/i',
'/spider/i',
'/scraper/i',
'/curl/i',
'/wget/i',
'/python-requests/i',
];
foreach ($botPatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
Log::warning('Bot user-agent detected', [
'ip' => $ip,
'user_agent' => $userAgent,
]);
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
class ContactFormServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/contact-form.php',
'contact-form'
);
$this->app->singleton(ContactFormService::class, function (): ContactFormService {
return new ContactFormService;
});
}
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'contact-form');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'contact-form');
Livewire::component('contact-form', \Acme\ContactForm\Livewire\ContactForm::class);
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/contact-form.php' => config_path('contact-form.php'),
], 'contact-form-config');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/contact-form'),
], 'contact-form-views');
$this->publishes([
__DIR__.'/../lang' => lang_path('vendor/contact-form'),
], 'contact-form-lang');
}
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace Acme\ContactForm\Livewire;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use Illuminate\View\View;
use Livewire\Component;
class ContactForm extends Component
{
/** @var array<string, mixed> */
public array $formData = [];
public ?int $formLoadedAt = null;
public bool $success = false;
public string $preset = 'simple';
public string $subject = '';
public string $logContext = 'contact-form';
/**
* @var array<string, array{type: string, rules: array, label?: string, placeholder?: string, required?: bool, name?: string, options?: array}>
*/
public array $customFields = [];
public function mount(
string $preset = 'simple',
string $subject = '',
array $fields = []
): void {
$this->preset = $preset;
$this->subject = $subject;
$this->customFields = $fields;
$this->formLoadedAt = time();
$this->initializeFormData();
}
protected function getFields(): array
{
if (! empty($this->customFields)) {
return $this->customFields;
}
$presets = config('contact-form.presets', []);
return $presets[$this->preset] ?? $presets['simple'];
}
protected function initializeFormData(): void
{
foreach ($this->getFields() as $key => $field) {
$name = $field['name'] ?? $key;
$this->formData[$name] = match ($field['type']) {
'checkbox' => false,
default => '',
};
}
}
protected function getRules(): array
{
$rules = [];
foreach ($this->getFields() as $key => $field) {
$name = $field['name'] ?? $key;
$rules["formData.{$name}"] = $field['rules'] ?? ['nullable'];
}
return $rules;
}
public function submit(ContactFormService $service): void
{
$validated = $this->validate($this->getRules());
$flattened = $validated['formData'] ?? [];
$spamDetector = SpamDetector::fromConfig();
$isSpam = $spamDetector->detect($flattened, $this->formLoadedAt);
$payload = array_merge($flattened, [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => $isSpam,
]);
$service->handle($payload, $this->subject, $this->logContext);
$this->success = true;
$this->dispatch('contact-form-submitted');
$this->resetForm();
}
protected function resetForm(): void
{
$this->initializeFormData();
$this->formLoadedAt = time();
}
public function render(): View
{
return view('contact-form::form', [
'fields' => $this->getFields(),
]);
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace Acme\ContactForm;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class SpamDetector
{
public function __construct(
protected array $config
) {}
/**
* Checks the given data for spam indicators.
*/
public function detect(array $validated, ?int $formLoadedAt = null, ?Request $request = null): bool
{
$request = $request ?? request();
if ($this->checkHoneypot($validated, $request)) {
return true;
}
if ($this->checkFillTime($formLoadedAt, $request)) {
return true;
}
if ($this->checkContent($validated, $request)) {
return true;
}
if ($this->checkDisposableEmail($validated, $request)) {
return true;
}
return false;
}
/**
* Checks honeypot fields configured fields must be empty.
*/
protected function checkHoneypot(array $validated, Request $request): bool
{
$honeypotFields = $this->config['honeypot_fields'] ?? ['company_check', 'website', 'url', 'website_url'];
foreach ($honeypotFields as $field) {
if (isset($validated[$field]) && ! empty($validated[$field])) {
$this->logSpam('Honeypot field filled', ['field' => $field], $request);
return true;
}
}
foreach ($validated as $key => $value) {
if (str_contains((string) $key, '_check') && ! empty($value)) {
$this->logSpam('Honeypot field filled', ['field' => $key], $request);
return true;
}
}
return false;
}
/**
* Time-based check: form submitted too quickly.
*/
protected function checkFillTime(?int $formLoadedAt, Request $request): bool
{
if ($formLoadedAt === null) {
return false;
}
$minSeconds = $this->config['spam']['min_fill_time_seconds'] ?? 3;
$timeSpent = time() - $formLoadedAt;
if ($timeSpent < $minSeconds) {
$this->logSpam('Form submitted too quickly', ['time_spent' => $timeSpent], $request);
return true;
}
return false;
}
/**
* Content analysis: suspicious patterns, excessive links, repeated characters, XSS.
*/
protected function checkContent(array $validated, Request $request): bool
{
$patterns = $this->config['spam']['suspicious_patterns'] ?? [];
$maxLinks = $this->config['spam']['max_links_in_message'] ?? 2;
$maxRepeated = $this->config['spam']['max_repeated_chars'] ?? 10;
foreach ($validated as $key => $value) {
if (! is_string($value) || str_contains($key, 'honeypot')) {
continue;
}
foreach ($patterns as $pattern) {
if (preg_match($pattern, $value)) {
$this->logSpam('Suspicious content detected', ['pattern' => $pattern, 'field' => $key], $request);
return true;
}
}
$linkCount = substr_count(strtolower($value), 'http://') +
substr_count(strtolower($value), 'https://') +
substr_count(strtolower($value), 'www.');
if ($linkCount > $maxLinks) {
$this->logSpam('Too many links', ['link_count' => $linkCount], $request);
return true;
}
if (preg_match('/(.)\1{'.($maxRepeated + 1).',}/', $value)) {
$this->logSpam('Too many repeated characters', [], $request);
return true;
}
}
return false;
}
/**
* Checks for disposable/throwaway email domains.
*/
protected function checkDisposableEmail(array $validated, Request $request): bool
{
$email = $validated['email'] ?? $validated['e-mail'] ?? null;
if (empty($email) || ! is_string($email)) {
return false;
}
$domains = $this->config['spam']['disposable_email_domains'] ?? [];
$emailDomain = substr((string) strrchr($email, '@'), 1);
if (in_array(strtolower($emailDomain), $domains)) {
$this->logSpam('Disposable email address', ['domain' => $emailDomain], $request);
return true;
}
return false;
}
protected function logSpam(string $reason, array $context, Request $request): void
{
Log::warning('Spam detected (contact-form): '.$reason, array_merge([
'ip' => $request->ip(),
], $context));
}
/**
* Static factory for convenient instantiation.
*/
public static function fromConfig(?array $config = null): self
{
return new self($config ?? config('contact-form', []));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,597 +1,14 @@
# Flux CMS Installation Guide
# Installation
This guide will walk you through installing and setting up Flux CMS in your Laravel application.
> **Hinweis:** Diese Datei beschreibt das Legacy-Installationsverfahren (Page/Component-basiertes CMS).
> Für die aktuelle Content-basierte Implementierung siehe **[SETUP.md](./SETUP.md)**.
## System Requirements
Die aktuelle Version von Flux CMS arbeitet mit einem Key-Value Content Store und spezialisierten Models
(News, Industries, FAQs, Downloads, LinkedIn Posts, Team, Suchindex). Die vollständige Dokumentation
findest du in:
- **PHP**: 8.2 or higher
- **Laravel**: 11.0 or higher
- **Livewire**: 3.0 or higher
- **Database**: MySQL 8.0+ or PostgreSQL 13+
- **Node.js**: 18+ (for asset compilation)
## Required Laravel Packages
Flux CMS requires these packages to be installed:
- `spatie/laravel-translatable`: For multilingual content
- `spatie/laravel-medialibrary`: For media management
- `spatie/laravel-tags`: For tagging blog posts
- `livewire/livewire`: For reactive components
- `livewire/flux`: For UI components (recommended)
## Step-by-Step Installation
### 1. Install Required Dependencies
```bash
# Install required packages
composer require spatie/laravel-translatable spatie/laravel-medialibrary spatie/laravel-tags
# Install Livewire if not already installed
composer require livewire/livewire
# Install Flux UI (recommended for admin interface)
composer require livewire/flux
```
### 2. Install Flux CMS Packages
```bash
# Install core package
composer require flux-cms/core
# Install components package (recommended)
composer require flux-cms/components
# Install starter components (optional)
composer require flux-cms/starter-components
```
### 3. Run Installation Command
```bash
# This will publish config, run migrations, and set up permissions
php artisan flux-cms:install
# Or with options
php artisan flux-cms:install --no-migrate --no-publish
```
The installation command will:
- ✅ Check system requirements
- 📦 Publish configuration files
- 🗃️ Run database migrations
- 🔗 Create storage link
- 📝 Create sample content (optional)
- 🔐 Set up permissions (optional)
### 4. Configure Your Application
#### Environment Variables
Add these to your `.env` file:
```env
# Flux CMS Configuration
FLUX_CMS_DEFAULT_LOCALE=de
FLUX_CMS_CACHE_ENABLED=true
FLUX_CMS_ROUTES_ENABLED=true
# Media Configuration
FLUX_CMS_MEDIA_DISK=public
FLUX_CMS_MAX_FILE_SIZE=10240
# Multi-domain (if using)
FLUX_CMS_MULTI_DOMAIN=true
FLUX_CMS_AUTO_DETECT_DOMAIN=true
```
#### Update App Configuration
```php
// config/app.php
'locale' => env('APP_LOCALE', 'de'),
'fallback_locale' => 'de',
// Add available locales
'available_locales' => ['de', 'en'],
```
### 5. Set Up Routes
#### Admin Routes
Add to your `routes/web.php` or create `routes/admin.php`:
```php
// Admin routes (protected)
Route::middleware(['web', 'auth'])->prefix('admin')->name('admin.')->group(function () {
Route::prefix('cms')->name('cms.')->group(function () {
Route::get('/', [Admin\DashboardController::class, 'index'])->name('index');
Route::resource('/pages', Admin\PageController::class)->except(['show']);
Route::get('/blog', [Admin\BlogController::class, 'index'])->name('blog.index');
Route::get('/media', [Admin\MediaController::class, 'index'])->name('media.index');
Route::get('/navigation', [Admin\NavigationController::class, 'index'])->name('navigation.index');
});
});
```
#### Frontend Routes
Add to your `routes/web.php` (MUST be at the end):
```php
// SEO routes
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
// Blog routes (if using blog)
Route::prefix('blog')->name('blog.')->group(function () {
Route::get('/', [PageController::class, 'blogIndex'])->name('index');
Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show');
});
// CMS pages - MUST BE LAST!
Route::get('/{slug?}', [PageController::class, 'show'])
->where('slug', '.*')
->name('cms.page');
```
### 6. Create Controllers
#### Page Controller
```php
<?php
namespace App\Http\Controllers;
use FluxCms\Core\Models\Page;
use Illuminate\Http\Request;
class PageController extends Controller
{
public function show(Request $request, string $slug = '/')
{
// Get domain key from existing domains config
$domainKey = $this->getCurrentDomainKey($request);
$locale = app()->getLocale();
// Find page
$page = Page::forDomain($domainKey)
->bySlug($slug, $locale)
->published()
->firstOrFail();
// Load components
$components = $page->components()->get();
return view('pages.show', compact('page', 'components'));
}
public function sitemap(Request $request)
{
$domainKey = $this->getCurrentDomainKey($request);
$pages = Page::forDomain($domainKey)->published()->get();
return response()->view('sitemap', compact('pages'))
->header('Content-Type', 'application/xml');
}
protected function getCurrentDomainKey(Request $request): string
{
$host = $request->getHost();
$domains = config('domains.domains', []);
foreach ($domains as $key => $config) {
if (isset($config['url']) && parse_url($config['url'], PHP_URL_HOST) === $host) {
return $key;
}
}
return config('flux-cms.domains.default_domain', 'default');
}
}
```
#### Admin Controller
```php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use FluxCms\Core\Models\Page;
class CmsController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function index()
{
$stats = [
'pages' => Page::count(),
'published_pages' => Page::published()->count(),
'draft_pages' => Page::where('is_published', false)->count(),
];
return view('admin.cms.index', compact('stats'));
}
public function pages()
{
$pages = Page::with(['components'])
->orderBy('updated_at', 'desc')
->paginate(20);
return view('admin.cms.pages', compact('pages'));
}
public function editPage(Page $page)
{
$this->authorize('flux-cms.edit');
return view('admin.cms.edit-page', compact('page'));
}
}
```
### 7. Create Views
#### Frontend Page Template
```blade
{{-- resources/views/pages/show.blade.php --}}
@extends('layouts.app')
@section('title', $page->getSeoTitle())
@section('description', $page->getSeoDescription())
@push('meta')
<meta property="og:title" content="{{ $page->getTranslation('title') }}">
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
<meta property="og:url" content="{{ request()->url() }}">
@if($page->getTranslation('og_image'))
<meta property="og:image" content="{{ $page->getTranslation('og_image') }}">
@endif
@endpush
@section('content')
<main class="cms-page">
@foreach($components as $component)
@if($component->canRender())
<div class="cms-component" data-component="{{ class_basename($component->component_class) }}">
@livewire($component->component_class, [
'content' => $component->getTranslations('content')
], key('component-' . $component->id))
</div>
@endif
@endforeach
</main>
@endsection
```
#### Admin Views
```blade
{{-- resources/views/admin/cms/edit-page.blade.php --}}
@extends('layouts.admin')
@section('title', 'Edit Page: ' . $page->getTranslation('title'))
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@livewire('flux-cms::page-editor', ['page' => $page])
</div>
@endsection
@push('scripts')
<script>
// Enable drag & drop sorting
Livewire.on('components-reordered', (orderedIds) => {
// Handle reordering feedback
console.log('Components reordered:', orderedIds);
});
// Preview functionality
Livewire.on('open-preview', (url) => {
window.open(url, '_blank');
});
</script>
@endpush
```
### 8. Set Up Permissions
If you're using Spatie Laravel Permission:
```php
// database/seeders/CmsPermissionSeeder.php
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class CmsPermissionSeeder extends Seeder
{
public function run()
{
// Create permissions
$permissions = [
'flux-cms.view',
'flux-cms.edit',
'flux-cms.publish',
'flux-cms.delete',
'flux-cms.admin',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
// Create CMS role
$cmsRole = Role::firstOrCreate(['name' => 'flux-cms']);
$cmsRole->syncPermissions($permissions);
// Assign to users
// User::find(1)->assignRole('flux-cms');
}
}
```
Run the seeder:
```bash
php artisan db:seed --class=CmsPermissionSeeder
```
### 9. Configure Multi-Domain (Optional)
If you're using multi-domain setup, ensure your `config/domains.php` is configured:
```php
// config/domains.php
return [
'domains' => [
'main' => [
'name' => 'Main Site',
'url' => env('APP_URL', 'https://example.com'),
'theme' => 'default',
],
'blog' => [
'name' => 'Blog Site',
'url' => 'https://blog.example.com',
'theme' => 'blog',
],
],
];
```
Update Flux CMS config:
```php
// config/flux-cms.php
'domains' => [
'enabled' => true,
'config_source' => 'domains', // Use domains.php config
'auto_detect' => true,
],
```
### 10. Create Your First Component
Generate a new component:
```bash
php artisan make:livewire Components/WelcomeHero
```
Update the component:
```php
<?php
namespace App\Livewire\Components;
use Livewire\Component;
use FluxCms\Core\FieldTypes\TextField;
use FluxCms\Core\FieldTypes\WysiwygField;
class WelcomeHero extends Component
{
public array $content = [];
public function mount(array $content = [])
{
$this->content = $content;
}
public static function getCmsName(): string
{
return 'Welcome Hero';
}
public static function getCmsCategory(): string
{
return 'Content';
}
public static function getCmsFields(): array
{
return [
TextField::make('headline', 'Headline')
->translatable()
->required(),
WysiwygField::make('description', 'Description')
->translatable(),
];
}
public function render()
{
return view('livewire.components.welcome-hero');
}
}
```
Create the view:
```blade
{{-- resources/views/livewire/components/welcome-hero.blade.php --}}
<section class="hero bg-gradient-to-r from-blue-500 to-purple-600 text-white py-20">
<div class="container mx-auto px-4 text-center">
@if($headline = $this->content['headline'][app()->getLocale()] ?? '')
<h1 class="text-5xl font-bold mb-6">{{ $headline }}</h1>
@endif
@if($description = $this->content['description'][app()->getLocale()] ?? '')
<div class="text-xl prose prose-lg prose-invert mx-auto">
{!! $description !!}
</div>
@endif
</div>
</section>
```
### 11. Create Your First Page
```php
// database/seeders/CmsContentSeeder.php
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use FluxCms\Core\Models\Page;
class CmsContentSeeder extends Seeder
{
public function run()
{
$homepage = Page::create([
'domain_key' => 'main',
'title' => [
'de' => 'Startseite',
'en' => 'Homepage'
],
'slug' => [
'de' => '/',
'en' => '/'
],
'meta_description' => [
'de' => 'Willkommen auf unserer Website',
'en' => 'Welcome to our website'
],
'is_published' => true,
]);
// Add welcome hero component
$homepage->allComponents()->create([
'component_class' => \App\Livewire\Components\WelcomeHero::class,
'order' => 1,
'content' => [
'headline' => [
'de' => 'Willkommen bei Flux CMS',
'en' => 'Welcome to Flux CMS'
],
'description' => [
'de' => 'Das moderne Content Management System für Laravel.',
'en' => 'The modern Content Management System for Laravel.'
]
],
]);
}
}
```
Run the seeder:
```bash
php artisan db:seed --class=CmsContentSeeder
```
## Verification
After installation, verify everything works:
1. **Visit admin interface**: `/admin/cms`
2. **Edit a page**: Click on your homepage
3. **Add components**: Try adding components to your page
4. **Check frontend**: Visit your homepage
5. **Test translations**: Switch languages if configured
## Troubleshooting
### Common Issues
#### 1. Component Registry Empty
```bash
# Clear cache and refresh
php artisan flux-cms:clear-cache
php artisan cache:clear
```
#### 2. Permission Denied
```bash
# Check if user has CMS role
php artisan tinker
> User::find(1)->assignRole('flux-cms');
```
#### 3. Media Files Not Loading
```bash
# Ensure storage link exists
php artisan storage:link
# Check disk configuration
php artisan tinker
> Storage::disk('public')->exists('test.txt');
```
#### 4. Routes Not Working
- Ensure CMS routes are at the **end** of `routes/web.php`
- Check middleware and permissions
- Verify domain configuration if using multi-domain
### Debug Mode
Enable debug mode in config:
```php
// config/flux-cms.php
'development' => [
'debug_mode' => true,
'show_component_info' => true,
'log_queries' => true,
],
```
### Getting Help
- 📖 **Documentation**: Check the full documentation
- 💬 **Community**: Join GitHub Discussions
- 🐛 **Issues**: Report bugs on GitHub
- 📧 **Support**: Contact support team
## Next Steps
1. **Create custom components** for your specific needs
2. **Set up navigation** using the navigation manager
3. **Configure domains** if using multi-domain
4. **Customize styling** to match your brand
5. **Set up deployment** with proper caching
You're now ready to start building amazing content with Flux CMS! 🚀
- **[README.md](./README.md)** — Überblick, Features, alle Modelle, Helper-Funktionen
- **[SETUP.md](./SETUP.md)** — Schritt-für-Schritt Installationsanleitung (Erstinstallation)
- **[MIGRATION.md](./MIGRATION.md)** — Migration in ein neues Projekt (Checkliste)
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Technische Details, Datenbankschema, Datenfluss
- **[README-FILE-UPLOAD.md](./README-FILE-UPLOAD.md)** — Medienbibliothek und Bildoptimierung

View file

@ -0,0 +1,264 @@
# Flux CMS — Migration in ein neues Projekt
Diese Checkliste führt dich durch alle Schritte, um Flux CMS aus einem bestehenden Projekt in ein neues Laravel-Projekt zu migrieren.
> **Voraussetzungen:** Das neue Projekt benötigt Laravel 11+, Livewire 3, Volt, Flux UI (Free oder Pro) und Tailwind CSS v4.
---
## Schritt 1 — Package-Verzeichnis kopieren
```bash
# Das gesamte Package-Verzeichnis ins neue Projekt kopieren
cp -r altes-projekt/package/ neues-projekt/package/
```
---
## Schritt 2 — composer.json anpassen
```json
{
"repositories": [
{ "type": "path", "url": "package/flux-cms/core" }
],
"require": {
"flux-cms/core": "@dev",
"intervention/image": "^3.0",
"blade-ui-kit/blade-heroicons": "^2.0"
},
"autoload": {
"files": ["app/helpers.php"]
}
}
```
```bash
composer update
```
---
## Schritt 3 — Konfiguration und Migrations
```bash
# Config publizieren
php artisan vendor:publish --tag=flux-cms-config
# Migrations ausführen (erstellt alle flux_cms_* Tabellen)
php artisan migrate
```
**Hinweis:** Falls das Quell-Projekt zusätzliche Migrations enthält (z.B. `add_detail_columns_to_flux_cms_downloads_table`), diese ebenfalls kopieren:
```bash
cp altes-projekt/database/migrations/*flux_cms*.php neues-projekt/database/migrations/
php artisan migrate
```
---
## Schritt 4 — Helper-Funktionen einrichten
Datei `app/helpers.php` erstellen (vollständiger Inhalt in [SETUP.md](SETUP.md#21-datei-erstellen)).
```bash
composer dump-autoload
```
---
## Schritt 5 — Livewire-Komponenten kopieren
```bash
mkdir -p app/Livewire/Admin/Cms
# Aus Package-Referenz kopieren
cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php app/Livewire/Admin/Cms/
cp package/flux-cms/core/src/Helpers/MediaPicker.php app/Livewire/Admin/Cms/
cp package/flux-cms/core/src/Helpers/MediaUploader.php app/Livewire/Admin/Cms/
```
Namespace in allen drei Dateien anpassen:
```php
namespace App\Livewire\Admin\Cms;
```
---
## Schritt 6 — Admin-Views einrichten
```bash
mkdir -p resources/views/livewire/admin/cms
mkdir -p resources/views/components/layouts
# Alle Admin-Views kopieren
cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \
resources/views/livewire/admin/cms/
# Admin-Layout kopieren
cp package/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php \
resources/views/components/layouts/cms.blade.php
```
**Anpassen:**
- `resources/views/components/layouts/cms.blade.php`: Branding/Logo anpassen
- `route('dashboard')` im Layout ggf. anpassen (Redirect nach Login)
---
## Schritt 7 — Routes registrieren
In `routes/web.php`:
```php
use Livewire\Volt\Volt;
Route::middleware(['auth'])->group(function () {
Volt::route('admin/cms', 'admin.cms.dashboard')->name('cms.dashboard');
Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index');
Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index');
Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index');
Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index');
Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index');
Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index');
Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index');
Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index');
Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index');
});
```
---
## Schritt 8 — Storage-Link erstellen
```bash
php artisan storage:link
```
---
## Schritt 9 — Seeders kopieren (optional)
Falls Inhalte aus dem alten Projekt übernommen werden sollen:
```bash
# Referenz-Seeders als Ausgangspunkt
cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/
```
Die Seeders müssen für das neue Projekt angepasst werden (andere Inhalte, andere Bilder).
Ausführungsreihenfolge in `DatabaseSeeder`:
```php
$this->call([
CmsContentSeeder::class,
CmsMediaSeeder::class,
CmsNewsItemSeeder::class,
CmsIndustrySeeder::class,
CmsFaqSeeder::class,
CmsLinkedinPostSeeder::class,
CmsDownloadSeeder::class,
CmsSearchIndexSeeder::class,
]);
```
---
## Schritt 10 — Medien migrieren (optional)
Falls Medien aus dem alten Projekt übernommen werden sollen:
```bash
# Storage-Verzeichnis kopieren
cp -r altes-projekt/storage/app/public/cms/ neues-projekt/storage/app/public/cms/
```
Dann den `CmsMediaSeeder` ausführen, um die DB-Einträge wiederherzustellen:
```bash
php artisan db:seed --class=CmsMediaSeeder
```
---
## Schritt 11 — Vite Build
```bash
npm install
npm run build
# oder für Entwicklung:
npm run dev
```
---
## Schritt 12 — Tests einrichten (optional)
```bash
# Referenz-Tests kopieren
cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms
# Tests ausführen
php artisan test --filter=Cms
```
---
## Checkliste
- [ ] Package-Verzeichnis kopiert (`package/flux-cms/`)
- [ ] `composer.json` angepasst (Repository, Require, Autoload)
- [ ] `composer update` ausgeführt
- [ ] Config publiziert (`vendor:publish --tag=flux-cms-config`)
- [ ] Migrations ausgeführt (inkl. projektspezifische)
- [ ] `app/helpers.php` erstellt
- [ ] `composer dump-autoload` ausgeführt
- [ ] Livewire-Komponenten kopiert + Namespace angepasst
- [ ] Admin-Views kopiert
- [ ] Layout angepasst (Branding, Route-Namen)
- [ ] Routes registriert
- [ ] `storage:link` ausgeführt
- [ ] Seeders angepasst und ausgeführt (optional)
- [ ] Medien migriert (optional)
- [ ] Vite Build ausgeführt
- [ ] Admin-Login testen: `/admin/cms`
---
## Häufige Probleme
### "View not found" für `livewire.admin.cms.*`
→ Prüfen ob Volt-Mount-Path `resources/views/livewire/` in `VoltServiceProvider` registriert ist.
### Bilder werden nicht angezeigt
`php artisan storage:link` ausführen. Prüfen ob `APP_URL` in `.env` korrekt gesetzt ist.
### "Route [cms.dashboard] not defined"
→ Sicherstellen dass alle CMS-Routes in `routes/web.php` registriert sind.
### Bilder hinter HTTPS-Proxy haben falsche URLs
→ In `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
})
```
Und in `AppServiceProvider::boot()`:
```php
if (request()->header('X-Forwarded-Proto') === 'https') {
URL::forceScheme('https');
}
```
### Flux UI `<flux:toast />` fehlt
→ Das Layout `resources/views/components/layouts/cms.blade.php` muss `<flux:toast />` enthalten (bereits im Referenz-Layout vorhanden).
---
## Weiterführende Dokumentation
- [README.md](README.md) — Überblick, alle Features, Helper-Funktionen
- [SETUP.md](SETUP.md) — Detaillierte Schritt-für-Schritt-Anleitung
- [ARCHITECTURE.md](ARCHITECTURE.md) — Datenbankschema, Datenfluss, Komponenten-Architektur
- [README-FILE-UPLOAD.md](README-FILE-UPLOAD.md) — Medienbibliothek im Detail

View file

@ -0,0 +1,458 @@
# File Upload mit Livewire Volt + Flux UI
Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist.
---
## Überblick
Der Upload nutzt:
- **Livewire `WithFileUploads`** verwaltet temporäre Uploads via signierter URL
- **Flux UI `flux:file-upload`** UI-Komponente (Dropzone + Vorschau)
- **Laravel `Storage::disk('public')`** Permanente Speicherung
- **Polymorphe `media`-Tabelle** Zuordnung von Dateien zu beliebigen Models
- **Alpine.js** Drag-&-Drop-Sortierung der vorhandenen Bilder
---
## 1. PHP / Livewire Volt Komponentenlogik
### Trait einbinden
```php
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $mainImages = [];
```
`WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs.
### Validierung
```php
'mainImages' => 'nullable|array|min:0|max:10',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
```
- `mainImages` ist ein **Array** (wegen `multiple`-Upload)
- `mainImages.*` validiert jede einzelne Datei
- `max:10240` = 10 MB in Kilobyte
- Livewire hat intern ein Default-Limit von 12 MB das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen)
### Einzelnes Bild entfernen (Vorschauliste)
```php
public function removePhoto(int $index): void
{
if (isset($this->mainImages[$index])) {
unset($this->mainImages[$index]);
$this->mainImages = array_values($this->mainImages);
}
}
```
Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen sonst bricht `@foreach` mit `$index` im Template.
### Vorhandenes Bild aus der DB löschen
```php
public function removeExistingMedia(int $mediaId): void
{
$media = $this->product->media()->find($mediaId);
if ($media) {
Storage::disk('public')->delete($media->file_path);
$media->delete();
$this->existingMedia = collect($this->existingMedia)
->reject(fn ($m) => $m['id'] === $mediaId)
->values()
->toArray();
}
}
```
Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert.
### Reihenfolge aktualisieren (Drag & Drop)
```php
public function updateMediaOrder(array $orderedIds): void
{
foreach ($orderedIds as $position => $mediaId) {
$this->product->media()
->where('id', $mediaId)
->update(['order_column' => $position + 1]);
}
// Lokalen State synchronisieren
$this->existingMedia = collect($orderedIds)
->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id)
? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1])
: null
)
->filter()
->values()
->toArray();
}
```
### Bilder permanent speichern (Neu-Anlage)
```php
$index = 1;
foreach ($this->mainImages as $image) {
$path = $image->store('products/' . $product->id, 'public');
$product->media()->create([
'file_path' => $path,
'type' => 'image',
'alt_text' => $this->name,
'order_column' => $index++,
]);
}
```
`$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk.
### Bilder permanent speichern (Bearbeiten neue Bilder hinzufügen)
```php
$maxOrder = $this->product->media()->max('order_column') ?? 0;
$index = $maxOrder + 1;
foreach ($this->mainImages as $image) {
$path = $image->store('products/' . $this->product->id, 'public');
$this->product->media()->create([
'file_path' => $path,
'type' => 'image',
'alt_text' => $this->name,
'order_column' => $index++,
]);
}
```
`order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen.
### Nach dem Speichern zurücksetzen
```php
// Neue Bilder leeren
$this->mainImages = [];
// Vorhandene Bilder aus DB neu laden (mit sortBy)
$this->existingMedia = $this->product->fresh()->media
->sortBy('order_column')
->values()
->map(fn ($m) => [
'id' => $m->id,
'file_path' => $m->file_path,
'alt_text' => $m->alt_text,
'order_column' => $m->order_column,
])
->toArray();
```
---
## 2. Blade / Flux UI Template
### Upload-Dropzone
```blade
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone
heading="Bilder hochladen"
text="Nur JPEG oder PNG max. 10 MB"
with-progress />
</flux:file-upload>
```
- `wire:model="mainImages"` bindet an das Array-Property
- `multiple` erlaubt Mehrfachauswahl
- `accept` schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!)
- `with-progress` zeigt Upload-Fortschrittsbalken
### Vorschauliste der neu hinzugefügten Bilder
```blade
@if (isset($mainImages) && count($mainImages) > 0)
<div class="mt-4 flex flex-wrap items-center gap-3">
@foreach ($mainImages as $index => $image)
<flux:file-item
:heading="$image->getClientOriginalName()"
:image="(str_starts_with($image->getMimeType() ?? '', 'image/') && $image->isPreviewable())
? $image->temporaryUrl()
: null"
:size="$image->getSize()">
<x-slot name="actions">
<flux:file-item.remove
wire:click="removePhoto({{ $index }})"
aria-label="{{ 'Remove file: ' . $image->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endforeach
</div>
@endif
```
**Wichtig bei `temporaryUrl()`:**
Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler.
### Fehleranzeige
```blade
<flux:error name="mainImages" />
```
Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt.").
Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet.
### Drag-&-Drop-Sortierung vorhandener Bilder
```blade
<div x-data="{
dragging: null,
dragOver: null,
items: @js(collect($existingMedia)->pluck('id')->toArray()),
onDragStart(e, id) {
this.dragging = id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
},
onDragOver(e, id) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.dragOver = id;
},
onDrop(e, targetId) {
e.preventDefault();
if (this.dragging === targetId) { this.dragOver = null; return; }
const fromIdx = this.items.indexOf(this.dragging);
const toIdx = this.items.indexOf(targetId);
this.items.splice(fromIdx, 1);
this.items.splice(toIdx, 0, this.dragging);
this.dragging = null;
this.dragOver = null;
$wire.updateMediaOrder(this.items);
},
onDragEnd() {
this.dragging = null;
this.dragOver = null;
}
}" class="flex flex-wrap items-start gap-3">
@foreach ($existingMedia as $mediaIndex => $media)
<div wire:key="existing-media-{{ $media['id'] }}"
draggable="true"
x-on:dragstart="onDragStart($event, {{ $media['id'] }})"
x-on:dragover="onDragOver($event, {{ $media['id'] }})"
x-on:drop="onDrop($event, {{ $media['id'] }})"
x-on:dragend="onDragEnd()"
:class="{
'opacity-50 scale-95': dragging === {{ $media['id'] }},
'ring-2 ring-blue-400 ring-offset-2': dragOver === {{ $media['id'] }} && dragging !== {{ $media['id'] }}
}"
class="group relative cursor-grab active:cursor-grabbing transition-all duration-150">
@if ($mediaIndex === 0)
<div class="absolute -top-1 -left-1 z-10 bg-blue-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-md shadow">
Standard
</div>
@endif
<img src="{{ Storage::url($media['file_path']) }}"
alt="{{ $media['alt_text'] ?? '' }}"
class="h-24 w-24 rounded-lg object-cover border border-zinc-200 dark:border-zinc-700
{{ $mediaIndex === 0 ? 'ring-2 ring-blue-500' : '' }}" />
<flux:button
wire:click="removeExistingMedia({{ $media['id'] }})"
wire:confirm="Bild wirklich löschen?"
variant="filled" size="xs" icon="trash"
class="absolute -top-2 -right-2 !bg-red-500 !text-white hover:!bg-red-600" />
</div>
@endforeach
</div>
```
**Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet.
---
## 3. Datenbank Media-Tabelle
```php
// Migration: database/migrations/xxxx_create_media_table.php
Schema::create('media', function (Blueprint $table) {
$table->id();
$table->string('model_type'); // z. B. "App\Models\Product"
$table->unsignedBigInteger('model_id');
$table->string('file_path'); // relativer Pfad auf dem public-Disk
$table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model'
$table->string('alt_text')->nullable();
$table->integer('order_column')->default(0);
$table->timestamps();
$table->index(['model_type', 'model_id']);
});
```
### Media-Model (`app/Models/Media.php`)
```php
class Media extends Model
{
use HasFactory;
protected $fillable = [
'model_type', 'model_id', 'file_path', 'type', 'alt_text', 'order_column',
];
protected function casts(): array
{
return ['order_column' => 'integer'];
}
/** Polymorphe Beziehung zum Eltern-Model */
public function model(): MorphTo
{
return $this->morphTo();
}
}
```
### Beziehung im Parent-Model (`app/Models/Product.php`)
```php
use Illuminate\Database\Eloquent\Relations\MorphMany;
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'model');
}
```
---
## 4. Filesystem-Konfiguration (`config/filesystems.php`)
```php
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'visibility' => 'public',
'throw' => false,
],
```
### Symlink anlegen
```bash
php artisan storage:link
```
Erstellt `public/storage``storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.**
---
## 5. Kritische System-Anpassungen
### 5a. `bootstrap/app.php` Reverse-Proxy / HTTPS
```php
->withMiddleware(function (Middleware $middleware) {
// Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen
// ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy
$middleware->trustProxies(at: '*');
})
```
**Warum?**
Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` die Signatur stimmt nicht, Upload schlägt fehl mit `403`.
### 5b. `app/Providers/AppServiceProvider.php` Schema erzwingen
```php
public function boot(): void
{
// X-Forwarded-Proto auswerten und Schema erzwingen
// Nötig für Livewire Upload-URLs hinter Traefik
$scheme = request()->header('X-Forwarded-Proto')
?? request()->server('HTTP_X_FORWARDED_PROTO')
?? (request()->secure() ? 'https' : 'http');
if ($scheme === 'https') {
URL::forceScheme('https');
}
}
```
**Warum zusätzlich zum `trustProxies`?**
`trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben.
**Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console besonders frustrierend, weil kein PHP-Fehler erscheint.
---
## 6. Livewire-Konfiguration (`config/livewire.php`)
```php
'temporary_file_upload' => [
'disk' => null, // null = default-Disk (meist 'local')
'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default)
'directory' => null, // null = 'livewire-tmp'
'middleware' => null, // null = 'throttle:60,1'
'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl()
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp',
'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ...
],
'max_upload_time' => 5, // Minuten bis Upload ungültig wird
'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen
],
```
**Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden.
---
## 7. Checkliste für ein neues Projekt
| Schritt | Was | Wo |
|---------|-----|----|
| ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse |
| ✅ | `public array $images = []` Property anlegen | Komponentenklasse |
| ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode |
| ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode |
| ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode |
| ✅ | `php artisan storage:link` ausführen | Terminal / Deploy |
| ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` |
| ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` |
| ✅ | `wire:key` in Foreach-Schleifen | Blade-Template |
| ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` |
| ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template |
---
## 8. Häufige Fallstricke
### Upload schlägt fehl mit 403 (Signature mismatch)
→ Reverse-Proxy-Problem. Siehe Punkt 5a und 5b.
### Vorschau-Thumbnail zeigt nichts an
`isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen.
### Nach `removePhoto()` stimmen die Indizes nicht
`array_values()` vergessen. Livewire sendet den Index als Parameter ohne Reindizierung kommt es zu Off-by-One-Fehlern.
### Upload-Limit-Fehler vor der Validierung
→ PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten.
### `temporaryUrl()` wirft eine Exception
→ Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern.
### Bilder nach Deploy nicht sichtbar
`php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert.

File diff suppressed because it is too large Load diff

493
packages/flux-cms/SETUP.md Normal file
View file

@ -0,0 +1,493 @@
# Flux CMS — Schritt-für-Schritt Setup-Anleitung
Diese Anleitung beschreibt die Integration von Flux CMS in ein neues Laravel-Projekt.
> **Migrierst du ein bestehendes Projekt?** Dann nutze stattdessen die kompakte **[MIGRATION.md](MIGRATION.md)** Checkliste.
## Voraussetzungen
- Laravel 11+ oder 12
- Livewire 4 mit Volt
- Flux UI (Free oder Pro)
- `spatie/laravel-translatable` (wird vom Package mitgebracht)
- `intervention/image` v3 (für Bildoptimierung)
- Tailwind CSS v4
- Heroicons (via `blade-ui-kit/blade-heroicons`)
- Mehrsprachige `lang/`-Dateien (DE/EN oder andere)
---
## 1. Package installieren
### 1.1 Repository registrieren
```json
// composer.json
{
"repositories": [
{ "type": "path", "url": "package/flux-cms/core" }
]
}
```
### 1.2 Dependencies hinzufügen
```bash
composer require flux-cms/core:@dev
composer require intervention/image
```
### 1.3 Konfiguration publizieren
```bash
php artisan vendor:publish --tag=flux-cms-config
```
### 1.4 Migrations ausführen
```bash
php artisan migrate
```
Dies erstellt folgende Tabellen:
- `flux_cms_contents` — Alle Seiteninhalte (Key-Value mit Übersetzungen)
- `flux_cms_news_items` — Nachrichteneinträge
- `flux_cms_industries` — Branchen-Band
- `flux_cms_faqs` — FAQ-Einträge
- `flux_cms_downloads` — Downloads (PDFs, etc.)
- `flux_cms_linkedin_posts` — LinkedIn-Posts
- `flux_cms_media` — Medienbibliothek (Bilder, PDFs)
**Hinweis:** Je nach Projekt-Erweiterung können zusätzliche Migrations nötig sein (z.B. für erweiterte Downloads mit Highlights/Checkpoints).
---
## 2. Helper-Funktionen einrichten
### 2.1 Datei erstellen
Erstelle `app/helpers.php`:
```php
<?php
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
if (! function_exists('cms')) {
function cms(string $key, array $replace = [], ?string $locale = null): mixed
{
return app(CmsContentService::class)->get($key, $replace, $locale);
}
}
if (! function_exists('tcms')) {
function tcms(string $key, array $replace = [], ?string $locale = null): string
{
$text = cms($key, $replace, $locale);
return is_string($text) ? $text : (string) $text;
}
}
if (! function_exists('cms_media_url')) {
/**
* Resolve a CmsContent key (type=image) to a full media URL.
* Falls back to asset('assets/images/...') if not found in media library.
*/
function cms_media_url(string $key, string $profile = ''): string
{
$filename = cms($key);
if (! $filename || ! is_string($filename) || $filename === $key) {
$fallback = str_replace('.', '/', $key);
return asset('assets/images/' . basename($fallback));
}
return media_url($filename, $profile);
}
}
if (! function_exists('media_url')) {
/**
* Resolve a CmsMedia filename to its full storage URL.
* Uses in-memory cache to avoid repeated DB queries.
*/
function media_url(?string $filename, string $profile = ''): string
{
if (! $filename || $filename === '') {
return '';
}
static $resolved = [];
$cacheKey = $filename . '|' . $profile;
if (isset($resolved[$cacheKey])) {
return $resolved[$cacheKey];
}
$media = CmsMedia::where('filename', $filename)->first();
if (! $media) {
$resolved[$cacheKey] = asset('assets/images/' . $filename);
return $resolved[$cacheKey];
}
if ($profile && $media->hasConversion($profile)) {
$resolved[$cacheKey] = $media->getConversionUrl($profile);
} else {
$resolved[$cacheKey] = $media->getUrl();
}
return $resolved[$cacheKey];
}
}
```
### 2.2 Autoload registrieren
```json
// composer.json → autoload
{
"autoload": {
"files": ["app/helpers.php"]
}
}
```
```bash
composer dump-autoload
```
---
## 3. Admin-Oberfläche einrichten
### 3.1 Layout erstellen
Kopiere `core/resources/views/admin-reference/layout-cms.blade.php` nach `resources/views/components/layouts/cms.blade.php`.
Passe die Sidebar-Navigation an:
- Branding/Logo
- Navigationspunkte: Dashboard, Inhalte, Medienbibliothek, News, Industries, Downloads, Team, FAQs, LinkedIn
- Benutzer-Menü
**Wichtig:** Das Layout muss `<flux:toast />` enthalten für die Benachrichtigungen.
### 3.2 Admin Views kopieren
```bash
mkdir -p resources/views/livewire/admin/cms/
cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \
resources/views/livewire/admin/cms/
```
| View | Datei | Beschreibung |
|------|-------|-------------|
| Dashboard | `dashboard-index.blade.php` | Übersicht mit Statistiken |
| Inhalte | `content-index.blade.php` | Haupteditor (Text/HTML/Image/JSON) |
| Medienbibliothek | `media-index.blade.php` | Zentrale Medienverwaltung (Grid+Liste) |
| News | `news-index.blade.php` | CRUD mit MediaPicker für Bild + PDF |
| Industries | `industries-index.blade.php` | CRUD mit Sortierung |
| FAQs | `faqs-index.blade.php` | CRUD nach Kategorien |
| LinkedIn | `linkedin-index.blade.php` | CRUD mit MediaPicker |
| Downloads | `downloads-index.blade.php` | CRUD für Case Studies/Capabilities/Stories |
| Team | `team-index.blade.php` | CRUD mit MediaPicker für Profilbilder |
| Suchindex | `search-index.blade.php` | Seitensuche: Keywords, Kategorien, Vorschau |
### 3.3 Livewire-Komponenten einrichten
Funktionale Volt-Komponenten können kein `WithFileUploads` verwenden. Daher gibt es class-based Livewire-Komponenten:
```bash
# Multi-File Upload
cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php \
app/Livewire/Admin/Cms/MediaLibraryUploader.php
# Medienauswahl-Modal
cp package/flux-cms/core/src/Helpers/MediaPicker.php \
app/Livewire/Admin/Cms/MediaPicker.php
```
Namespace in beiden Dateien anpassen:
```php
namespace App\Livewire\Admin\Cms;
```
### 3.4 Blade-Views für Livewire-Komponenten
```bash
# MediaLibraryUploader View
cp package/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php \
resources/views/livewire/admin/cms/media-library-uploader.blade.php
# MediaPicker View
cp package/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php \
resources/views/livewire/admin/cms/media-picker.blade.php
```
### 3.5 Routes registrieren
```php
// routes/web.php
use Livewire\Volt\Volt;
Route::middleware(['auth'])->group(function () {
Volt::route('admin/cms', 'admin.cms.dashboard-index')->name('cms.dashboard');
Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index');
Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index');
Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index');
Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index');
Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index');
Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index');
Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index');
Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index');
Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index');
});
```
---
## 4. Medienbibliothek einrichten
### 4.1 Storage-Link
```bash
php artisan storage:link
```
### 4.2 Storage-Verzeichnisse
Die Verzeichnisse werden automatisch erstellt beim ersten Upload:
- `storage/app/public/cms/media/originals/` — Original-Uploads
- `storage/app/public/cms/media/conversions/` — Generierte Bildgrößen
- `storage/app/public/cms/media/thumbnails/` — Auto-Thumbnails
### 4.3 Bildprofile konfigurieren
In `config/flux-cms.php` die Conversion-Profile an dein Projekt anpassen:
```php
'media' => [
'max_upload_size' => 20480, // KB
'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'],
'storage_disk' => 'public',
'profiles' => [
'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85],
'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80],
'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75],
'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80],
'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80],
'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70],
],
],
```
### 4.4 HTTPS / Proxy
Falls hinter einem Reverse Proxy, in `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
})
```
Und in `AppServiceProvider::boot()`:
```php
if (request()->header('X-Forwarded-Proto') === 'https' || app()->environment('production')) {
URL::forceScheme('https');
}
```
---
## 5. Inhalte importieren (Seeder)
### 5.1 Seeders kopieren & anpassen
```bash
cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/
```
### 5.2 CmsContentSeeder anpassen
```php
protected array $skipFiles = [
'faqs', 'sections', 'validation', 'auth', 'passwords', 'pagination',
];
protected array $skipKeys = [
'components' => ['news_band', 'industries_band'],
];
```
### 5.3 CmsMediaSeeder erstellen
Erstelle einen Seeder, der alle hochgeladenen Medien und die `CmsContent`-Einträge vom Typ `image` wiederherstellt. Dieser wird nach dem `CmsContentSeeder` ausgeführt, um Image-Keys zu erzeugen (z.B. `welcome.hero.image` → Dateiname).
### 5.4 CmsDownloadSeeder erstellen (optional)
Falls du Downloads (Case Studies, Capabilities, Success Stories) verwendest, erstelle einen Seeder mit den vollständigen Inline-Daten.
### 5.5 DatabaseSeeder registrieren
```php
public function run(): void
{
$this->call([
CmsContentSeeder::class,
CmsMediaSeeder::class,
CmsNewsItemSeeder::class,
CmsIndustrySeeder::class,
CmsFaqSeeder::class,
CmsLinkedinPostSeeder::class,
CmsDownloadSeeder::class,
]);
}
```
### 5.6 Seeding ausführen
```bash
php artisan db:seed
```
---
## 6. Frontend umstellen
### 6.1 `__()` durch `cms()` ersetzen
```diff
- {{ __('welcome.hero.heading') }}
+ {{ cms('welcome.hero.heading') }}
```
### 6.2 Bilder über Medienbibliothek laden
```diff
- <img src="{{ asset('assets/images/keyvisual.webp') }}" />
+ <img src="{{ cms_media_url('welcome.hero.image', 'hero') }}" />
```
Oder direkt über Dateiname:
```blade
<img src="{{ media_url($item['image'], 'card') }}" />
```
### 6.3 Downloads über CmsDownload
```blade
@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $dl)
<x-download-article-card :article="$dl->toFrontendArray()" :index="$loop->index" />
@endforeach
```
### 6.4 News über CmsNewsItem
```blade
@php
$items = CmsNewsItem::published()->ordered()->get()
->map(fn($i) => $i->toFrontendArray())->toArray();
@endphp
```
### 6.5 :highlight Pattern
```blade
{!! cms('welcome.solutions.heading', [
'highlight' => '<span class="text-gradient-premium">'
. cms('welcome.solutions.heading_highlight') . '</span>',
]) !!}
```
---
## 7. Tests einrichten
### 7.1 Test-Dateien kopieren
```bash
cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms
```
### 7.2 Tests ausführen
```bash
php artisan test --filter=Cms
```
---
## 8. Anpassungen für dein Projekt
### Sprachen hinzufügen
`config/flux-cms.php`:
```php
'locales' => [
'de' => 'Deutsch',
'en' => 'English',
'fr' => 'Français',
],
```
### Neue Content-Typen
1. Migration + Model mit `HasTranslations`
2. Admin-View als Volt-Komponente
3. Route registrieren, Sidebar-Link hinzufügen
4. `toFrontendArray()` Methode für Frontend-Integration
5. Seeder erstellen
### Editor-Toolbar
- **Standard** (`bold italic`): Normale Texte
- **Voll** (`heading | bold italic underline strike | bullet ordered blockquote | link`): Impressum, Datenschutz, News-Body
### Icon-Auswahl (Performance)
Die Icon-Auswahl nutzt Blade Heroicons. Nur der Name wird im Dropdown angezeigt, das SVG nur als Vorschau neben dem Select-Feld — damit werden nicht 2800+ SVGs gerendert.
---
## Verzeichnisstruktur nach Installation
```
dein-projekt/
├── app/
│ ├── helpers.php # cms(), tcms(), cms_media_url(), media_url()
│ └── Livewire/Admin/Cms/
│ ├── MediaLibraryUploader.php # Multi-File Upload
│ └── MediaPicker.php # Medienauswahl-Modal
├── config/
│ └── flux-cms.php # CMS + Media Konfiguration
├── database/
│ ├── migrations/
│ │ └── *_create_flux_cms_*.php # Automatisch vom Package
│ └── seeders/
│ ├── CmsContentSeeder.php # Lang → DB
│ ├── CmsMediaSeeder.php # Medien + Image-Content
│ ├── CmsDownloadSeeder.php # Case Studies etc.
│ ├── CmsNewsItemSeeder.php
│ ├── CmsIndustrySeeder.php
│ ├── CmsFaqSeeder.php
│ └── CmsLinkedinPostSeeder.php
├── package/flux-cms/core/ # Das Package
├── resources/views/
│ ├── components/layouts/
│ │ └── cms.blade.php # Admin-Layout
│ └── livewire/admin/cms/
│ ├── content-index.blade.php # Content-Editor
│ ├── media-index.blade.php # Medienbibliothek
│ ├── media-library-uploader.blade.php
│ ├── media-picker.blade.php
│ ├── news-index.blade.php
│ ├── downloads-index.blade.php
│ ├── team-index.blade.php
│ ├── linkedin-index.blade.php
│ ├── industries-index.blade.php
│ ├── faqs-index.blade.php
│ ├── search-index.blade.php # Suchindex-Verwaltung
│ └── dashboard.blade.php # CMS-Dashboard
└── routes/web.php # CMS-Routes
```

View file

@ -2,10 +2,10 @@
namespace FluxCms\Components;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Livewire\Livewire;
use ReflectionClass;
class FluxCmsComponentsServiceProvider extends ServiceProvider
@ -33,7 +33,7 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
*/
protected function bootViews(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-components');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms-components');
}
/**
@ -44,12 +44,12 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
if ($this->app->runningInConsole()) {
// Publish views
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-components'),
__DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms-components'),
], 'flux-cms-components-views');
// Publish assets
$this->publishes([
__DIR__ . '/../resources/assets' => public_path('vendor/flux-cms-components'),
__DIR__.'/../resources/assets' => public_path('vendor/flux-cms-components'),
], 'flux-cms-components-assets');
}
}
@ -59,22 +59,22 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
*/
protected function bootLivewireComponents(): void
{
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
$this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
$this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
}
protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void
{
$filesystem = new Filesystem();
if (!$filesystem->isDirectory($path)) {
$filesystem = new Filesystem;
if (! $filesystem->isDirectory($path)) {
return;
}
foreach ($filesystem->allFiles($path) as $file) {
$class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
$class = $namespace.'\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) {
$alias = $aliasPrefix . Str::kebab(class_basename($class));
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && ! (new ReflectionClass($class))->isAbstract()) {
$alias = $aliasPrefix.Str::kebab(class_basename($class));
Livewire::component($alias, $class);
}
}

View file

@ -2,13 +2,13 @@
namespace FluxCms\Components\Livewire\Backend;
use Livewire\Component;
use FluxCms\Core\Models\BlogPost;
use Spatie\Tags\Tag;
use Livewire\Component;
class BlogEditor extends Component
{
public BlogPost $post;
public string $tags = '';
public function mount(BlogPost $post)

View file

@ -2,21 +2,26 @@
namespace FluxCms\Components\Livewire\Backend;
use FluxCms\Core\Models\BlogPost;
use Livewire\Component;
use Livewire\WithPagination;
use FluxCms\Core\Models\BlogPost;
use Illuminate\Support\Collection;
class BlogManager extends Component
{
use WithPagination;
public string $domainKey;
public array $availableLanguages = [];
public string $currentLocale = 'de';
public string $search = '';
public string $filterStatus = 'all';
public bool $showCreateModal = false;
public ?BlogPost $editingPost = null;
// Form data
@ -48,12 +53,12 @@ class BlogManager extends Component
$query = BlogPost::forDomain($this->domainKey);
// Search filter
if (!empty($this->search)) {
if (! empty($this->search)) {
$query->where(function ($q) {
$q->where('title->de', 'like', '%' . $this->search . '%')
->orWhere('title->en', 'like', '%' . $this->search . '%')
->orWhere('content->de', 'like', '%' . $this->search . '%')
->orWhere('content->en', 'like', '%' . $this->search . '%');
$q->where('title->de', 'like', '%'.$this->search.'%')
->orWhere('title->en', 'like', '%'.$this->search.'%')
->orWhere('content->de', 'like', '%'.$this->search.'%')
->orWhere('content->en', 'like', '%'.$this->search.'%');
});
}
@ -175,7 +180,7 @@ class BlogManager extends Component
session()->flash('success', $message);
} catch (\Exception $e) {
session()->flash('error', 'Error saving blog post: ' . $e->getMessage());
session()->flash('error', 'Error saving blog post: '.$e->getMessage());
}
}
@ -191,7 +196,7 @@ class BlogManager extends Component
session()->flash('success', 'Blog post deleted successfully.');
}
} catch (\Exception $e) {
session()->flash('error', 'Error deleting blog post: ' . $e->getMessage());
session()->flash('error', 'Error deleting blog post: '.$e->getMessage());
}
}
@ -213,7 +218,7 @@ class BlogManager extends Component
session()->flash('success', $message);
}
} catch (\Exception $e) {
session()->flash('error', 'Error updating publish status: ' . $e->getMessage());
session()->flash('error', 'Error updating publish status: '.$e->getMessage());
}
}
@ -225,12 +230,12 @@ class BlogManager extends Component
try {
$post = BlogPost::find($postId);
if ($post) {
$post->update(['is_featured' => !$post->is_featured]);
$post->update(['is_featured' => ! $post->is_featured]);
$message = $post->is_featured ? 'Post marked as featured.' : 'Post removed from featured.';
session()->flash('success', $message);
}
} catch (\Exception $e) {
session()->flash('error', 'Error updating featured status: ' . $e->getMessage());
session()->flash('error', 'Error updating featured status: '.$e->getMessage());
}
}
@ -249,14 +254,14 @@ class BlogManager extends Component
// Update title to indicate it's a copy
$titles = $duplicate->getTranslations('title');
foreach ($titles as $locale => $title) {
$titles[$locale] = $title . ' (Copy)';
$titles[$locale] = $title.' (Copy)';
}
$duplicate->title = $titles;
// Update slugs to avoid conflicts
$slugs = $duplicate->getTranslations('slug');
foreach ($slugs as $locale => $slug) {
$slugs[$locale] = $slug . '-copy-' . time();
$slugs[$locale] = $slug.'-copy-'.time();
}
$duplicate->slug = $slugs;
@ -264,7 +269,7 @@ class BlogManager extends Component
session()->flash('success', 'Blog post duplicated successfully.');
}
} catch (\Exception $e) {
session()->flash('error', 'Error duplicating blog post: ' . $e->getMessage());
session()->flash('error', 'Error duplicating blog post: '.$e->getMessage());
}
}
@ -276,7 +281,7 @@ class BlogManager extends Component
$title = $this->postData['title'][$locale] ?? '';
if ($title) {
$slug = \Illuminate\Support\Str::slug($title);
$this->postData['slug'][$locale] = '/' . $slug;
$this->postData['slug'][$locale] = '/'.$slug;
}
}
@ -315,4 +320,4 @@ class BlogManager extends Component
'featured' => BlogPost::forDomain($this->domainKey)->featured()->count(),
];
}
}
}

View file

@ -2,17 +2,22 @@
namespace FluxCms\Components\Livewire\Backend;
use Livewire\Component;
use FluxCms\Core\Models\PageComponent;
use FluxCms\Core\Services\ComponentRegistry;
use Livewire\Component;
class ComponentEditor extends Component
{
public PageComponent $component;
public array $content = [];
public array $availableLanguages = [];
public string $currentLocale = 'de';
public bool $expanded = false;
public array $validationErrors = [];
protected ComponentRegistry $componentRegistry;
@ -56,7 +61,7 @@ class ComponentEditor extends Component
*/
public function toggleExpanded()
{
$this->expanded = !$this->expanded;
$this->expanded = ! $this->expanded;
}
/**
@ -85,21 +90,22 @@ class ComponentEditor extends Component
{
$this->validateContent();
if (!empty($this->validationErrors)) {
if (! empty($this->validationErrors)) {
session()->flash('error', 'Please correct validation errors.');
return;
}
try {
$this->component->update([
'content' => $this->content
'content' => $this->content,
]);
session()->flash('success', 'Component saved successfully.');
$this->dispatch('component-saved', componentId: $this->component->id);
} catch (\Exception $e) {
session()->flash('error', 'Error saving component: ' . $e->getMessage());
session()->flash('error', 'Error saving component: '.$e->getMessage());
}
}
@ -111,7 +117,7 @@ class ComponentEditor extends Component
if (empty($this->validationErrors)) {
try {
$this->component->update([
'content' => $this->content
'content' => $this->content,
]);
} catch (\Exception $e) {
// Silent fail for auto-save
@ -189,6 +195,7 @@ class ComponentEditor extends Component
public function hasFieldError(string $fieldKey, ?string $locale = null): bool
{
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
return isset($this->validationErrors[$errorKey]);
}
@ -198,6 +205,7 @@ class ComponentEditor extends Component
public function getFieldErrors(string $fieldKey, ?string $locale = null): array
{
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
return $this->validationErrors[$errorKey] ?? [];
}
@ -225,4 +233,4 @@ class ComponentEditor extends Component
$this->content = $this->component->getTranslations('content');
$this->validationErrors = [];
}
}
}

View file

@ -2,24 +2,32 @@
namespace FluxCms\Components\Livewire\Backend;
use Illuminate\Http\UploadedFile;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Illuminate\Http\UploadedFile;
class MediaManager extends Component
{
use WithFileUploads, WithPagination;
public bool $showModal = false;
public ?string $targetComponentId = null;
public ?string $targetFieldKey = null;
public ?string $targetLocale = null;
public array $uploadingFiles = [];
public string $searchTerm = '';
public string $filterType = 'all';
public array $selectedMedia = [];
public bool $multiSelect = false;
protected $paginationTheme = 'simple-bootstrap';
@ -71,7 +79,7 @@ class MediaManager extends Component
public function uploadFiles()
{
$this->validate([
'uploadingFiles.*' => 'file|max:' . config('flux-cms.media.max_file_size', 10240),
'uploadingFiles.*' => 'file|max:'.config('flux-cms.media.max_file_size', 10240),
]);
try {
@ -83,7 +91,7 @@ class MediaManager extends Component
session()->flash('success', 'Files uploaded successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Error uploading files: ' . $e->getMessage());
session()->flash('error', 'Error uploading files: '.$e->getMessage());
}
}
@ -94,8 +102,10 @@ class MediaManager extends Component
{
// Create a temporary model for media library
// In real implementation, you'd use a dedicated media model
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia {
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia
{
use \Spatie\MediaLibrary\InteractsWithMedia;
protected $table = 'flux_cms_media'; // Would exist in real implementation
};
@ -114,7 +124,7 @@ class MediaManager extends Component
{
if ($this->multiSelect) {
if (in_array($mediaId, $this->selectedMedia)) {
$this->selectedMedia = array_filter($this->selectedMedia, fn($id) => $id !== $mediaId);
$this->selectedMedia = array_filter($this->selectedMedia, fn ($id) => $id !== $mediaId);
} else {
$this->selectedMedia[] = $mediaId;
}
@ -165,7 +175,7 @@ class MediaManager extends Component
session()->flash('success', 'Media deleted successfully.');
}
} catch (\Exception $e) {
session()->flash('error', 'Error deleting media: ' . $e->getMessage());
session()->flash('error', 'Error deleting media: '.$e->getMessage());
}
}
@ -177,13 +187,13 @@ class MediaManager extends Component
$query = Media::query()->orderBy('created_at', 'desc');
// Search filter
if (!empty($this->searchTerm)) {
$query->where('name', 'like', '%' . $this->searchTerm . '%');
if (! empty($this->searchTerm)) {
$query->where('name', 'like', '%'.$this->searchTerm.'%');
}
// Type filter
if ($this->filterType !== 'all') {
$query->where('mime_type', 'like', $this->filterType . '%');
$query->where('mime_type', 'like', $this->filterType.'%');
}
return $query->paginate(20);
@ -271,6 +281,6 @@ class MediaManager extends Component
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
return round($bytes, 2).' '.$units[$pow];
}
}
}

View file

@ -2,26 +2,35 @@
namespace FluxCms\Components\Livewire\Backend;
use Livewire\Component;
use FluxCms\Core\Models\Navigation;
use FluxCms\Core\Models\NavigationItem;
use FluxCms\Core\Models\Page;
use Illuminate\Support\Collection;
use Livewire\Component;
class NavigationManager extends Component
{
public string $domainKey;
public Collection $navigations;
public ?Navigation $selectedNavigation = null;
public Collection $navigationItems;
public array $availableLanguages = [];
public string $currentLocale = 'de';
public bool $showCreateModal = false;
public bool $showItemModal = false;
public ?NavigationItem $editingItem = null;
// Form data
public array $navigationData = [];
public array $itemData = [];
public function mount(string $domainKey)
@ -58,8 +67,9 @@ class NavigationManager extends Component
*/
public function loadNavigationItems()
{
if (!$this->selectedNavigation) {
if (! $this->selectedNavigation) {
$this->navigationItems = collect();
return;
}
@ -124,7 +134,7 @@ class NavigationManager extends Component
session()->flash('success', 'Navigation created successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Error creating navigation: ' . $e->getMessage());
session()->flash('error', 'Error creating navigation: '.$e->getMessage());
}
}
@ -172,6 +182,7 @@ class NavigationManager extends Component
// Validate that either page or external URL is provided
if (empty($this->itemData['page_id']) && empty($this->itemData['external_url'])) {
$this->addError('itemData.page_id', 'Either select a page or provide an external URL.');
return;
}
@ -205,7 +216,7 @@ class NavigationManager extends Component
session()->flash('success', $message);
} catch (\Exception $e) {
session()->flash('error', 'Error saving navigation item: ' . $e->getMessage());
session()->flash('error', 'Error saving navigation item: '.$e->getMessage());
}
}
@ -222,7 +233,7 @@ class NavigationManager extends Component
session()->flash('success', 'Navigation item deleted successfully.');
}
} catch (\Exception $e) {
session()->flash('error', 'Error deleting navigation item: ' . $e->getMessage());
session()->flash('error', 'Error deleting navigation item: '.$e->getMessage());
}
}
@ -234,11 +245,11 @@ class NavigationManager extends Component
try {
$item = NavigationItem::find($itemId);
if ($item) {
$item->update(['is_active' => !$item->is_active]);
$item->update(['is_active' => ! $item->is_active]);
$this->loadNavigationItems();
}
} catch (\Exception $e) {
session()->flash('error', 'Error toggling navigation item: ' . $e->getMessage());
session()->flash('error', 'Error toggling navigation item: '.$e->getMessage());
}
}
@ -256,7 +267,7 @@ class NavigationManager extends Component
session()->flash('success', 'Navigation order updated successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Error updating order: ' . $e->getMessage());
session()->flash('error', 'Error updating order: '.$e->getMessage());
}
}
@ -279,7 +290,7 @@ class NavigationManager extends Component
session()->flash('success', 'Navigation deleted successfully.');
}
} catch (\Exception $e) {
session()->flash('error', 'Error deleting navigation: ' . $e->getMessage());
session()->flash('error', 'Error deleting navigation: '.$e->getMessage());
}
}
@ -288,7 +299,7 @@ class NavigationManager extends Component
*/
public function getAvailableParentsProperty(): Collection
{
if (!$this->selectedNavigation) {
if (! $this->selectedNavigation) {
return collect();
}
@ -327,4 +338,4 @@ class NavigationManager extends Component
$this->navigationData = [];
$this->itemData = [];
}
}
}

View file

@ -2,22 +2,29 @@
namespace FluxCms\Components\Livewire\Backend;
use Livewire\Component;
use Livewire\Attributes\On;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\PageComponent;
use FluxCms\Core\Services\ComponentRegistry;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
class PageEditor extends Component
{
public Page $page;
public Collection $components;
public array $availableLanguages = [];
public string $currentLocale = 'de';
public bool $showComponentModal = false;
public array $availableComponents = [];
public string $selectedCategory = 'all';
public bool $isLoading = false;
protected ComponentRegistry $componentRegistry;
@ -39,7 +46,7 @@ class PageEditor extends Component
public function render()
{
return view('flux-cms-components::livewire.backend.page-editor')
->layout('flux-cms-components::layouts.admin');
->layout('flux-cms-components::layouts.admin');
}
/**
@ -99,8 +106,9 @@ class PageEditor extends Component
*/
public function addComponent(string $componentClass)
{
if (!$this->componentRegistry->isValidComponent($componentClass)) {
if (! $this->componentRegistry->isValidComponent($componentClass)) {
$this->addError('component', 'Invalid component selected.');
return;
}
@ -121,7 +129,7 @@ class PageEditor extends Component
session()->flash('success', 'Component added successfully.');
} catch (\Exception $e) {
$this->addError('component', 'Error adding component: ' . $e->getMessage());
$this->addError('component', 'Error adding component: '.$e->getMessage());
}
}
@ -133,8 +141,9 @@ class PageEditor extends Component
try {
$component = $this->components->firstWhere('id', $componentId);
if (!$component) {
if (! $component) {
$this->addError('component', 'Component not found.');
return;
}
@ -145,7 +154,7 @@ class PageEditor extends Component
session()->flash('success', 'Component deleted successfully.');
} catch (\Exception $e) {
$this->addError('component', 'Error deleting component: ' . $e->getMessage());
$this->addError('component', 'Error deleting component: '.$e->getMessage());
}
}
@ -157,8 +166,9 @@ class PageEditor extends Component
try {
$component = $this->components->firstWhere('id', $componentId);
if (!$component) {
if (! $component) {
$this->addError('component', 'Component not found.');
return;
}
@ -169,7 +179,7 @@ class PageEditor extends Component
session()->flash('success', 'Component duplicated successfully.');
} catch (\Exception $e) {
$this->addError('component', 'Error duplicating component: ' . $e->getMessage());
$this->addError('component', 'Error duplicating component: '.$e->getMessage());
}
}
@ -181,15 +191,15 @@ class PageEditor extends Component
try {
$component = $this->components->firstWhere('id', $componentId);
if (!$component) {
if (! $component) {
return;
}
$component->update(['is_active' => !$component->is_active]);
$component->update(['is_active' => ! $component->is_active]);
$this->loadComponents();
} catch (\Exception $e) {
$this->addError('component', 'Error toggling component: ' . $e->getMessage());
$this->addError('component', 'Error toggling component: '.$e->getMessage());
}
}
@ -208,7 +218,7 @@ class PageEditor extends Component
session()->flash('success', 'Component order updated.');
} catch (\Exception $e) {
$this->addError('order', 'Error updating order: ' . $e->getMessage());
$this->addError('order', 'Error updating order: '.$e->getMessage());
}
}
@ -237,7 +247,7 @@ class PageEditor extends Component
}
foreach ($config['fields'] as $field) {
if (!$field instanceof \FluxCms\Core\FieldTypes\BaseField) {
if (! $field instanceof \FluxCms\Core\FieldTypes\BaseField) {
continue;
}
@ -271,7 +281,7 @@ class PageEditor extends Component
session()->flash('success', 'Page data saved successfully.');
} catch (\Exception $e) {
$this->addError('page', 'Error saving page: ' . $e->getMessage());
$this->addError('page', 'Error saving page: '.$e->getMessage());
}
}
@ -292,21 +302,21 @@ class PageEditor extends Component
session()->flash('success', $message);
} catch (\Exception $e) {
$this->addError('publish', 'Error updating publish status: ' . $e->getMessage());
$this->addError('publish', 'Error updating publish status: '.$e->getMessage());
}
}
/**
* Create version
*/
public function createVersion(string $description = null)
public function createVersion(?string $description = null)
{
try {
$this->page->createVersion($description, auth()->id());
session()->flash('success', 'Version created successfully.');
} catch (\Exception $e) {
$this->addError('version', 'Error creating version: ' . $e->getMessage());
$this->addError('version', 'Error creating version: '.$e->getMessage());
}
}
@ -320,10 +330,11 @@ class PageEditor extends Component
if (empty($slug)) {
$this->addError('preview', 'No slug available for current language.');
return;
}
$url = $this->page->getUrl($locale) . '?preview=1';
$url = $this->page->getUrl($locale).'?preview=1';
$this->dispatch('open-preview', url: $url);
}
@ -351,6 +362,7 @@ class PageEditor extends Component
foreach ($this->availableComponents as $category => $categoryComponents) {
$components = array_merge($components, $categoryComponents);
}
return $components;
}
@ -362,7 +374,7 @@ class PageEditor extends Component
*/
public function getPageStatusProperty(): string
{
if (!$this->page->is_published) {
if (! $this->page->is_published) {
return 'draft';
}
@ -372,4 +384,4 @@ class PageEditor extends Component
return 'published';
}
}
}

View file

@ -2,26 +2,34 @@
namespace FluxCms\Components\Livewire\Frontend;
use Livewire\Component;
use Livewire\WithPagination;
use FluxCms\Core\Models\BlogPost;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;
class BlogList extends Component
{
use WithPagination;
public string $domainKey;
public int $perPage = 12;
public bool $showFeatured = true;
public bool $showPagination = true;
public string $orderBy = 'published_at';
public string $orderDirection = 'desc';
public array $classes = [];
// Filtering
public string $search = '';
public array $tags = [];
public ?string $category = null;
protected $paginationTheme = 'simple-bootstrap';
@ -63,12 +71,12 @@ class BlogList extends Component
$query = BlogPost::forDomain($this->domainKey)->published();
// Search filter
if (!empty($this->search)) {
if (! empty($this->search)) {
$locale = app()->getLocale();
$query->where(function ($q) use ($locale) {
$q->where("title->{$locale}", 'like', '%' . $this->search . '%')
->orWhere("excerpt->{$locale}", 'like', '%' . $this->search . '%')
->orWhere("content->{$locale}", 'like', '%' . $this->search . '%');
$q->where("title->{$locale}", 'like', '%'.$this->search.'%')
->orWhere("excerpt->{$locale}", 'like', '%'.$this->search.'%')
->orWhere("content->{$locale}", 'like', '%'.$this->search.'%');
});
}
@ -119,6 +127,7 @@ class BlogList extends Component
public function getPostTitle(BlogPost $post): string
{
$locale = app()->getLocale();
return $post->getTranslation('title', $locale);
}
@ -185,6 +194,7 @@ class BlogList extends Component
{
$defaultClasses = ['flux-cms-blog-list', 'blog-list'];
$allClasses = array_merge($defaultClasses, $this->classes);
return implode(' ', $allClasses);
}
@ -228,6 +238,7 @@ class BlogList extends Component
public function getSearchPlaceholder(): string
{
$locale = app()->getLocale();
return $locale === 'de' ? 'Blog durchsuchen...' : 'Search blog...';
}
@ -238,10 +249,10 @@ class BlogList extends Component
{
$locale = app()->getLocale();
if (!empty($this->search)) {
if (! empty($this->search)) {
return $locale === 'de'
? 'Keine Artikel für "' . $this->search . '" gefunden.'
: 'No posts found for "' . $this->search . '".';
? 'Keine Artikel für "'.$this->search.'" gefunden.'
: 'No posts found for "'.$this->search.'".';
}
return $locale === 'de'
@ -262,4 +273,4 @@ class BlogList extends Component
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
}
}
}

View file

@ -2,18 +2,24 @@
namespace FluxCms\Components\Livewire\Frontend;
use Livewire\Component;
use FluxCms\Core\Models\BlogPost as BlogPostModel;
use Illuminate\Support\Collection;
use Livewire\Component;
class BlogPost extends Component
{
public BlogPostModel $post;
public string $domainKey;
public bool $showRelated = true;
public bool $showAuthor = true;
public bool $showMeta = true;
public bool $showSocial = true;
public array $classes = [];
public function mount(
@ -65,7 +71,7 @@ class BlogPost extends Component
$locale = app()->getLocale();
return [
'title' => $this->getTitle() . ' - Blog',
'title' => $this->getTitle().' - Blog',
'description' => $this->getExcerpt(160),
'keywords' => $this->post->getTranslation('meta_keywords', $locale),
'og_title' => $this->getTitle(),
@ -85,6 +91,7 @@ class BlogPost extends Component
public function getTitle(): string
{
$locale = app()->getLocale();
return $this->post->getTranslation('title', $locale);
}
@ -94,6 +101,7 @@ class BlogPost extends Component
public function getContent(): string
{
$locale = app()->getLocale();
return $this->post->getTranslation('content', $locale);
}
@ -228,6 +236,7 @@ class BlogPost extends Component
public function getRelatedPostTitle(BlogPostModel $relatedPost): string
{
$locale = app()->getLocale();
return $relatedPost->getTranslation('title', $locale);
}
@ -311,4 +320,4 @@ class BlogPost extends Component
'next' => $nextPost,
];
}
}
}

View file

@ -2,18 +2,24 @@
namespace FluxCms\Components\Livewire\Frontend;
use Livewire\Component;
use FluxCms\Core\Models\Navigation;
use Illuminate\Support\Collection;
use Livewire\Component;
class NavigationRenderer extends Component
{
public string $domainKey;
public string $navigationName;
public ?Navigation $navigation = null;
public Collection $navigationItems;
public string $currentUrl = '';
public array $classes = [];
public bool $showInactive = false;
public function mount(
@ -49,7 +55,7 @@ class NavigationRenderer extends Component
$this->navigationItems = $this->navigation->getHierarchicalItems();
// Filter inactive items if needed
if (!$this->showInactive) {
if (! $this->showInactive) {
$this->navigationItems = $this->navigationItems->where('is_active', true);
}
} else {
@ -79,6 +85,7 @@ class NavigationRenderer extends Component
public function getItemLabel($item): string
{
$locale = app()->getLocale();
return $item->getTranslation('label', $locale);
}
@ -95,9 +102,10 @@ class NavigationRenderer extends Component
*/
public function getChildren($item): Collection
{
if (!$this->showInactive) {
if (! $this->showInactive) {
return $item->children->where('is_active', true);
}
return $item->children;
}
@ -114,8 +122,9 @@ class NavigationRenderer extends Component
*/
public function getNavigationClasses(): string
{
$defaultClasses = ['flux-cms-navigation', 'navigation-' . $this->navigationName];
$defaultClasses = ['flux-cms-navigation', 'navigation-'.$this->navigationName];
$allClasses = array_merge($defaultClasses, $this->classes);
return implode(' ', $allClasses);
}
@ -138,7 +147,7 @@ class NavigationRenderer extends Component
$classes[] = 'nav-item--has-children';
}
if (!$item->is_active) {
if (! $item->is_active) {
$classes[] = 'nav-item--inactive';
}
@ -177,7 +186,7 @@ class NavigationRenderer extends Component
$attributeStrings = [];
foreach ($attributes as $key => $value) {
$attributeStrings[] = $key . '="' . htmlspecialchars($value) . '"';
$attributeStrings[] = $key.'="'.htmlspecialchars($value).'"';
}
return implode(' ', $attributeStrings);
@ -239,11 +248,12 @@ class NavigationRenderer extends Component
*/
public function getNavigationDisplayName(): string
{
if (!$this->navigation) {
if (! $this->navigation) {
return '';
}
$locale = app()->getLocale();
return $this->navigation->getTranslation('display_name', $locale);
}
}
}

View file

@ -2,15 +2,18 @@
namespace FluxCms\Components\Livewire\Frontend;
use Livewire\Component;
use FluxCms\Core\Models\Page;
use Illuminate\Support\Collection;
use Livewire\Component;
class PageRenderer extends Component
{
public Page $page;
public Collection $components;
public bool $isPreview = false;
public array $seoData = [];
public function mount(Page $page, bool $isPreview = false)
@ -24,10 +27,10 @@ class PageRenderer extends Component
public function render()
{
return view('flux-cms-components::livewire.frontend.page-renderer')
->layout('flux-cms-components::layouts.frontend', [
'seoData' => $this->seoData,
'page' => $this->page,
]);
->layout('flux-cms-components::layouts.frontend', [
'seoData' => $this->seoData,
'page' => $this->page,
]);
}
/**
@ -84,6 +87,7 @@ class PageRenderer extends Component
public function getComponentContent(PageComponent $component): array
{
$locale = app()->getLocale();
return $component->getTranslatedContent($locale);
}
@ -93,23 +97,24 @@ class PageRenderer extends Component
public function renderComponent(PageComponent $component): string
{
try {
if (!$this->canRenderComponent($component)) {
if (! $this->canRenderComponent($component)) {
return '';
}
$content = $this->getComponentContent($component);
// Check if component class exists
if (!class_exists($component->component_class)) {
if (! class_exists($component->component_class)) {
if ($this->isPreview) {
return $this->renderComponentError($component, 'Component class not found');
}
return '';
}
// Render component
$componentHtml = \Livewire\Livewire::mount($component->component_class, [
'content' => $component->getTranslations('content')
'content' => $component->getTranslations('content'),
])->html();
// Wrap component if enabled
@ -123,7 +128,7 @@ class PageRenderer extends Component
\Log::error('Error rendering component', [
'component_id' => $component->id,
'component_class' => $component->component_class,
'error' => $e->getMessage()
'error' => $e->getMessage(),
]);
if ($this->isPreview) {
@ -141,10 +146,10 @@ class PageRenderer extends Component
{
$classes = [
'flux-cms-component',
'flux-cms-component--' . class_basename($component->component_class),
'flux-cms-component--'.class_basename($component->component_class),
];
if (!$component->is_active) {
if (! $component->is_active) {
$classes[] = 'flux-cms-component--inactive';
}
@ -157,7 +162,7 @@ class PageRenderer extends Component
}
$attributeString = collect($attributes)
->map(fn($value, $key) => "{$key}=\"{$value}\"")
->map(fn ($value, $key) => "{$key}=\"{$value}\"")
->implode(' ');
$classString = implode(' ', $classes);
@ -196,9 +201,9 @@ class PageRenderer extends Component
public function getRelatedPages(): Collection
{
return Page::forDomain($this->page->domain_key)
->published()
->where('id', '!=', $this->page->id)
->limit(3)
->get();
->published()
->where('id', '!=', $this->page->id)
->limit(3)
->get();
}
}
}

View file

@ -2,10 +2,10 @@
namespace FluxCms\Components\Tests\Feature\Backend;
use Livewire\Livewire;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Components\Livewire\Backend\BlogEditor;
use FluxCms\Core\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Orchestra\Testbench\TestCase;
class BlogEditorTest extends TestCase

View file

@ -19,9 +19,7 @@
"require": {
"php": "^8.2",
"laravel/framework": "^11.0|^12.0",
"spatie/laravel-translatable": "^6.0",
"spatie/laravel-medialibrary": "^11.0",
"spatie/laravel-tags": "^4.0"
"spatie/laravel-translatable": "^6.0"
},
"require-dev": {
"orchestra/testbench": "^9.0",
@ -53,4 +51,4 @@
"pestphp/pest-plugin": true
}
}
}
}

View file

@ -143,27 +143,61 @@ return [
'media' => [
'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'),
'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB
'originals_path' => 'cms/media/originals',
'conversions_path' => 'cms/media/conversions',
'allowed_extensions' => [
'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'],
'videos' => ['mp4', 'webm', 'ogg', 'avi', 'mov'],
'audio' => ['mp3', 'wav', 'ogg', 'flac'],
],
'conversions' => [
'profiles' => [
'thumb' => [
'width' => 300,
'height' => 300,
'fit' => 'crop',
'format' => 'webp',
'quality' => 80,
'fit' => 'cover',
],
'medium' => [
'hero' => [
'width' => 1920,
'height' => 800,
'format' => 'webp',
'quality' => 85,
'fit' => 'cover',
],
'service' => [
'width' => 800,
'height' => 600,
'fit' => 'contain',
'format' => 'webp',
'quality' => 85,
'fit' => 'cover',
],
'large' => [
'avatar' => [
'width' => 400,
'height' => 400,
'format' => 'webp',
'quality' => 85,
'fit' => 'cover',
],
'news' => [
'width' => 1200,
'height' => 900,
'fit' => 'contain',
'height' => 400,
'format' => 'webp',
'quality' => 85,
'fit' => 'cover',
],
'thumbnail' => [
'width' => 600,
'height' => 400,
'format' => 'webp',
'quality' => 80,
'fit' => 'cover',
],
'og_image' => [
'width' => 1200,
'height' => 630,
'format' => 'jpg',
'quality' => 90,
'fit' => 'cover',
],
],
],
@ -184,12 +218,22 @@ return [
'basic' => ['bold', 'italic'],
'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
'full' => [
'bold', 'italic', 'underline', 'strike',
'heading1', 'heading2', 'heading3',
'bulletList', 'orderedList',
'link', 'image', 'table',
'code', 'codeBlock',
'quote', 'rule'
'bold',
'italic',
'underline',
'strike',
'heading1',
'heading2',
'heading3',
'bulletList',
'orderedList',
'link',
'image',
'table',
'code',
'codeBlock',
'quote',
'rule',
],
],
],
@ -302,4 +346,4 @@ return [
'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true),
],
];
];

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_contents', function (Blueprint $table) {
$table->id();
$table->string('group')->index();
$table->string('key');
$table->string('type')->default('text');
$table->json('value')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->unique(['group', 'key']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_contents');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_downloads', function (Blueprint $table) {
$table->id();
$table->json('title');
$table->json('description')->nullable();
$table->string('category');
$table->string('file_path')->nullable();
$table->string('thumbnail')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
$table->index('category');
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_downloads');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_linkedin_posts', function (Blueprint $table) {
$table->id();
$table->string('linkedin_id')->nullable()->unique();
$table->json('title');
$table->json('excerpt')->nullable();
$table->json('content')->nullable();
$table->string('author')->nullable();
$table->date('date')->nullable();
$table->string('url')->nullable();
$table->string('image')->nullable();
$table->json('tags')->nullable();
$table->string('source')->default('manual');
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_linkedin_posts');
}
};

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_faqs', function (Blueprint $table) {
$table->id();
$table->string('category')->index();
$table->json('question');
$table->json('answer');
$table->json('help')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_faqs');
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_news_items', function (Blueprint $table) {
$table->id();
$table->string('icon')->nullable();
$table->json('text');
$table->json('title');
$table->json('excerpt')->nullable();
$table->json('content')->nullable();
$table->string('image')->nullable();
$table->date('date')->nullable();
$table->string('author')->nullable();
$table->string('link')->nullable();
$table->string('pdf_path')->nullable();
$table->json('pdf_open_text')->nullable();
$table->json('pdf_download_text')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_news_items');
}
};

View file

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_industries', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_industries');
}
};

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_media', function (Blueprint $table) {
$table->id();
$table->string('filename');
$table->string('disk')->default('public');
$table->string('path');
$table->string('type')->default('image');
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('file_size')->default(0);
$table->unsignedInteger('original_width')->nullable();
$table->unsignedInteger('original_height')->nullable();
$table->json('alt_text')->nullable();
$table->json('title')->nullable();
$table->string('collection')->nullable()->index();
$table->json('conversions')->nullable();
$table->boolean('is_published')->default(true);
$table->unsignedInteger('order')->default(0);
$table->timestamps();
$table->index(['type', 'collection']);
$table->index('is_published');
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_media');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_search_index', function (Blueprint $table) {
$table->id();
$table->string('item_id')->unique();
$table->string('route');
$table->json('route_params')->nullable();
$table->json('category');
$table->string('title_key')->nullable();
$table->json('title_fallback')->nullable();
$table->string('description_key')->nullable();
$table->string('description_fallback_key')->nullable();
$table->json('description_fallback_text')->nullable();
$table->json('keywords');
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_search_index');
}
};

View file

@ -0,0 +1,186 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
class CmsContentSeeder extends Seeder
{
/**
* Files to skip entirely (handled by dedicated seeders or irrelevant).
*
* @var array<string>
*/
protected array $skipFiles = [
'faqs',
'sections',
'search_index',
'validation',
'auth',
'passwords',
'pagination',
];
/**
* Keys to skip within specific groups (handled by dedicated models).
*
* @var array<string, array<string>>
*/
protected array $skipKeys = [
'components' => ['news_band', 'industries_band'],
];
public function run(): void
{
$locales = ['de', 'en'];
$deFiles = glob(lang_path('de/*.php'));
if (! $deFiles) {
return;
}
foreach ($deFiles as $filePath) {
$group = pathinfo($filePath, PATHINFO_FILENAME);
if (in_array($group, $this->skipFiles)) {
continue;
}
$translations = [];
foreach ($locales as $locale) {
$localePath = lang_path("{$locale}/{$group}.php");
if (file_exists($localePath)) {
$translations[$locale] = require $localePath;
}
}
if (empty($translations)) {
continue;
}
$deData = $translations['de'] ?? [];
$enData = $translations['en'] ?? [];
$flatDe = $this->flatten($deData);
$flatEn = $this->flatten($enData);
$allKeys = array_keys($flatDe);
foreach (array_keys($flatEn) as $enKey) {
if (! in_array($enKey, $allKeys)) {
$allKeys[] = $enKey;
}
}
$order = 0;
foreach ($allKeys as $key) {
if ($this->shouldSkipKey($group, $key)) {
continue;
}
$deValue = $flatDe[$key] ?? null;
$enValue = $flatEn[$key] ?? null;
$type = $this->detectType($deValue ?? $enValue);
$translatedValue = [];
if ($deValue !== null) {
$translatedValue['de'] = is_string($deValue) ? $this->cleanHtml($deValue) : $deValue;
}
if ($enValue !== null) {
$translatedValue['en'] = is_string($enValue) ? $this->cleanHtml($enValue) : $enValue;
}
CmsContent::updateOrCreate(
['group' => $group, 'key' => $key],
[
'type' => $type,
'value' => $translatedValue,
'order' => $order++,
]
);
}
}
}
/**
* Flatten a nested array into dot-notation keys.
* Arrays of objects (indexed arrays) are stored as JSON type.
*
* @return array<string, mixed>
*/
protected function flatten(array $array, string $prefix = ''): array
{
$result = [];
foreach ($array as $key => $value) {
$fullKey = $prefix ? "{$prefix}.{$key}" : (string) $key;
if (is_array($value) && ! Arr::isAssoc($value)) {
$result[$fullKey] = $value;
} elseif (is_array($value)) {
$result = array_merge($result, $this->flatten($value, $fullKey));
} else {
$result[$fullKey] = $value;
}
}
return $result;
}
protected function detectType(mixed $value): string
{
if (is_array($value)) {
return 'json';
}
if (! is_string($value)) {
return 'text';
}
if (preg_match('/<[a-z][\s\S]*>/i', $value)) {
return 'html';
}
if (preg_match('/\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i', $value)) {
return 'image';
}
if (preg_match('/\.(pdf|doc|docx)$/i', $value)) {
return 'link';
}
return 'text';
}
/**
* Clean HTML by converting font-weight spans to <strong> tags.
* Preserves text-gradient-premium spans and email-protection spans.
*/
protected function cleanHtml(string $value): string
{
$value = preg_replace(
'/<span\s+class="[^"]*(?:font-semibold|font-bold)[^"]*">(\s*)(.*?)<\/span>/si',
'$1<strong>$2</strong>',
$value
);
return $value;
}
protected function shouldSkipKey(string $group, string $key): bool
{
if (! isset($this->skipKeys[$group])) {
return false;
}
foreach ($this->skipKeys[$group] as $skipPrefix) {
if ($key === $skipPrefix || str_starts_with($key, "{$skipPrefix}.")) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,218 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsDownload;
use Illuminate\Database\Seeder;
class CmsDownloadSeeder extends Seeder
{
public function run(): void
{
$items = $this->getItems();
foreach ($items as $index => $item) {
CmsDownload::updateOrCreate(
[
'category' => $item['category'],
'order' => $index,
],
[
'title' => $item['title'],
'description' => $item['description'],
'icon' => $item['icon'],
'sub_category' => $item['sub_category'],
'type_label' => $item['type_label'],
'alt' => $item['alt'],
'thumbnail' => $item['thumbnail'],
'file_path' => $item['file_path'],
'open_text' => $item['open_text'],
'download_text' => $item['download_text'],
'highlights' => $item['highlights'] ?? null,
'checkpoints' => $item['checkpoints'] ?? null,
'is_published' => true,
]
);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function getItems(): array
{
return [
// === Case Studies ===
[
'category' => 'case_study',
'title' => ['de' => 'Hair-Care R&D Product Support', 'en' => 'Hair-Care R&D Product Support'],
'description' => ['de' => 'Ein globaler FMCG-Kunde im Bereich Hair Care musste eine komplexe R&D-Roadmap umsetzen und wir die Koordination von rund 5 parallelen Entwicklungsinitiativen koordinierten.', 'en' => 'A global FMCG client in the hair care segment needed to implement a complex R&D roadmap. We coordinated approximately 5 parallel development initiatives.'],
'icon' => 'document-chart-bar',
'sub_category' => 'R&D Product Support',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Hair-Care R&D Product Support', 'en' => 'Case Study Hair-Care R&D Product Support'],
'thumbnail' => 'case-study-7011.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'en' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'highlights' => [['value' => '100%', 'label' => 'Dokumentations-<br>konformität'], ['value' => '5', 'label' => 'parallele<br>Entwicklungsinitiativen']],
],
[
'category' => 'case_study',
'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'],
'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Lab Support Data Integration',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'],
'thumbnail' => 'case-study-7012.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration<br>&nbsp;']],
],
[
'category' => 'case_study',
'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'],
'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Fragrance Pump Experience',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'],
'thumbnail' => 'case-study-7013.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'highlights' => [['value' => '∞', 'label' => 'Objektivierung-<br>von Emotionen'], ['value' => '∞', 'label' => 'Globale<br>Differenzierung']],
],
[
'category' => 'case_study',
'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'],
'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Master Data Excellence',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'],
'thumbnail' => 'case-study-7010.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz<br>&nbsp;']],
],
// === Capabilities ===
[
'category' => 'capability',
'title' => ['de' => 'Global Player', 'en' => 'Global Player'],
'description' => ['de' => 'Beherrschen Sie globale Komplexität. Skalieren Sie Innovationen sicher über Märkte und Werke hinweg.', 'en' => 'Master global complexity. Scale innovations safely across markets and plants.'],
'icon' => 'document-text',
'sub_category' => 'Globale Player & Internationale Projekte',
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
'alt' => ['de' => 'Capability Profile Global Player', 'en' => 'Capability Profile Global Player'],
'thumbnail' => 'global-player.webp',
'file_path' => ['de' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'en' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'checkpoints' => [['value' => 'Etablieren Sie globale Exzellenz'], ['value' => 'Sichern Sie Ihre "License to Operate"'], ['value' => 'Synchronisieren Sie Zentrale und Werke']],
],
[
'category' => 'capability',
'title' => ['de' => 'Nationale Champions', 'en' => 'National Champions'],
'description' => ['de' => 'Realisieren Sie große Ideen mit pragmatischer Schlagkraft. Nutzen Sie bewährte Methoden maßgeschneidert für Ihre Strukturen.', 'en' => 'Turn big ideas into reality with pragmatic impact. Use proven methods tailored to your structures.'],
'icon' => 'document-text',
'sub_category' => 'Nationale Champions & Regionale Akteure',
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
'alt' => ['de' => 'Capability Profile Nationale Champions', 'en' => 'Capability Profile National Champions'],
'thumbnail' => 'nationale-champions.webp',
'file_path' => ['de' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'en' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'checkpoints' => [['value' => 'Erweitern Sie Ihre Handlungsfähigkeit'], ['value' => 'Profitieren Sie von Best-Practices'], ['value' => 'Steigern Sie Ihre Marge']],
],
[
'category' => 'capability',
'title' => ['de' => 'Leistungsübersicht', 'en' => 'Service Overview'],
'description' => ['de' => 'Bündeln Sie Ihre Anforderungen. Wir bieten Ihnen ein integriertes Spektrum aus Packaging, Engineering, Projektmanagement und spezialisiertem Consulting.', 'en' => 'We offer you an integrated spectrum of Packaging, Engineering, Project Management and specialized consulting.'],
'icon' => 'document-text',
'sub_category' => 'Ihr Leistungsportfolio für technische Exzellenz.',
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
'alt' => ['de' => 'Capability Leistungsübersicht', 'en' => 'Capability Service Overview for Technical Excellence'],
'thumbnail' => 'keyvisual.webp',
'file_path' => ['de' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'en' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'checkpoints' => [['value' => 'Ihre Buchungsmodelle'], ['value' => 'Verfügbare Experten-Rollen'], ['value' => 'Warum inno-projekt?']],
],
[
'category' => 'capability',
'title' => ['de' => 'Master Data Management', 'en' => 'Master Data Management'],
'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Master Data Management und Systemintegration für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in master data management and system integration for FMCG companies.'],
'icon' => 'document-text',
'sub_category' => 'Verwandeln Sie Datenchaos in Prozessgeschwindigkeit.',
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
'alt' => ['de' => 'Capability Profile Master Data Management', 'en' => 'Capability Profile Master Data Management'],
'thumbnail' => 'leistung-2.webp',
'file_path' => ['de' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'en' => 'inno-projekt-Capability_9012_MasterData_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'checkpoints' => [['value' => 'Entlasten Sie Ihre Experten'], ['value' => 'Beschleunigen Sie Ihren Markteintritt'], ['value' => 'Garantieren Sie Compliance']],
],
[
'category' => 'capability',
'title' => ['de' => 'Integrated Consumer Research', 'en' => 'Integrated Consumer Research'],
'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Integrated Consumer Research für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in integrated consumer research for FMCG companies.'],
'icon' => 'document-text',
'sub_category' => 'Verwandeln Sie subjektives Erleben in messbare technische Daten.',
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
'alt' => ['de' => 'Capability Integrated Consumer Research', 'en' => 'Capability Integrated Consumer Research'],
'thumbnail' => 'leistung-2.webp',
'file_path' => ['de' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'en' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
'checkpoints' => [['value' => 'Minimieren Sie Fehlentwicklungen'], ['value' => 'Machen Sie Markenwerte messbar'], ['value' => 'Verstehen Sie Ihre "Emotional Map"']],
],
// === Success Stories ===
[
'category' => 'success_story',
'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'],
'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Lab Support Data Integration',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'],
'thumbnail' => 'case-study-7012.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration<br>&nbsp;']],
],
[
'category' => 'success_story',
'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'],
'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Fragrance Pump Experience',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'],
'thumbnail' => 'case-study-7013.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
'highlights' => [['value' => '∞', 'label' => 'Objektivierung-<br>von Emotionen'], ['value' => '∞', 'label' => 'Globale<br>Differenzierung']],
],
[
'category' => 'success_story',
'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'],
'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'],
'icon' => 'document-chart-bar',
'sub_category' => 'Master Data Excellence',
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'],
'thumbnail' => 'case-study-7010.webp',
'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'],
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz<br>&nbsp;']],
],
];
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsFaq;
use Illuminate\Database\Seeder;
class CmsFaqSeeder extends Seeder
{
public function run(): void
{
$locales = ['de', 'en'];
$faqsByLocale = [];
foreach ($locales as $locale) {
$path = lang_path("{$locale}/faqs.php");
if (file_exists($path)) {
$faqsByLocale[$locale] = require $path;
}
}
$deData = $faqsByLocale['de'] ?? [];
$enData = $faqsByLocale['en'] ?? [];
$allCategories = array_unique(array_merge(array_keys($deData), array_keys($enData)));
foreach ($allCategories as $category) {
$deCategory = $deData[$category] ?? [];
$enCategory = $enData[$category] ?? [];
$deItems = $deCategory['items'] ?? [];
$enItems = $enCategory['items'] ?? [];
$maxCount = max(count($deItems), count($enItems));
for ($i = 0; $i < $maxCount; $i++) {
$de = $deItems[$i] ?? [];
$en = $enItems[$i] ?? [];
CmsFaq::create([
'category' => $category,
'question' => array_filter([
'de' => $de['question'] ?? null,
'en' => $en['question'] ?? null,
]),
'answer' => array_filter([
'de' => $de['answer'] ?? null,
'en' => $en['answer'] ?? null,
]),
'help' => array_filter([
'de' => $de['help'] ?? null,
'en' => $en['help'] ?? null,
]) ?: null,
'is_published' => true,
'order' => $i,
]);
}
}
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsIndustry;
use Illuminate\Database\Seeder;
class CmsIndustrySeeder extends Seeder
{
public function run(): void
{
$locales = ['de', 'en'];
$industriesByLocale = [];
foreach ($locales as $locale) {
$path = lang_path("{$locale}/components.php");
if (file_exists($path)) {
$data = require $path;
$industriesByLocale[$locale] = $data['industries_band']['industries'] ?? [];
}
}
$deIndustries = $industriesByLocale['de'] ?? [];
$enIndustries = $industriesByLocale['en'] ?? [];
$maxCount = max(count($deIndustries), count($enIndustries));
for ($i = 0; $i < $maxCount; $i++) {
CmsIndustry::updateOrCreate(
['order' => $i],
[
'name' => array_filter([
'de' => $deIndustries[$i] ?? null,
'en' => $enIndustries[$i] ?? null,
]),
'is_published' => true,
]
);
}
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsLinkedinPost;
use Illuminate\Database\Seeder;
class CmsLinkedinPostSeeder extends Seeder
{
public function run(): void
{
$posts = $this->getFallbackPosts();
foreach ($posts as $index => $post) {
CmsLinkedinPost::updateOrCreate(
['linkedin_id' => $post['id'] ?? null],
[
'title' => ['de' => $post['title'], 'en' => $post['title']],
'excerpt' => ['de' => $post['excerpt'], 'en' => $post['excerpt']],
'content' => ['de' => $post['content'], 'en' => $post['content']],
'author' => $post['author'] ?? 'inno-projekt',
'date' => $post['date'] ?? null,
'url' => $post['url'] ?? null,
'image' => $post['image'] ?? null,
'tags' => $post['tags'] ?? [],
'source' => 'manual',
'is_published' => true,
'order' => $index,
]
);
}
}
/**
* @return array<int, array<string, mixed>>
*/
protected function getFallbackPosts(): array
{
return [
[
'id' => '1',
'title' => 'How to relieve your project management team and accelerate projects.',
'excerpt' => 'Project managers in the FMCG sector often find themselves caught between two stools...',
'content' => '<strong>How to relieve your project management team and accelerate projects.</strong><br><br>Project managers in the FMCG sector often find themselves caught between two stools: they are expected to meet the strategic expectations of management while at the same time solving operational problems on the front line.',
'date' => '2026-01-13',
'author' => 'inno-projekt',
'tags' => ['Update', 'LinkedIn'],
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_how-to-relieve-your-project-management-team-activity-7416741386902790144-dfJf',
'image' => 'post-1.jpeg',
],
[
'id' => '2',
'title' => '2026 will be a clearly defined stress test for many packaging concepts.',
'excerpt' => 'From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding...',
'content' => '<strong>2026 will be a clearly defined stress test for many packaging concepts, primarily due to one thing:</strong><br><br>From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding.',
'date' => '2026-01-09',
'author' => 'inno-projekt',
'tags' => ['Update', 'LinkedIn'],
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_2026-will-be-a-clearly-defined-stress-test-activity-7414929446262018049-2JjY',
'image' => 'post-2.jpeg',
],
[
'id' => '3',
'title' => 'For many, the new working year is beginning these days.',
'excerpt' => 'For us, it is time to take on responsibility again...',
'content' => '<strong>For many, the new working year is beginning these days.</strong><br><br>For us, it is time to take on responsibility again.',
'date' => '2026-01-02',
'author' => 'inno-projekt',
'tags' => ['Update', 'LinkedIn'],
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_for-many-the-new-working-year-is-beginning-activity-7414204668291059712-MfKU',
'image' => 'post-3.jpeg',
],
];
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
class CmsMediaSeeder extends Seeder
{
public function run(): void
{
$mediaItems = [
['filename' => 'success-1.webp', 'path' => 'cms/media/originals/Q1qptIeXjLHi2l05i9ovwVka7uVKmyHnYBh3xQN6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 30960, 'original_width' => 768, 'original_height' => 512],
['filename' => 'success-2.webp', 'path' => 'cms/media/originals/btwUGqS3gj45hjnPelRu9uXfyUvACKsU81C7efZk.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512],
['filename' => 'success-3.webp', 'path' => 'cms/media/originals/s13wFweUaQytN4tDlLXjuEr4VEIuXDFPElVS43hg.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46794, 'original_width' => 768, 'original_height' => 512],
['filename' => 'team.webp', 'path' => 'cms/media/originals/cUs47da887T1ZfkrHXTGbTE45LEAy6S8421E4YvD.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33650, 'original_width' => 768, 'original_height' => 512],
['filename' => 'save-pillar-1.webp', 'path' => 'cms/media/originals/G5eDDfenyKtbRiP1w1QT79EANtdArogAOmYc402W.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42774, 'original_width' => 768, 'original_height' => 512],
['filename' => 'save-pillar-2.webp', 'path' => 'cms/media/originals/hlhfRpV5GYKZCA9KSgJG8dY3ItULKn8o1SySxRUu.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52858, 'original_width' => 768, 'original_height' => 512],
['filename' => 'save-pillar-3.webp', 'path' => 'cms/media/originals/X0FHC9ieaxBzYqR5Af9ZagqPUPZTA26zz0bU2CKo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48472, 'original_width' => 768, 'original_height' => 512],
['filename' => 'story-logo.webp', 'path' => 'cms/media/originals/Q8VL2F3lBmLQ30tWq3KZf1eeP3XUXgzAKHSaJQ8z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9796, 'original_width' => 768, 'original_height' => 370],
['filename' => 'story-taob.webp', 'path' => 'cms/media/originals/tlMQPvaP4t4nn3dM2C1njMLghPui8CjTLYXswwBP.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 6770, 'original_width' => 768, 'original_height' => 300],
['filename' => 'mittelstand.webp', 'path' => 'cms/media/originals/me3XNKIxWVw5pNhEZldm8GEN6CUvN4wjtm7ElGfx.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512],
['filename' => 'mittelstand1.webp', 'path' => 'cms/media/originals/V5MFyj8JWMo6WaBqfJJmqCA9drykvC65349RnU9H.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512],
['filename' => 'nationale-champions.webp', 'path' => 'cms/media/originals/LTSgeytA3mncxZxdXD5SlULD6EBcTiZCwuaDJ89w.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 66366, 'original_width' => 768, 'original_height' => 512],
['filename' => 'projekte1.webp', 'path' => 'cms/media/originals/0MPvcE7cpGeDe64JCWaF3heCQetvU0cOpGYZnvXf.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51146, 'original_width' => 768, 'original_height' => 512],
['filename' => 'leistung-1.webp', 'path' => 'cms/media/originals/oVi2TrTyITKKGD22wjfhcjopgDzMofFqO2XX0Mh1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42336, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistung-2.webp', 'path' => 'cms/media/originals/tVbL57cQkrKSQIkG1WWk9jOt4z9RiZUobLit5vpt.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 87448, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistung-3.webp', 'path' => 'cms/media/originals/e7mwnJSowDbCjxQimayoUP0tZAOkzvT7y1LQjqYp.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 49824, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistung-4.webp', 'path' => 'cms/media/originals/JHCKZVE1c9dor97PfD6yQhjkmd7PIJqwkilVBXAV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistung-5.webp', 'path' => 'cms/media/originals/FCElmkfJ0Qacp7vQRMeR1J5s9oxjRNdcFEVyoOJE.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48686, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistungen-4.webp', 'path' => 'cms/media/originals/pbbDLBoYBI0I8Quzy0L9rHkmMzc3O78xmG9ZccRS.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600],
['filename' => 'leistungen.webp', 'path' => 'cms/media/originals/ggwLmiykMRovYcMExdvHRRmUrkJrxMCpF5ZfOXJT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51018, 'original_width' => 800, 'original_height' => 600],
['filename' => 'karriere1.webp', 'path' => 'cms/media/originals/KvaAufDevlUTVWQhQI5dsE6mVxORB063I8wmd1L1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44516, 'original_width' => 768, 'original_height' => 512],
['filename' => 'keyvisual-small.webp', 'path' => 'cms/media/originals/MHnntV48r17drkJIKZna9NG5Zqye62ypAFSA90L6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 35164, 'original_width' => 960, 'original_height' => 400],
['filename' => 'keyvisual.webp', 'path' => 'cms/media/originals/ma1SzM8v4l4pQeVGhrJ80bYxq2bBnuRC7C1hwNSV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9762, 'original_width' => 768, 'original_height' => 512],
['filename' => 'kontakt.webp', 'path' => 'cms/media/originals/3r2ugopYW4Rbiups57eNdzrjpqPvQYc19oVEoUap.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33692, 'original_width' => 768, 'original_height' => 512],
['filename' => 'grosskonzerne.webp', 'path' => 'cms/media/originals/NBSE2qKQ1uZChAgVNAYespoLKVBzYYtAE96AoK1o.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 47772, 'original_width' => 768, 'original_height' => 512],
['filename' => 'integration-process.webp', 'path' => 'cms/media/originals/nqOmw0aYAvY0QP1OCfMvrl9B3rpIsDIRPuyNvoxz.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 54274, 'original_width' => 800, 'original_height' => 600],
['filename' => 'karriere.webp', 'path' => 'cms/media/originals/V9Nkxj9ViC90a6kO6Qcg5UYWZpA3YkNBUEAyi2d0.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 45306, 'original_width' => 768, 'original_height' => 512],
['filename' => 'dirigent.webp', 'path' => 'cms/media/originals/ZdIjdXC5QMhAqhqluKLlOEB4Qi9LjqWz5BCOhTPw.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40648, 'original_width' => 768, 'original_height' => 512],
['filename' => 'global-player.webp', 'path' => 'cms/media/originals/TJj3114VYbME1b5Mz3KZ8OWwEvByXMNdHkAggzud.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40444, 'original_width' => 768, 'original_height' => 512],
['filename' => 'case-study-7010.webp', 'path' => 'cms/media/originals/ZbRnWxUI5K15CyistdZ0wxRojDoqeYHAeomVehx3.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 70382, 'original_width' => 768, 'original_height' => 512],
['filename' => 'case-study-7011.webp', 'path' => 'cms/media/originals/9b0tmCAz0msWZaCl1EPFKnu0fzs1HOwHEiiQPixo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53120, 'original_width' => 768, 'original_height' => 512],
['filename' => 'case-study-7012.webp', 'path' => 'cms/media/originals/DPVmai1rcxWxYwUPL9J0ON3AYvAe9tGOTeyEanLC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 55586, 'original_width' => 768, 'original_height' => 512],
['filename' => 'case-study-7013.webp', 'path' => 'cms/media/originals/mLtyPzKJRbDeciSBdKRwxgy9fHQQr7sySdrgHD7L.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52752, 'original_width' => 768, 'original_height' => 512],
['filename' => 'capability-global-player.webp', 'path' => 'cms/media/originals/Io8eU0kzezXhAC3nUD0c0udqqEYzU6UG2wDJUzOr.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 28912, 'original_width' => 768, 'original_height' => 300],
['filename' => 'capability-national-champions.webp', 'path' => 'cms/media/originals/5WFTRenjgq8ZMS1w5zhPOAlK7yESJozAzchixY6g.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46992, 'original_width' => 768, 'original_height' => 300],
['filename' => 'capability-overview_of_services.webp', 'path' => 'cms/media/originals/Vjylm0dN37CEEH53zi3ZxHWTx1TOR7KYekrnhb3z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 34220, 'original_width' => 768, 'original_height' => 300],
['filename' => 'case-studies.webp', 'path' => 'cms/media/originals/wf3oMRP9pRGk32vvI0p4e25F7RT9pEiX3wIs8cO8.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44688, 'original_width' => 768, 'original_height' => 512],
['filename' => 'art.webp', 'path' => 'cms/media/originals/U9owB5ZZNSM8mIjexOpZbW7ypKZoRHFuC7igXOuT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53226, 'original_width' => 768, 'original_height' => 512],
['filename' => 'bridge-builder-2.webp', 'path' => 'cms/media/originals/w1cNSvzLriT6mqqwOEMhoUVI3O3UMiT34IZqVmMC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 37212, 'original_width' => 768, 'original_height' => 512],
['filename' => 'bridge-builder.webp', 'path' => 'cms/media/originals/n4ZWkwGSsa73oCDFjIvVgs4kG3iEOc8BEYEXl7fW.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33050, 'original_width' => 768, 'original_height' => 512],
['filename' => 'capabilities.webp', 'path' => 'cms/media/originals/5VTPNpgX3vwpzJVipg7RczfbVyU5lOXPpCTrRu66.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 41244, 'original_width' => 768, 'original_height' => 512],
['filename' => 'about-team.webp', 'path' => 'cms/media/originals/iBJanSjRP72c5smgMu3bKVaVIbMrXhah3nOd5B1I.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 39250, 'original_width' => 800, 'original_height' => 600],
['filename' => 'about.webp', 'path' => 'cms/media/originals/cHKkrpxYyjeaqCUMgBuO8bVdJZQPQwLYxqKOqR4O.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512],
['filename' => 'architekt.webp', 'path' => 'cms/media/originals/AaTTxGRDVSDMzfPSkANrT2gI4PjFTltLykPnk5vQ.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 27666, 'original_width' => 768, 'original_height' => 512],
['filename' => 'art-of-balance.webp', 'path' => 'cms/media/originals/2x1uvwBHhHhBdJwOnqXDEig4ngb1KK1UBwjhUbdV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 10636, 'original_width' => 768, 'original_height' => 512],
['filename' => 'about-1.webp', 'path' => 'cms/media/originals/pcd3MP3TQ189hurZEXhuzrCEE2uAziXRjvyMGl5r.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 32548, 'original_width' => 768, 'original_height' => 512],
['filename' => 'art-of-balance.jpg', 'path' => 'cms/media/originals/0cbt8cH8mXsZ7i5juu45WYZDx1QLWKAlfdrXj158.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 30205, 'original_width' => 768, 'original_height' => 512],
['filename' => 'daniel-el-titi.jpg', 'path' => 'cms/media/originals/FKKBL4HtByeEB0VxT9vqDGdbbesWWYup23HnuCMb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 23299, 'original_width' => 400, 'original_height' => 400],
['filename' => 'dogan-nergiz.jpg', 'path' => 'cms/media/originals/HHfjQ0sEFK8bD04e3v8KX6FMKHp5skAY97wAb1Gb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 27201, 'original_width' => 400, 'original_height' => 400],
['filename' => 'jana-doepfner.jpg', 'path' => 'cms/media/originals/m7FqrMZonLZXyYIG2L03D7QSzA7y1JRjm9OCnfkX.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 29203, 'original_width' => 400, 'original_height' => 400],
['filename' => 'jessica-rath.jpg', 'path' => 'cms/media/originals/sOtVnXLtCIeBWFRvcldtHsSXb5yKLQhcwumSqunA.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 28226, 'original_width' => 400, 'original_height' => 400],
['filename' => 'marcus-thiemann.jpg', 'path' => 'cms/media/originals/79HmDT478Zrv2hPOKsQhRWr288QEhFVgLKY17Rou.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33777, 'original_width' => 400, 'original_height' => 400],
['filename' => 'markus-kirsch.jpg', 'path' => 'cms/media/originals/TgyErbPn8rOL1Lzsjo42MXoTjFZqdP7t4xmaFAnB.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33592, 'original_width' => 400, 'original_height' => 400],
['filename' => 'martina-zeidler.jpg', 'path' => 'cms/media/originals/xVkW0fKZk7Fblbd3hdj00ntC5k6clNS7zHgsyY4S.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 45997, 'original_width' => 400, 'original_height' => 400],
['filename' => 'peter-bernhards.jpg', 'path' => 'cms/media/originals/8MMBC2jzZzfNjJrKvSbuRTOQDdNgFxquhzecVZlK.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 25726, 'original_width' => 400, 'original_height' => 400],
['filename' => 'sina-roehrs.jpg', 'path' => 'cms/media/originals/ljf17PgoAMAUse4TQ1FFE6IT6twBU93r2SpC4Ngm.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 44266, 'original_width' => 400, 'original_height' => 400],
['filename' => 'post-1.jpeg', 'path' => 'cms/media/originals/5J1FlboQGfQ43gcnTPIBuntrXMkLMCtpIDRoLkro.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 76671, 'original_width' => 800, 'original_height' => 417],
['filename' => 'post-2.jpeg', 'path' => 'cms/media/originals/bwlf6A9hTehxMIUqSlUA8274SCaN9PtpKXKhkMYL.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 68640, 'original_width' => 800, 'original_height' => 417],
['filename' => 'post-3.jpeg', 'path' => 'cms/media/originals/1tWXALEJxq0UlNpA7ha936ubSxGXonwXTQHTLpKJ.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 67433, 'original_width' => 800, 'original_height' => 417],
['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'path' => 'cms/media/originals/BAY8u1AiIJx9uXdNqISgLGaXdLV5QiRhV3bQ2EC8.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 285318, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf', 'path' => 'cms/media/originals/iSkUqzR600Ujd0lypZFd0eKLrCqGEtN2oFnhiqOZ.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 297608, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'path' => 'cms/media/originals/rhh7d9qWvOnLalBFC7g1ANYJXUBjjB1ZNfFY0yns.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 454633, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9012_MasterData_en.pdf', 'path' => 'cms/media/originals/t9PpREeNXZU8xWVzZLXeqmdunq9JiZGUYNzmY2BX.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 451953, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'path' => 'cms/media/originals/fpFymdm8FwXT0v3XjJY6wks9qGlIBHeWWW8zcypF.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 358498, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf', 'path' => 'cms/media/originals/ZKwcEebti4yjCNMOfZVimblesZgfncoN8db8lSaO.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 369039, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf', 'path' => 'cms/media/originals/flcDISWYm41Cc8mTPs1ERKsmEY2pK1gnLLATOynv.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 466950, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'path' => 'cms/media/originals/UiJ4HznbKtc5QNjZoHfxD8e8fpp2zXP6Ehh17Q5g.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 470648, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'path' => 'cms/media/originals/K1mwmLVYmQGhaxLrgeyBPPOQwBUTjKh1gCR1hbn5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 213462, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf', 'path' => 'cms/media/originals/ABM3X7BRMMpViLKBc9PbpPyi1lFFQBhviIRa53Fp.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 206054, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'path' => 'cms/media/originals/ebvunH8d8qhEMamnpmA3Myvck6nOnOXzCdsC4XCr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 437216, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf', 'path' => 'cms/media/originals/whR8GmCPX6qCbMoA8T99G7LEkm2oL2Z3YEG8vYg5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 415218, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'path' => 'cms/media/originals/zf1xUGsjiCG7SI4Ci2QiXkojwcwjmsAmqObXCX0W.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 361192, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf', 'path' => 'cms/media/originals/6cr2yF2CekUyEcTz0TzNNcd2fsEEvThutJjHHmpy.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 376542, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'path' => 'cms/media/originals/bRu1mHHsUEcv0gLSkoZQPZcUf9Ixr2Y2tbML8K5o.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 402710, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf', 'path' => 'cms/media/originals/cB8h1DybKR5gjG4BNfkw4xQAa4K7DSi16sck8XCG.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 380668, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'path' => 'cms/media/originals/5yNbrbMGGm6jIC72SNPv6JHHTKWRy0Rf22MyPL0m.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 386255, 'original_width' => null, 'original_height' => null],
['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf', 'path' => 'cms/media/originals/wVjrJVHWhY1SwYvYuDhLeFjL00UMPUQ4UeyaISvr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 397411, 'original_width' => null, 'original_height' => null],
];
$service = app(MediaConversionService::class);
foreach ($mediaItems as $item) {
$media = CmsMedia::updateOrCreate(
['filename' => $item['filename']],
[
'path' => $item['path'],
'type' => $item['type'],
'mime_type' => $item['mime_type'],
'file_size' => $item['file_size'],
'original_width' => $item['original_width'],
'original_height' => $item['original_height'],
'disk' => 'public',
'is_published' => true,
]
);
if (
$item['type'] === 'image'
&& Storage::disk('public')->exists($item['path'])
) {
$service->generateThumbnail($media);
}
}
$imageEntries = [
['group' => 'welcome', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'],
['group' => 'art-of-balance', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'],
['group' => 'art-of-balance', 'key' => 'integration_image', 'value' => 'integration-process.webp'],
['group' => 'kontakt', 'key' => 'hero.image', 'value' => 'kontakt.webp'],
['group' => 'faq_page', 'key' => 'hero.image', 'value' => 'kontakt.webp'],
['group' => 'case-studies', 'key' => 'hero.image', 'value' => 'case-studies.webp'],
['group' => 'capabilities', 'key' => 'hero.image', 'value' => 'capabilities.webp'],
['group' => 'nationale-champions', 'key' => 'hero.image', 'value' => 'nationale-champions.webp'],
['group' => 'global-player', 'key' => 'hero.image', 'value' => 'global-player.webp'],
['group' => 'leistungen', 'key' => 'hero.image', 'value' => 'leistungen.webp'],
['group' => 'leistungen', 'key' => 'feature_image', 'value' => 'leistung-1.webp'],
['group' => 'karriere', 'key' => 'hero.image', 'value' => 'karriere.webp'],
['group' => 'about', 'key' => 'hero.image', 'value' => 'about-1.webp'],
['group' => 'team', 'key' => 'hero.image', 'value' => 'team.webp'],
['group' => 'about', 'key' => 'preview_image', 'value' => 'about-team.webp'],
['group' => 'digitale-transformation', 'key' => 'hero.image', 'value' => 'leistung-5.webp'],
['group' => 'master-data', 'key' => 'hero.image', 'value' => 'leistung-2.webp'],
['group' => 'nachhaltige-verpackungen', 'key' => 'hero.image', 'value' => 'leistung-3.webp'],
['group' => 'prozess-optimierung', 'key' => 'hero.image', 'value' => 'leistung-4.webp'],
['group' => 'strategische-projektumsetzung', 'key' => 'hero.image', 'value' => 'leistung-1.webp'],
];
foreach ($imageEntries as $entry) {
$content = CmsContent::updateOrCreate(
['group' => $entry['group'], 'key' => $entry['key']],
['type' => 'image']
);
$content->setTranslation('value', 'de', $entry['value']);
$content->setTranslation('value', 'en', $entry['value']);
$content->save();
}
app(\FluxCms\Core\Services\CmsContentService::class)->clearCache();
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsNewsItem;
use Illuminate\Database\Seeder;
class CmsNewsItemSeeder extends Seeder
{
/**
* @var array<string, string>
*/
private array $imageMapping = [
'/assets/images/capability-overview_of_services.jpg' => 'capability-overview_of_services.webp',
'/assets/images/capability-global-player.jpg' => 'capability-global-player.webp',
'/assets/images/capability-national-champions.jpg' => 'capability-national-champions.webp',
'/assets/images/story-taob.jpg?v1' => 'story-taob.webp',
'/assets/images/story-taob.jpg' => 'story-taob.webp',
'/assets/images/story-logo.jpg' => 'story-logo.webp',
'/assets/images/leistung-4.jpg' => 'leistung-4.webp',
'/assets/images/leistung-2.jpg' => 'leistung-2.webp',
];
/**
* @var array<string, string>
*/
private array $pdfMapping = [
'pdfs/inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf',
'pdfs/inno-projekt-Capability_9101_GlobalPlayer_de.pdf' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf',
'pdfs/inno-projekt-Capability_9102_NationaleChampions_de.pdf' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf',
];
public function run(): void
{
$locales = ['de', 'en'];
$itemsByLocale = [];
foreach ($locales as $locale) {
$path = lang_path("{$locale}/components.php");
if (file_exists($path)) {
$data = require $path;
$itemsByLocale[$locale] = $data['news_band']['items'] ?? [];
}
}
$deItems = $itemsByLocale['de'] ?? [];
$enItems = $itemsByLocale['en'] ?? [];
$maxCount = max(count($deItems), count($enItems));
for ($i = 0; $i < $maxCount; $i++) {
$de = $deItems[$i] ?? [];
$en = $enItems[$i] ?? [];
$rawImage = $de['image'] ?? $en['image'] ?? null;
$rawPdf = $de['pdf_path'] ?? $en['pdf_path'] ?? null;
CmsNewsItem::updateOrCreate(
['order' => $i],
[
'icon' => $de['icon'] ?? $en['icon'] ?? null,
'text' => array_filter(['de' => $de['text'] ?? null, 'en' => $en['text'] ?? null]),
'title' => array_filter(['de' => $de['title'] ?? null, 'en' => $en['title'] ?? null]),
'excerpt' => array_filter(['de' => $de['excerpt'] ?? null, 'en' => $en['excerpt'] ?? null]),
'content' => array_filter(['de' => $de['content'] ?? null, 'en' => $en['content'] ?? null]),
'image' => $this->resolveImage($rawImage),
'date' => $de['date'] ?? $en['date'] ?? null,
'author' => $de['author'] ?? $en['author'] ?? null,
'link' => $de['link'] ?? $en['link'] ?? null,
'pdf_path' => $this->resolvePdf($rawPdf),
'pdf_open_text' => array_filter(['de' => $de['pdf_open_text'] ?? null, 'en' => $en['pdf_open_text'] ?? null]),
'pdf_download_text' => array_filter(['de' => $de['pdf_download_text'] ?? null, 'en' => $en['pdf_download_text'] ?? null]),
'is_published' => true,
]
);
}
}
private function resolveImage(?string $path): ?string
{
if (! $path) {
return null;
}
return $this->imageMapping[$path] ?? pathinfo($path, PATHINFO_FILENAME).'.webp';
}
private function resolvePdf(?string $path): ?string
{
if (! $path) {
return null;
}
return $this->pdfMapping[$path] ?? basename($path);
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsSearchIndex;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
class CmsSearchIndexSeeder extends Seeder
{
public function run(): void
{
$deItems = $this->loadItems('de');
$enItems = $this->loadItems('en');
$deById = collect($deItems)->keyBy('id');
$enById = collect($enItems)->keyBy('id');
$allIds = $deById->keys()->merge($enById->keys())->unique();
$order = 0;
foreach ($allIds as $itemId) {
$de = $deById->get($itemId, []);
$en = $enById->get($itemId, []);
$source = ! empty($de) ? $de : $en;
CmsSearchIndex::updateOrCreate(
['item_id' => $itemId],
[
'route' => $source['route'] ?? '',
'route_params' => $source['route_params'] ?? [],
'category' => [
'de' => $de['category'] ?? ($en['category'] ?? ''),
'en' => $en['category'] ?? ($de['category'] ?? ''),
],
'title_key' => $source['title_key'] ?? null,
'title_fallback' => [
'de' => $de['title_fallback'] ?? null,
'en' => $en['title_fallback'] ?? null,
],
'description_key' => $source['description_key'] ?? null,
'description_fallback_key' => $source['description_fallback_key'] ?? null,
'description_fallback_text' => [
'de' => $de['description_fallback_text'] ?? null,
'en' => $en['description_fallback_text'] ?? null,
],
'keywords' => [
'de' => $de['keywords'] ?? [],
'en' => $en['keywords'] ?? [],
],
'is_published' => true,
'order' => $order++,
]
);
}
$this->command->info('CmsSearchIndexSeeder: '.$allIds->count().' Eintraege erstellt/aktualisiert.');
}
/**
* @return array<int, array<string, mixed>>
*/
protected function loadItems(string $locale): array
{
$path = lang_path("{$locale}/search_index.php");
if (! File::exists($path)) {
return [];
}
$config = require $path;
return $config['items'] ?? [];
}
}

View file

@ -2,9 +2,9 @@
namespace FluxCms\Core\Database\Seeders;
use Illuminate\Database\Seeder;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Core\Models\Page;
use Illuminate\Database\Seeder;
class CmsContentSeeder extends Seeder
{
@ -27,19 +27,19 @@ class CmsContentSeeder extends Seeder
'domain_key' => 'default',
'title' => [
'de' => 'Willkommen auf unserer Website',
'en' => 'Welcome to our Website'
'en' => 'Welcome to our Website',
],
'slug' => [
'de' => '/',
'en' => '/'
'en' => '/',
],
'meta_description' => [
'de' => 'Willkommen auf unserer modernen Website, erstellt mit Flux CMS.',
'en' => 'Welcome to our modern website, built with Flux CMS.'
'en' => 'Welcome to our modern website, built with Flux CMS.',
],
'meta_keywords' => [
'de' => 'Website, CMS, Flux CMS, Laravel',
'en' => 'Website, CMS, Flux CMS, Laravel'
'en' => 'Website, CMS, Flux CMS, Laravel',
],
'is_published' => true,
'published_at' => now(),
@ -50,15 +50,15 @@ class CmsContentSeeder extends Seeder
'domain_key' => 'default',
'title' => [
'de' => 'Über uns',
'en' => 'About us'
'en' => 'About us',
],
'slug' => [
'de' => '/ueber-uns',
'en' => '/about'
'en' => '/about',
],
'meta_description' => [
'de' => 'Erfahren Sie mehr über unser Unternehmen und unsere Mission.',
'en' => 'Learn more about our company and our mission.'
'en' => 'Learn more about our company and our mission.',
],
'is_published' => true,
'published_at' => now(),
@ -69,15 +69,15 @@ class CmsContentSeeder extends Seeder
'domain_key' => 'default',
'title' => [
'de' => 'Kontakt',
'en' => 'Contact'
'en' => 'Contact',
],
'slug' => [
'de' => '/kontakt',
'en' => '/contact'
'en' => '/contact',
],
'meta_description' => [
'de' => 'Kontaktieren Sie uns für weitere Informationen.',
'en' => 'Contact us for more information.'
'en' => 'Contact us for more information.',
],
'is_published' => true,
'published_at' => now(),
@ -95,19 +95,19 @@ class CmsContentSeeder extends Seeder
[
'title' => [
'de' => 'Willkommen bei Flux CMS',
'en' => 'Welcome to Flux CMS'
'en' => 'Welcome to Flux CMS',
],
'slug' => [
'de' => 'willkommen-bei-flux-cms',
'en' => 'welcome-to-flux-cms'
'en' => 'welcome-to-flux-cms',
],
'excerpt' => [
'de' => 'Flux CMS ist ein modernes, komponentenbasiertes Content Management System für Laravel.',
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.'
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.',
],
'content' => [
'de' => '<p>Flux CMS revolutioniert die Art, wie Sie Inhalte verwalten. Mit seiner einzigartigen "Code-as-Schema" Philosophie definieren Sie Inhaltsstrukturen direkt in PHP-Komponenten.</p><p>Dies bietet beispiellose Flexibilität und eine hervorragende Entwicklererfahrung.</p>',
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>'
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>',
],
'category' => 'News',
'tags' => ['CMS', 'Laravel', 'Flux'],
@ -118,19 +118,19 @@ class CmsContentSeeder extends Seeder
[
'title' => [
'de' => 'Multi-Domain Support',
'en' => 'Multi-Domain Support'
'en' => 'Multi-Domain Support',
],
'slug' => [
'de' => 'multi-domain-support',
'en' => 'multi-domain-support'
'en' => 'multi-domain-support',
],
'excerpt' => [
'de' => 'Verwalten Sie mehrere Websites von einer Installation aus.',
'en' => 'Manage multiple websites from one installation.'
'en' => 'Manage multiple websites from one installation.',
],
'content' => [
'de' => '<p>Mit Flux CMS können Sie mehrere Domains von einer einzigen Installation aus verwalten. Jede Domain kann ihre eigenen Inhalte, Designs und Einstellungen haben.</p>',
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>'
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>',
],
'category' => 'Features',
'tags' => ['Multi-Domain', 'Features'],
@ -141,19 +141,19 @@ class CmsContentSeeder extends Seeder
[
'title' => [
'de' => 'Komponenten-First Architektur',
'en' => 'Component-First Architecture'
'en' => 'Component-First Architecture',
],
'slug' => [
'de' => 'komponenten-first-architektur',
'en' => 'component-first-architecture'
'en' => 'component-first-architecture',
],
'excerpt' => [
'de' => 'Bauen Sie Seiten aus wiederverwendbaren Livewire-Komponenten.',
'en' => 'Build pages from reusable Livewire components.'
'en' => 'Build pages from reusable Livewire components.',
],
'content' => [
'de' => '<p>Die Komponenten-First Architektur von Flux CMS ermöglicht es Ihnen, komplexe Seiten aus kleinen, wiederverwendbaren Komponenten zu erstellen.</p><p>Jede Komponente kann ihre eigenen Felder und Validierungsregeln definieren.</p>',
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>'
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>',
],
'category' => 'Architecture',
'tags' => ['Components', 'Livewire', 'Architecture'],

View file

@ -0,0 +1,473 @@
<?php
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use Flux\Flux;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use FluxCms\Core\Services\HeroiconOutlineList;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{state, computed, on};
state([
'selectedGroup' => null,
'search' => '',
'editingId' => null,
'editLocale' => 'de',
'editValue' => '',
'editMediaId' => null,
'showJsonModal' => false,
'jsonItems' => [],
'jsonIsStringArray' => false,
'jsonEditingKey' => '',
]);
on(['media-selected' => function ($mediaId, $url, $field) {
if ($field !== 'content_image') {
return;
}
$media = CmsMedia::find($mediaId);
if ($media) {
$this->editValue = $media->filename;
$this->editMediaId = $mediaId;
}
}]);
$groups = computed(fn() => CmsContent::query()->selectRaw('`group`, count(*) as count')->groupBy('group')->orderBy('group')->pluck('count', 'group')->toArray());
$contents = computed(fn() => $this->selectedGroup ? CmsContent::forGroup($this->selectedGroup)->when($this->search, fn($q) => $q->where('key', 'like', "%{$this->search}%"))->orderBy('order')->get() : collect());
$availableIcons = computed(fn () => HeroiconOutlineList::names());
$selectGroup = function (string $group) {
$this->selectedGroup = $group;
$this->editingId = null;
};
$startEdit = function (int $id) {
$content = CmsContent::find($id);
if (!$content) {
return;
}
$this->editingId = $id;
if ($content->type === 'json') {
$value = $content->getTranslation('value', $this->editLocale);
if (!is_array($value)) {
$value = [];
}
$this->jsonEditingKey = $content->key;
if (!empty($value) && !is_array($value[0] ?? null)) {
$this->jsonIsStringArray = true;
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
}
$this->showJsonModal = true;
} else {
$this->editValue = $content->getTranslation('value', $this->editLocale) ?? '';
if (is_array($this->editValue)) {
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
if ($content->type === 'image') {
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} else {
$this->editMediaId = null;
}
}
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$content = CmsContent::find($this->editingId);
if (!$content) {
return;
}
if ($content->type === 'json') {
$value = $content->getTranslation('value', $locale);
if (!is_array($value)) {
$value = [];
}
if (!empty($value) && !is_array($value[0] ?? null)) {
$this->jsonIsStringArray = true;
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
}
} else {
$this->editValue = $content->getTranslation('value', $locale) ?? '';
if (is_array($this->editValue)) {
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
}
}
};
$addJsonItem = function () {
if ($this->jsonIsStringArray) {
$this->jsonItems[] = ['_value' => ''];
} elseif (!empty($this->jsonItems)) {
$template = array_map(fn() => '', $this->jsonItems[0]);
$this->jsonItems[] = $template;
}
};
$removeJsonItem = function (int $index) {
unset($this->jsonItems[$index]);
$this->jsonItems = array_values($this->jsonItems);
};
$saveJsonModal = function () {
$content = CmsContent::find($this->editingId);
if (!$content) {
return;
}
if ($this->jsonIsStringArray) {
$value = array_values(array_map(fn($item) => $item['_value'] ?? '', $this->jsonItems));
} else {
$value = array_values(
array_map(function ($item) {
$cleaned = [];
foreach ($item as $k => $v) {
if (str_starts_with($v, '[') || str_starts_with($v, '{')) {
$decoded = json_decode($v, true);
$cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;
} else {
$cleaned[$k] = $v;
}
}
return $cleaned;
}, $this->jsonItems),
);
}
$content->setTranslation('value', $this->editLocale, $value);
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->showJsonModal = false;
$this->editingId = null;
$this->jsonItems = [];
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'JSON-Inhalt wurde erfolgreich aktualisiert.');
};
$saveEdit = function () {
$content = CmsContent::find($this->editingId);
if (!$content) {
return;
}
$content->setTranslation('value', $this->editLocale, $this->editValue);
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->editingId = null;
$this->editValue = '';
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
$cancelEdit = fn() => ($this->editingId = null);
$cancelJsonModal = function () {
$this->showJsonModal = false;
$this->editingId = null;
$this->jsonItems = [];
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Inhalte verwalten</flux:heading>
<div class="flex items-center gap-2">
<flux:badge color="blue">{{ array_sum($this->groups) }} Einträge</flux:badge>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
{{-- Sidebar: Groups --}}
<div class="lg:col-span-1">
<flux:card>
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
<div class="flex flex-col gap-1">
@foreach ($this->groups as $group => $count)
<button wire:click="selectGroup('{{ $group }}')"
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
<span>{{ $group }}</span>
<flux:badge size="sm">{{ $count }}</flux:badge>
</button>
@endforeach
</div>
</flux:card>
</div>
{{-- Main: Content Editor --}}
<div class="lg:col-span-3">
@if ($selectedGroup)
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $selectedGroup }}</flux:heading>
<div class="flex items-center gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
icon="magnifying-glass" class="w-48" />
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">
{{ strtoupper($code) }}
</flux:button>
@endforeach
</div>
</div>
</div>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->contents as $content)
<div wire:key="content-{{ $content->id }}" class="py-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<code
class="rounded bg-zinc-100 text-zinc-400 px-1.5 py-0.5 text-xs dark:bg-zinc-700 dark:text-zinc-400">{{ $content->key }}</code>
<flux:badge size="sm"
:color="match($content->type) { 'html' => 'amber', 'image' => 'green', 'json' => 'violet', 'link' => 'rose', default => 'zinc' }">
{{ $content->type }}</flux:badge>
</div>
@if ($editingId === $content->id && $content->type === 'image')
<div class="mt-2">
<div class="flex items-start gap-4">
@if ($editValue)
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($editValue) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker
:value="$editMediaId"
field="content_image"
type="image"
profile="thumbnail"
label="Bild wählen"
:key="'content-img-' . $editingId"
/>
<p class="mt-1 text-xs text-zinc-400">{{ $editValue ?: 'Kein Bild ausgewählt' }}</p>
</div>
</div>
<div class="mt-2 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="saveEdit">
Speichern</flux:button>
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
Abbrechen</flux:button>
</div>
</div>
@elseif ($editingId === $content->id && $content->type !== 'json')
<div class="mt-2">
@if (in_array($selectedGroup, ['datenschutz', 'impressum']))
<flux:editor wire:model="editValue"
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
class="**:data-[slot=content]:min-h-[80px]!" />
@else
<flux:editor wire:model="editValue" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[60px]!" />
@endif
<div class="mt-2 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="saveEdit">
Speichern</flux:button>
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
Abbrechen</flux:button>
</div>
</div>
@else
@php
$displayValue = $content->getTranslation('value', $editLocale);
$displayStr = is_array($displayValue)
? json_encode($displayValue, JSON_UNESCAPED_UNICODE)
: (string) $displayValue;
@endphp
@if ($content->type === 'image')
<div class="flex items-center gap-3">
@if ($displayStr)
<div class="h-10 w-10 shrink-0 overflow-hidden rounded border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($displayStr) }}" class="h-full w-full object-cover" loading="lazy" />
</div>
@endif
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ $displayStr ?: 'Kein Bild' }}</span>
</div>
@elseif ($content->type === 'json')
@php
$jsonVal = $content->getTranslation('value', $editLocale);
$itemCount = is_array($jsonVal) ? count($jsonVal) : 0;
$firstItem =
is_array($jsonVal) && !empty($jsonVal) ? $jsonVal[0] : null;
$isObjects = is_array($firstItem);
@endphp
<div
class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<flux:badge size="sm" color="violet">{{ $itemCount }}
Einträge</flux:badge>
@if ($isObjects && is_array($firstItem))
<span class="text-xs text-zinc-400">Felder:
{{ implode(', ', array_keys($firstItem)) }}</span>
@endif
</div>
@elseif ($content->type === 'html')
<div
class="prose prose-sm max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200 line-clamp-2">
{!! \Illuminate\Support\Str::limit($displayStr, 200) !!}
</div>
@else
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
{{ \Illuminate\Support\Str::limit(strip_tags($displayStr), 120) }}
</p>
@endif
@endif
</div>
@if ($editingId !== $content->id)
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="startEdit({{ $content->id }})" />
@endif
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine Einträge gefunden.</flux:text>
@endforelse
</div>
</flux:card>
@else
<flux:card>
<div class="py-12 text-center">
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Seite auswählen</flux:heading>
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
</div>
</flux:card>
@endif
</div>
</div>
{{-- JSON Editor Modal --}}
<flux:modal wire:model="showJsonModal" class="w-full max-w-5xl space-y-6 overflow-y-auto max-h-[90vh]">
<div>
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
<flux:text class="mt-1">
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
({{ count($jsonItems) }} Einträge) {{ strtoupper($editLocale) }}
</flux:text>
</div>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
<div class="space-y-4">
@foreach ($jsonItems as $idx => $item)
<div wire:key="json-item-{{ $idx }}"
class="rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-500">Eintrag {{ $idx + 1 }}</span>
<flux:button size="xs" variant="ghost" icon="trash"
wire:click="removeJsonItem({{ $idx }})"
wire:confirm="Eintrag {{ $idx + 1 }} wirklich entfernen?" />
</div>
@if ($jsonIsStringArray)
<flux:input wire:model="jsonItems.{{ $idx }}._value" placeholder="Wert" />
@else
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
@foreach ($item as $field => $fieldValue)
@php
$isIcon = in_array($field, ['icon']);
$isRichText = in_array($field, [
'description',
'text',
'content',
'help',
'answer',
'quote',
]);
$isLongText = in_array($field, ['tagline']);
$isNestedJson =
is_string($fieldValue) &&
(str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
@endphp
@if ($isIcon)
<div class="md:col-span-2">
<div class="flex items-end gap-3">
<div class="flex-1">
<flux:select
wire:model="jsonItems.{{ $idx }}.{{ $field }}"
variant="listbox" searchable label="{{ ucfirst($field) }}"
placeholder="Icon auswählen...">
<flux:select.option value=""> Kein Icon
</flux:select.option>
@foreach ($this->availableIcons as $iconName)
<flux:select.option value="{{ $iconName }}">
{{ $iconName }}</flux:select.option>
@endforeach
</flux:select>
</div>
@if (!empty($fieldValue))
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
<x-dynamic-component :component="'heroicon-o-' . $fieldValue"
class="h-5 w-5 text-primary" />
</div>
@endif
</div>
</div>
@elseif ($isRichText)
<div class="md:col-span-2">
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }}" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
@elseif ($isNestedJson)
<div class="md:col-span-2">
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }} (JSON)" rows="3"
class="font-mono text-xs" />
</div>
@else
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }}" />
@endif
@endforeach
</div>
@endif
</div>
@endforeach
</div>
<div class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-700 pt-4">
<flux:button size="sm" variant="ghost" icon="plus" wire:click="addJsonItem">
Eintrag hinzufügen
</flux:button>
<div class="flex gap-2">
<flux:button variant="ghost" wire:click="cancelJsonModal">Abbrechen</flux:button>
<flux:button variant="primary" wire:click="saveJsonModal">Speichern</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,104 @@
<?php
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsDownload;
use FluxCms\Core\Models\CmsFaq;
use FluxCms\Core\Models\CmsIndustry;
use FluxCms\Core\Models\CmsLinkedinPost;
use FluxCms\Core\Models\CmsNewsItem;
use function Livewire\Volt\{computed};
$stats = computed(
fn() => [
'contents' => CmsContent::count(),
'groups' => CmsContent::distinct()->pluck('group')->count(),
'news' => CmsNewsItem::count(),
'industries' => CmsIndustry::count(),
'faqs' => CmsFaq::count(),
'linkedin' => CmsLinkedinPost::count(),
'downloads' => CmsDownload::count(),
],
);
?>
<div>
<flux:heading size="xl" class="mb-6">CMS Dashboard</flux:heading>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<a href="{{ route('cms.content.index') }}" wire:navigate>
<flux:card class="hover:border-blue-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="document-text" class="text-blue-500" />
<div>
<flux:heading size="lg">{{ $this->stats['contents'] }}</flux:heading>
<flux:text class="text-sm">Inhalte in {{ $this->stats['groups'] }} Gruppen</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.news.index') }}" wire:navigate>
<flux:card class="hover:border-green-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="newspaper" class="text-green-500" />
<div>
<flux:heading size="lg">{{ $this->stats['news'] }}</flux:heading>
<flux:text class="text-sm">News Items</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.faqs.index') }}" wire:navigate>
<flux:card class="hover:border-amber-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="question-mark-circle" class="text-amber-500" />
<div>
<flux:heading size="lg">{{ $this->stats['faqs'] }}</flux:heading>
<flux:text class="text-sm">FAQ Einträge</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.linkedin.index') }}" wire:navigate>
<flux:card class="hover:border-sky-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="chat-bubble-left-right" class="text-sky-500" />
<div>
<flux:heading size="lg">{{ $this->stats['linkedin'] }}</flux:heading>
<flux:text class="text-sm">LinkedIn Beiträge</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.industries.index') }}" wire:navigate>
<flux:card class="hover:border-violet-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="building-office" class="text-violet-500" />
<div>
<flux:heading size="lg">{{ $this->stats['industries'] }}</flux:heading>
<flux:text class="text-sm">Industries</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.downloads.index') }}" wire:navigate>
<flux:card class="hover:border-rose-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="arrow-down-tray" class="text-rose-500" />
<div>
<flux:heading size="lg">{{ $this->stats['downloads'] }}</flux:heading>
<flux:text class="text-sm">Downloads</flux:text>
</div>
</div>
</flux:card>
</a>
</div>
</div>

View file

@ -0,0 +1,408 @@
<?php
use Flux\Flux;
use FluxCms\Core\Models\CmsDownload;
use FluxCms\Core\Services\HeroiconOutlineList;
use function Livewire\Volt\{state, computed, layout, on};
layout('components.layouts.cms');
state([
'editLocale' => 'de',
'showForm' => false,
'editingId' => null,
'filterCategory' => '',
'title' => '',
'description' => '',
'category' => 'case_study',
'icon' => 'document-text',
'sub_category' => '',
'type_label' => '',
'alt' => '',
'file_path' => '',
'fileMediaId' => null,
'thumbnail' => '',
'thumbMediaId' => null,
'open_text' => '',
'download_text' => '',
'highlights' => [],
'checkpoints' => [],
]);
$downloads = computed(function () {
$query = CmsDownload::ordered();
if ($this->filterCategory) {
$query->byCategory($this->filterCategory);
}
return $query->get();
});
$availableIcons = computed(fn () => HeroiconOutlineList::names());
$create = function () {
$this->reset(['editingId', 'title', 'description', 'icon', 'sub_category', 'type_label', 'alt', 'file_path', 'fileMediaId', 'thumbnail', 'thumbMediaId', 'open_text', 'download_text', 'highlights', 'checkpoints']);
$this->category = 'case_study';
$this->icon = 'document-text';
$this->highlights = [];
$this->checkpoints = [];
$this->showForm = true;
};
$edit = function (int $id) {
$dl = CmsDownload::findOrFail($id);
$this->editingId = $id;
$this->showForm = true;
$l = $this->editLocale;
$this->title = $dl->getTranslation('title', $l) ?? '';
$this->description = $dl->getTranslation('description', $l) ?? '';
$this->category = $dl->category;
$this->icon = $dl->icon ?? 'document-text';
$this->sub_category = $dl->sub_category ?? '';
$this->type_label = $dl->getTranslation('type_label', $l) ?? '';
$this->alt = $dl->getTranslation('alt', $l) ?? '';
$this->file_path = $dl->getTranslation('file_path', $l) ?? '';
$this->thumbnail = $dl->thumbnail ?? '';
$this->open_text = $dl->getTranslation('open_text', $l) ?? '';
$this->download_text = $dl->getTranslation('download_text', $l) ?? '';
$this->highlights = is_array($dl->highlights) ? $dl->highlights : [];
$this->checkpoints = is_array($dl->checkpoints) ? $dl->checkpoints : [];
$this->thumbMediaId = $this->thumbnail ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->thumbnail)->first()?->id : null;
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
};
$save = function () {
$existing = $this->editingId ? CmsDownload::find($this->editingId) : null;
$merge = function (string $field, ?string $value) use ($existing) {
$t = $existing ? $existing->getTranslations($field) : [];
$t[$this->editLocale] = $value ?? '';
return $t;
};
$data = [
'title' => $merge('title', $this->title),
'description' => $merge('description', $this->description),
'category' => $this->category,
'icon' => $this->icon,
'sub_category' => $this->sub_category,
'type_label' => $merge('type_label', $this->type_label),
'alt' => $merge('alt', $this->alt),
'file_path' => $merge('file_path', $this->file_path),
'thumbnail' => $this->thumbnail,
'open_text' => $merge('open_text', $this->open_text),
'download_text' => $merge('download_text', $this->download_text),
'highlights' => array_values(array_filter($this->highlights, fn($h) => !empty($h['value']) || !empty($h['label']))),
'checkpoints' => array_values(array_filter($this->checkpoints, fn($c) => !empty($c['value']))),
];
if ($existing) {
$existing->update($data);
} else {
$data['order'] = CmsDownload::max('order') + 1;
$data['is_published'] = true;
CmsDownload::create($data);
}
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Download wurde erfolgreich gespeichert.');
};
$delete = function (int $id) {
CmsDownload::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Download wurde entfernt.');
};
$togglePublished = function (int $id) {
$dl = CmsDownload::findOrFail($id);
$dl->update(['is_published' => !$dl->is_published]);
Flux::toast(heading: 'Status geändert', text: $dl->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$dl = CmsDownload::find($this->editingId);
if ($dl) {
$this->title = $dl->getTranslation('title', $locale) ?? '';
$this->description = $dl->getTranslation('description', $locale) ?? '';
$this->type_label = $dl->getTranslation('type_label', $locale) ?? '';
$this->alt = $dl->getTranslation('alt', $locale) ?? '';
$this->file_path = $dl->getTranslation('file_path', $locale) ?? '';
$this->open_text = $dl->getTranslation('open_text', $locale) ?? '';
$this->download_text = $dl->getTranslation('download_text', $locale) ?? '';
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
}
}
};
$cancel = function () {
$this->showForm = false;
$this->editingId = null;
};
$addHighlight = function () {
$this->highlights[] = ['value' => '', 'label' => ''];
};
$removeHighlight = function (int $index) {
unset($this->highlights[$index]);
$this->highlights = array_values($this->highlights);
};
$addCheckpoint = function () {
$this->checkpoints[] = ['value' => ''];
};
$removeCheckpoint = function (int $index) {
unset($this->checkpoints[$index]);
$this->checkpoints = array_values($this->checkpoints);
};
$moveUp = function (int $id) {
$items = CmsDownload::ordered()->get();
$idx = $items->search(fn($i) => $i->id === $id);
if ($idx > 0) {
$prev = $items[$idx - 1];
$curr = $items[$idx];
[$prev->order, $curr->order] = [$curr->order, $prev->order];
$prev->save();
$curr->save();
}
};
$moveDown = function (int $id) {
$items = CmsDownload::ordered()->get();
$idx = $items->search(fn($i) => $i->id === $id);
if ($idx !== false && $idx < $items->count() - 1) {
$next = $items[$idx + 1];
$curr = $items[$idx];
[$next->order, $curr->order] = [$curr->order, $next->order];
$next->save();
$curr->save();
}
};
on([
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'dl_file') {
$this->fileMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->file_path = $media ? $media->filename : '';
} elseif ($field === 'dl_thumb') {
$this->thumbMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->thumbnail = $media ? $media->filename : '';
}
},
]);
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Downloads</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
{{-- Category Filter --}}
<div class="mb-4 flex flex-wrap gap-2">
<flux:button size="xs" :variant="$filterCategory === '' ? 'primary' : 'ghost'"
wire:click="$set('filterCategory', '')">Alle</flux:button>
<flux:button size="xs" :variant="$filterCategory === 'case_study' ? 'primary' : 'ghost'"
wire:click="$set('filterCategory', 'case_study')">Case Studies</flux:button>
<flux:button size="xs" :variant="$filterCategory === 'capability' ? 'primary' : 'ghost'"
wire:click="$set('filterCategory', 'capability')">Capabilities</flux:button>
<flux:button size="xs" :variant="$filterCategory === 'success_story' ? 'primary' : 'ghost'"
wire:click="$set('filterCategory', 'success_story')">Success Stories</flux:button>
</div>
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer Download' }}
</flux:heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="title" label="Titel" />
<flux:select wire:model="category" label="Kategorie">
<flux:select.option value="case_study">Case Study</flux:select.option>
<flux:select.option value="capability">Capability</flux:select.option>
<flux:select.option value="success_story">Success Story</flux:select.option>
</flux:select>
<flux:input wire:model="sub_category" label="Unterkategorie" placeholder="z.B. R&D Product Support" />
<flux:input wire:model="type_label" label="Typ-Label" placeholder="z.B. Case Study" />
<flux:input wire:model="alt" label="Alt-Text (Bild)" />
<div>
<div class="flex items-end gap-3">
<div class="flex-1">
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
placeholder="Icon auswählen...">
@foreach ($this->availableIcons as $iconName)
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
</flux:select.option>
@endforeach
</flux:select>
</div>
@if ($icon)
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
</div>
@endif
</div>
</div>
</div>
{{-- Vorschaubild + PDF --}}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Vorschaubild</label>
<div class="flex items-start gap-3">
@if ($thumbnail)
<div
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($thumbnail) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="$thumbMediaId" field="dl_thumb" type="image"
profile="thumbnail" label="Bild wählen" :key="'dl-thumb-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $thumbnail ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium">PDF-Datei ({{ strtoupper($editLocale) }})</label>
@if ($file_path)
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<iframe src="{{ media_url($file_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
loading="lazy"></iframe>
</div>
<p class="mb-1 text-xs text-zinc-400">{{ $file_path }}</p>
@endif
<livewire:admin.cms.media-picker :value="$fileMediaId" field="dl_file" type="pdf" profile=""
label="PDF wählen" :key="'dl-file-' . ($editingId ?? 'new') . '-' . $editLocale" />
</div>
</div>
{{-- Button-Texte --}}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="open_text" label="PDF öffnen Text" placeholder="PDF öffnen" />
<flux:input wire:model="download_text" label="PDF downloaden Text" placeholder="PDF downloaden" />
</div>
{{-- Beschreibung --}}
<div class="mt-4">
<flux:editor wire:model="description" label="Beschreibung" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[80px]!" />
</div>
{{-- Highlights (Case Studies / Success Stories) --}}
@if ($category === 'case_study' || $category === 'success_story')
<div class="mt-4">
<label class="mb-2 block text-sm font-medium">Highlights (Kennzahlen)</label>
<div class="space-y-2">
@foreach ($highlights as $hIdx => $highlight)
<div wire:key="hl-{{ $hIdx }}" class="flex items-center gap-2">
<flux:input wire:model="highlights.{{ $hIdx }}.value"
placeholder="Wert (z.B. 100%)" class="w-32!" />
<flux:input wire:model="highlights.{{ $hIdx }}.label" placeholder="Label"
class="flex-1!" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="removeHighlight({{ $hIdx }})" />
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight"
class="mt-2">
Highlight hinzufügen</flux:button>
</div>
@endif
{{-- Checkpoints (Capabilities) --}}
@if ($category === 'capability')
<div class="mt-4">
<label class="mb-2 block text-sm font-medium">Checkpoints</label>
<div class="space-y-2">
@foreach ($checkpoints as $cIdx => $checkpoint)
<div wire:key="cp-{{ $cIdx }}" class="flex items-center gap-2">
<flux:input wire:model="checkpoints.{{ $cIdx }}.value"
placeholder="Checkpoint-Text" class="flex-1!" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="removeCheckpoint({{ $cIdx }})" />
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addCheckpoint"
class="mt-2">Checkpoint hinzufügen</flux:button>
</div>
@endif
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->downloads as $dl)
<div wire:key="dl-{{ $dl->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($dl->thumbnail)
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($dl->thumbnail) }}" alt=""
class="h-full w-full object-cover" />
</div>
@elseif ($dl->icon)
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<x-dynamic-component :component="'heroicon-o-' . $dl->icon" class="h-6 w-6 text-primary" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium">{{ $dl->getTranslation('title', $editLocale) }}</span>
<flux:badge size="sm"
:color="$dl->category === 'case_study' ? 'blue' : ($dl->category === 'capability' ? 'green' : 'purple')">
{{ $dl->category === 'case_study' ? 'Case Study' : ($dl->category === 'capability' ? 'Capability' : 'Success Story') }}
</flux:badge>
@unless ($dl->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="truncate text-sm text-zinc-500">
{{ $dl->sub_category }}
@if ($dl->getTranslation('file_path', $editLocale))
· {{ $dl->getTranslation('file_path', $editLocale) }}
@endif
</p>
</div>
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="chevron-up"
wire:click="moveUp({{ $dl->id }})" />
<flux:button size="sm" variant="ghost" icon="chevron-down"
wire:click="moveDown({{ $dl->id }})" />
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="edit({{ $dl->id }})" />
<flux:button size="sm" variant="ghost" :icon="$dl->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $dl->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="delete({{ $dl->id }})" wire:confirm="Wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine Downloads vorhanden.</flux:text>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,202 @@
<?php
use Flux\Flux;
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use FluxCms\Core\Models\CmsFaq;
use function Livewire\Volt\{state, computed};
state([
'editLocale' => 'de',
'selectedCategory' => null,
'showForm' => false,
'editingId' => null,
'question' => '',
'answer' => '',
'help' => '',
'category' => '',
]);
$categories = computed(fn() => CmsFaq::query()->selectRaw('category, count(*) as count')->groupBy('category')->orderBy('category')->pluck('count', 'category')->toArray());
$faqs = computed(fn() => $this->selectedCategory ? CmsFaq::byCategory($this->selectedCategory)->ordered()->get() : collect());
$selectCategory = function (string $cat) {
$this->selectedCategory = $cat;
$this->showForm = false;
$this->editingId = null;
};
$create = function () {
$this->resetForm();
$this->category = $this->selectedCategory ?? '';
$this->showForm = true;
};
$edit = function (int $id) {
$faq = CmsFaq::findOrFail($id);
$this->editingId = $id;
$this->showForm = true;
$l = $this->editLocale;
$this->question = $faq->getTranslation('question', $l) ?? '';
$this->answer = $faq->getTranslation('answer', $l) ?? '';
$this->help = $faq->getTranslation('help', $l) ?? '';
$this->category = $faq->category;
};
$save = function () {
$existing = $this->editingId ? CmsFaq::find($this->editingId) : null;
$data = [
'category' => $this->category,
'question' => $this->mergeTranslation($existing, 'question', $this->question),
'answer' => $this->mergeTranslation($existing, 'answer', $this->answer),
'help' => $this->help ? $this->mergeTranslation($existing, 'help', $this->help) : null,
];
if ($existing) {
$existing->update($data);
} else {
$data['order'] = CmsFaq::where('category', $this->category)->max('order') + 1;
$data['is_published'] = true;
CmsFaq::create($data);
}
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'FAQ wurde erfolgreich gespeichert.');
};
$delete = function (int $id) {
CmsFaq::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'FAQ wurde entfernt.');
};
$togglePublished = function (int $id) {
$faq = CmsFaq::findOrFail($id);
$faq->update(['is_published' => !$faq->is_published]);
Flux::toast(heading: 'Status geändert', text: $faq->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$faq = CmsFaq::find($this->editingId);
if ($faq) {
$this->question = $faq->getTranslation('question', $locale) ?? '';
$this->answer = $faq->getTranslation('answer', $locale) ?? '';
$this->help = $faq->getTranslation('help', $locale) ?? '';
}
}
};
$cancel = function () {
$this->showForm = false;
$this->editingId = null;
};
$resetForm = function () {
$this->editingId = null;
$this->question = '';
$this->answer = '';
$this->help = '';
};
$mergeTranslation = function (?CmsFaq $model, string $field, string $value): array {
$existing = $model ? $model->getTranslations($field) : [];
$existing[$this->editLocale] = $value;
return $existing;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">FAQs</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div class="lg:col-span-1">
<flux:card>
<flux:heading size="sm" class="mb-3">Kategorien</flux:heading>
<div class="flex flex-col gap-1">
@foreach ($this->categories as $cat => $count)
<button wire:click="selectCategory('{{ $cat }}')"
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedCategory === $cat ? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
<span>{{ $cat }}</span>
<flux:badge size="sm">{{ $count }}</flux:badge>
</button>
@endforeach
</div>
</flux:card>
</div>
<div class="lg:col-span-3">
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'FAQ bearbeiten' : 'Neue FAQ' }}
</flux:heading>
<div class="space-y-4">
<flux:input wire:model="category" label="Kategorie" />
<flux:input wire:model="question" label="Frage" />
<flux:editor wire:model="answer" label="Antwort" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[80px]!" />
<flux:editor wire:model="help" label="Hilfe-Text (optional)" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[80px]!" />
<div class="flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</div>
</flux:card>
@endif
@if ($selectedCategory)
<flux:card>
<flux:heading size="lg" class="mb-4">{{ $selectedCategory }}</flux:heading>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->faqs as $faq)
<div wire:key="faq-{{ $faq->id }}"
class="flex items-center justify-between gap-4 py-3">
<div class="min-w-0 flex-1">
<p class="font-medium">{{ $faq->getTranslation('question', $editLocale) }}</p>
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
{{ \Illuminate\Support\Str::limit(strip_tags($faq->getTranslation('answer', $editLocale) ?? ''), 100) }}
</p>
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="edit({{ $faq->id }})" />
<flux:button size="sm" variant="ghost"
:icon="$faq->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $faq->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="delete({{ $faq->id }})" wire:confirm="Wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine FAQs in dieser Kategorie.</flux:text>
@endforelse
</div>
</flux:card>
@else
<flux:card>
<div class="py-12 text-center">
<flux:icon name="question-mark-circle" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Kategorie auswählen</flux:heading>
<flux:text>Wähle links eine Kategorie, um FAQs zu bearbeiten.</flux:text>
</div>
</flux:card>
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,176 @@
<?php
use Flux\Flux;
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use FluxCms\Core\Models\CmsIndustry;
use function Livewire\Volt\{state, computed};
state([
'editLocale' => 'de',
'showForm' => false,
'editingId' => null,
'name' => '',
'order' => 0,
]);
$industries = computed(fn () => CmsIndustry::ordered()->get());
$create = function () {
$this->editingId = null;
$this->name = '';
$this->order = CmsIndustry::max('order') + 1;
$this->showForm = true;
};
$edit = function (int $id) {
$item = CmsIndustry::findOrFail($id);
$this->editingId = $id;
$this->name = $item->getTranslation('name', $this->editLocale) ?? '';
$this->order = $item->order;
$this->showForm = true;
};
$save = function () {
$existing = $this->editingId ? CmsIndustry::find($this->editingId) : null;
$translations = $existing ? $existing->getTranslations('name') : [];
$translations[$this->editLocale] = $this->name;
if ($existing) {
$existing->update(['name' => $translations, 'order' => (int) $this->order]);
} else {
CmsIndustry::create([
'name' => $translations,
'order' => (int) $this->order,
'is_published' => true,
]);
}
$this->showForm = false;
$this->editingId = null;
$this->name = '';
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Industrie wurde erfolgreich gespeichert.');
};
$delete = function (int $id) {
CmsIndustry::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Industrie wurde entfernt.');
};
$togglePublished = function (int $id) {
$item = CmsIndustry::findOrFail($id);
$item->update(['is_published' => !$item->is_published]);
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$item = CmsIndustry::find($this->editingId);
$this->name = $item?->getTranslation('name', $locale) ?? '';
}
};
$moveUp = function (int $id) {
$items = CmsIndustry::ordered()->get();
$index = $items->search(fn($i) => $i->id === $id);
if ($index === false || $index === 0) {
return;
}
$prev = $items[$index - 1];
$current = $items[$index];
$tmpOrder = $prev->order;
$prev->update(['order' => $current->order]);
$current->update(['order' => $tmpOrder]);
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
};
$moveDown = function (int $id) {
$items = CmsIndustry::ordered()->get();
$index = $items->search(fn($i) => $i->id === $id);
if ($index === false || $index >= $items->count() - 1) {
return;
}
$next = $items[$index + 1];
$current = $items[$index];
$tmpOrder = $next->order;
$next->update(['order' => $current->order]);
$current->update(['order' => $tmpOrder]);
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
};
$cancel = function () {
$this->showForm = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Industries Band</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'" wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neue Industry' }}</flux:heading>
<div class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="md:col-span-3">
<flux:input wire:model="name" label="Name" />
</div>
<flux:input wire:model="order" label="Reihenfolge" type="number" min="0" />
</div>
<div class="flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->industries as $item)
<div wire:key="industry-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm text-zinc-400">{{ $item->order }}</span>
<span class="font-medium">{{ $item->getTranslation('name', $editLocale) }}</span>
@unless ($item->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="chevron-up"
wire:click="moveUp({{ $item->id }})"
:disabled="$loop->first" />
<flux:button size="sm" variant="ghost" icon="chevron-down"
wire:click="moveDown({{ $item->id }})"
:disabled="$loop->last" />
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="edit({{ $item->id }})" />
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'" wire:click="togglePublished({{ $item->id }})" />
<flux:button size="sm" variant="ghost" icon="trash" wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine Industries vorhanden.</flux:text>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,226 @@
<?php
use Flux\Flux;
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use FluxCms\Core\Models\CmsLinkedinPost;
use function Livewire\Volt\{state, computed, on};
state([
'editLocale' => 'de',
'showForm' => false,
'editingId' => null,
'title' => '',
'excerpt' => '',
'content' => '',
'author' => '',
'date' => null,
'url' => '',
'image' => '',
'imageMediaId' => null,
'tags' => '',
'source' => 'manual',
]);
$posts = computed(fn() => CmsLinkedinPost::ordered()->get());
$create = function () {
$this->reset(['editingId', 'title', 'excerpt', 'content', 'author', 'date', 'url', 'image', 'imageMediaId', 'tags', 'source']);
$this->source = 'manual';
$this->showForm = true;
};
$edit = function (int $id) {
$post = CmsLinkedinPost::findOrFail($id);
$this->editingId = $id;
$this->showForm = true;
$l = $this->editLocale;
$this->title = $post->getTranslation('title', $l) ?? '';
$this->excerpt = $post->getTranslation('excerpt', $l) ?? '';
$this->content = $post->getTranslation('content', $l) ?? '';
$this->author = $post->author ?? '';
$this->date = $post->date?->format('Y-m-d');
$this->url = $post->url ?? '';
$this->image = $post->image ?? '';
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
$this->tags = is_array($post->tags) ? implode(', ', $post->tags) : '';
$this->source = $post->source;
};
$save = function () {
$existing = $this->editingId ? CmsLinkedinPost::find($this->editingId) : null;
$mergeT = function (string $field, string $value) use ($existing) {
$t = $existing ? $existing->getTranslations($field) : [];
$t[$this->editLocale] = $value;
return $t;
};
$tagsArray = array_map('trim', explode(',', $this->tags));
$tagsArray = array_filter($tagsArray);
$data = [
'title' => $mergeT('title', $this->title),
'excerpt' => $mergeT('excerpt', $this->excerpt),
'content' => $mergeT('content', $this->content),
'author' => $this->author,
'date' => $this->date ?: null,
'url' => $this->url,
'image' => $this->image,
'tags' => array_values($tagsArray),
'source' => $this->source,
];
if ($existing) {
$existing->update($data);
} else {
$data['order'] = CmsLinkedinPost::max('order') + 1;
$data['is_published'] = true;
CmsLinkedinPost::create($data);
}
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'LinkedIn-Post wurde erfolgreich gespeichert.');
};
$delete = function (int $id) {
CmsLinkedinPost::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'LinkedIn-Post wurde entfernt.');
};
$togglePublished = function (int $id) {
$post = CmsLinkedinPost::findOrFail($id);
$post->update(['is_published' => !$post->is_published]);
Flux::toast(heading: 'Status geändert', text: $post->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$post = CmsLinkedinPost::find($this->editingId);
if ($post) {
$this->title = $post->getTranslation('title', $locale) ?? '';
$this->excerpt = $post->getTranslation('excerpt', $locale) ?? '';
$this->content = $post->getTranslation('content', $locale) ?? '';
}
}
};
$cancel = function () {
$this->showForm = false;
$this->editingId = null;
};
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'linkedin_image') {
$this->imageMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->image = $media ? $media->filename : '';
}
}]);
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">LinkedIn Beiträge</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer LinkedIn Beitrag' }}
</flux:heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="title" label="Titel" />
<flux:input wire:model="author" label="Autor" />
<flux:input wire:model="date" label="Datum" type="date" />
<flux:input wire:model="url" label="LinkedIn URL" />
<div>
<label class="mb-1 block text-sm font-medium">Bild</label>
<div class="flex items-start gap-3">
@if ($image)
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker
:value="$imageMediaId"
field="linkedin_image"
type="image"
profile="news"
label="Bild wählen"
:key="'linkedin-img-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
<flux:input wire:model="tags" label="Tags (kommagetrennt)" />
<flux:select wire:model="source" label="Quelle">
<flux:select.option value="manual">Manuell</flux:select.option>
<flux:select.option value="api">API</flux:select.option>
</flux:select>
</div>
<div class="mt-4">
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
<div class="mt-4">
<flux:editor wire:model="content" label="Inhalt"
toolbar="bold italic"
class="**:data-[slot=content]:min-h-[120px]!" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->posts as $post)
<div wire:key="linkedin-{{ $post->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($post->image)
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($post->image) }}" alt="" class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium">{{ $post->getTranslation('title', $editLocale) }}</span>
<flux:badge size="sm" :color="$post->source === 'api' ? 'blue' : 'zinc'">
{{ $post->source }}</flux:badge>
@unless ($post->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ $post->author }} ·
{{ $post->date?->format('d.m.Y') }}</p>
</div></div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="edit({{ $post->id }})" />
<flux:button size="sm" variant="ghost" :icon="$post->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $post->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="delete({{ $post->id }})" wire:confirm="Wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine LinkedIn Beiträge vorhanden.</flux:text>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,475 @@
<?php
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Support\Facades\Storage;
use function Livewire\Volt\{state, computed, on};
state([
'search' => '',
'filterType' => 'all',
'filterCollection' => '',
'viewMode' => 'grid',
'editingId' => null,
'editLocale' => 'de',
'altText' => '',
'mediaTitle' => '',
'collection' => '',
'showDetail' => false,
'selectedProfiles' => [],
]);
$media = computed(
fn() => CmsMedia::query()
->when(
$this->filterType !== 'all',
fn($q) => match ($this->filterType) {
'image' => $q->images(),
'pdf' => $q->pdfs(),
'document' => $q->documents(),
default => $q,
},
)
->when($this->filterCollection, fn($q) => $q->inCollection($this->filterCollection))
->when($this->search, fn($q) => $q->where('filename', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(48),
);
$collections = computed(fn() => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray());
$profiles = computed(fn() => config('flux-cms.media.profiles', []));
$stats = computed(
fn() => [
'total' => CmsMedia::count(),
'images' => CmsMedia::images()->count(),
'pdfs' => CmsMedia::pdfs()->count(),
],
);
on([
'media-library-uploaded' => function ($mediaId) {
$media = CmsMedia::find($mediaId);
if ($media) {
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.');
}
},
]);
$startEdit = function (int $id) {
$media = CmsMedia::find($id);
if (!$media) {
return;
}
$this->editingId = $id;
$this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? '';
$this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? '';
$this->collection = $media->collection ?? '';
$this->showDetail = true;
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$media = CmsMedia::find($this->editingId);
if ($media) {
$this->altText = $media->getTranslation('alt_text', $locale) ?? '';
$this->mediaTitle = $media->getTranslation('title', $locale) ?? '';
}
}
};
$saveEdit = function () {
$media = CmsMedia::find($this->editingId);
if (!$media) {
return;
}
$media->setTranslation('alt_text', $this->editLocale, $this->altText);
$media->setTranslation('title', $this->editLocale, $this->mediaTitle);
$media->collection = $this->collection;
$media->save();
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
};
$generateConversion = function (string $profile) {
$media = CmsMedia::find($this->editingId);
if (!$media || !$media->isImage()) {
return;
}
$service = app(MediaConversionService::class);
$result = $service->convert($media, $profile);
if ($result) {
$media->refresh();
Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert.");
} else {
Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden.");
}
};
$generateAllConversions = function () {
$media = CmsMedia::find($this->editingId);
if (!$media || !$media->isImage()) {
return;
}
$service = app(MediaConversionService::class);
$results = $service->generateAllConversions($media);
$count = count(array_filter($results));
$media->refresh();
Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert.");
};
$deleteMedia = function (int $id) {
$media = CmsMedia::find($id);
if (!$media) {
return;
}
$filename = $media->filename;
$service = app(MediaConversionService::class);
$service->deleteAll($media);
$media->delete();
$this->editingId = null;
$this->showDetail = false;
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt.");
};
$closeDetail = function () {
$this->showDetail = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Medienbibliothek</flux:heading>
<div class="flex items-center gap-3">
<flux:badge color="blue">{{ $this->stats['images'] }} Bilder</flux:badge>
<flux:badge color="amber">{{ $this->stats['pdfs'] }} PDFs</flux:badge>
</div>
</div>
{{-- Upload Area --}}
<flux:card class="mb-6">
<div class="flex items-center gap-4">
<div class="flex-1">
<livewire:admin.cms.media-library-uploader key="media-lib-uploader" />
</div>
</div>
</flux:card>
{{-- Filters --}}
<div class="mb-4 flex flex-wrap items-center gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Dateiname suchen..." icon="magnifying-glass"
size="sm" class="w-56" />
<flux:select wire:model.live="filterType" size="sm" class="w-36">
<flux:select.option value="all">Alle Typen</flux:select.option>
<flux:select.option value="image">Bilder</flux:select.option>
<flux:select.option value="pdf">PDFs</flux:select.option>
<flux:select.option value="document">Dokumente</flux:select.option>
</flux:select>
@if (!empty($this->collections))
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
<flux:select.option value="">Alle Ordner</flux:select.option>
@foreach ($this->collections as $col)
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
@endforeach
</flux:select>
@endif
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
<button wire:click="$set('viewMode', 'grid')"
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
</button>
<button wire:click="$set('viewMode', 'list')"
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
<x-heroicon-s-list-bullet class="h-4 w-4" />
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
{{-- Media Grid / List --}}
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
@if ($viewMode === 'grid')
<div
class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
@forelse ($this->media as $item)
<div wire:key="media-g-{{ $item->id }}"
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
wire:click="startEdit({{ $item->id }})">
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($item->isImage())
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
alt="{{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isPdf())
<div class="relative h-full w-full">
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
class="pointer-events-none h-full w-full scale-100 bg-white"
loading="lazy"></iframe>
<div class="absolute inset-0"></div>
</div>
@else
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
<x-heroicon-o-document class="h-10 w-10" />
<span
class="text-xs">{{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }}</span>
</div>
@endif
</div>
<div class="flex items-center gap-1.5 p-2">
@if ($item->isImage())
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-blue-500" />
@elseif ($item->isPdf())
<x-heroicon-s-document-text class="h-3.5 w-3.5 shrink-0 text-red-500" />
@else
<x-heroicon-s-document class="h-3.5 w-3.5 shrink-0 text-zinc-400" />
@endif
<div class="min-w-0">
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">
{{ $item->filename }}</p>
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
</div>
</div>
@if ($item->collection)
<div class="absolute right-1 top-1">
<flux:badge size="sm" color="blue" class="text-[10px]!">
{{ $item->collection }}</flux:badge>
</div>
@endif
</div>
@empty
<div class="col-span-full py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
<flux:text>Noch keine Medien hochgeladen.</flux:text>
</div>
@endforelse
</div>
@else
{{-- List View --}}
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="w-full text-sm">
<thead
class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
<tr>
<th class="w-12 px-3 py-2"></th>
<th class="px-3 py-2">Dateiname</th>
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
<th class="hidden px-3 py-2 md:table-cell">Abmessungen</th>
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
<th class="px-3 py-2 text-right">Datum</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($this->media as $item)
<tr wire:key="media-l-{{ $item->id }}"
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
wire:click="startEdit({{ $item->id }})">
<td class="px-3 py-1.5">
<div
class="h-8 w-8 overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
@if ($item->isImage())
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isPdf())
<div class="relative h-full w-full">
<iframe
src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
class="pointer-events-none h-full w-full bg-white"
loading="lazy"></iframe>
</div>
@else
<div
class="flex h-full w-full items-center justify-center text-zinc-400">
<x-heroicon-s-document class="h-4 w-4" />
</div>
@endif
</div>
</td>
<td class="px-3 py-1.5">
<span
class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</span>
</td>
<td class="hidden px-3 py-1.5 text-zinc-500 sm:table-cell">
<flux:badge size="sm"
:color="$item->isImage() ? 'blue' : ($item->isPdf() ? 'amber' : 'zinc')">
{{ $item->type }}
</flux:badge>
</td>
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
{{ $item->getHumanFileSize() }}</td>
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
{{ $item->getDimensionsLabel() ?: '—' }}</td>
<td class="hidden px-3 py-1.5 lg:table-cell">
@if ($item->collection)
<flux:badge size="sm" color="blue">{{ $item->collection }}
</flux:badge>
@else
<span class="text-zinc-300"></span>
@endif
</td>
<td class="px-3 py-1.5 text-right text-zinc-400">
{{ $item->created_at->format('d.m.Y') }}</td>
</tr>
@empty
<tr>
<td colspan="7" class="py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
<flux:text>Noch keine Medien hochgeladen.</flux:text>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($this->media->hasPages())
<div class="mt-4">
{{ $this->media->links() }}
</div>
@endif
</div>
{{-- Detail Sidebar --}}
@if ($showDetail && $editingId)
@php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp
@if ($editMedia)
<div class="lg:col-span-1">
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="sm">Details</flux:heading>
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
</div>
{{-- Large Preview --}}
<div
class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
@if ($editMedia->isImage())
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
class="w-full object-contain" style="max-height: 300px;" />
@elseif ($editMedia->isPdf())
<iframe src="{{ $editMedia->getUrl() }}#toolbar=0&navpanes=0"
class="h-64 w-full bg-white" loading="lazy"></iframe>
@endif
</div>
{{-- File Info --}}
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
<p><strong>Typ:</strong> {{ $editMedia->mime_type }}</p>
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
@if ($editMedia->getDimensionsLabel())
<p><strong>Abmessungen:</strong> {{ $editMedia->getDimensionsLabel() }} px</p>
@endif
<p><strong>Hochgeladen:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
<p class="break-all"><strong>URL:</strong>
<a href="{{ $editMedia->getUrl() }}" target="_blank"
class="text-blue-500 hover:underline">
{{ $editMedia->getUrl() }}
</a>
</p>
</div>
{{-- Locale Switcher --}}
<div class="mb-3 flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">
{{ strtoupper($code) }}
</flux:button>
@endforeach
</div>
{{-- Edit Fields --}}
<div class="space-y-3">
<flux:input wire:model="mediaTitle" label="Titel" size="sm"
placeholder="Anzeigename..." />
<flux:input wire:model="altText" label="Alt-Text" size="sm"
placeholder="Bildbeschreibung für SEO..." />
<flux:input wire:model="collection" label="Ordner / Sammlung" size="sm"
placeholder="z.B. hero, team, news..." />
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
Speichern
</flux:button>
</div>
{{-- Conversions --}}
@if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml')
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<div class="mb-3 flex items-center justify-between">
<flux:heading size="sm">Bildgrößen</flux:heading>
<flux:button size="xs" variant="ghost" wire:click="generateAllConversions"
wire:loading.attr="disabled">
Alle generieren
</flux:button>
</div>
<div class="space-y-2">
@foreach ($this->profiles as $profileName => $profileConfig)
@php
$hasConversion = $editMedia->hasConversion($profileName);
$conversionUrl = $hasConversion
? $editMedia->getConversionUrl($profileName)
: null;
@endphp
<div
class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<div>
<span class="text-sm font-medium">{{ $profileName }}</span>
<span class="text-xs text-zinc-400">
{{ $profileConfig['width'] }}×{{ $profileConfig['height'] }}
{{ strtoupper($profileConfig['format'] ?? 'webp') }}
</span>
</div>
<div class="flex items-center gap-2">
@if ($hasConversion)
<flux:badge size="sm" color="green">OK</flux:badge>
@else
<flux:badge size="sm" color="zinc"></flux:badge>
@endif
<flux:button size="xs" variant="ghost" icon="arrow-path"
wire:click="generateConversion('{{ $profileName }}')"
wire:loading.attr="disabled" />
</div>
</div>
@endforeach
</div>
</div>
@endif
{{-- Delete --}}
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
wire:click="deleteMedia({{ $editMedia->id }})"
wire:confirm="'{{ $editMedia->filename }}' wirklich löschen? Alle Conversions werden ebenfalls entfernt.">
Datei löschen
</flux:button>
</div>
</flux:card>
</div>
@endif
@endif
</div>
</div>

View file

@ -0,0 +1,30 @@
<div>
<flux:file-upload wire:model="uploads" multiple
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
with-progress />
</flux:file-upload>
@if (isset($uploads) && count($uploads) > 0)
<div class="mt-3 flex flex-wrap items-center gap-3">
@foreach ($uploads as $index => $upload)
<flux:file-item
:heading="$upload->getClientOriginalName()"
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
? $upload->temporaryUrl()
: null"
:size="$upload->getSize()">
<x-slot name="actions">
<flux:file-item.remove
wire:click="removeUpload({{ $index }})"
aria-label="Entfernen: {{ $upload->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endforeach
</div>
@endif
<flux:error name="uploads" />
</div>

View file

@ -0,0 +1,128 @@
<div>
{{-- Current Selection Preview --}}
<div class="flex items-end gap-3">
<div class="flex-1">
@if ($this->selected)
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
@if ($this->selected->isImage())
<img src="{{ $this->selected->hasConversion('thumb') ? $this->selected->getConversionUrl('thumb') : $this->selected->getUrl() }}"
alt="{{ $this->selected->filename }}"
class="h-16 w-16 rounded-md object-cover" />
@elseif ($this->selected->isPdf())
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-red-50 dark:bg-red-900/20">
<x-heroicon-o-document-text class="h-8 w-8 text-red-500" />
</div>
@endif
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
{{ $this->selected->filename }}
</p>
<p class="text-xs text-zinc-400">
{{ $this->selected->getHumanFileSize() }}
@if ($this->selected->getDimensionsLabel())
{{ $this->selected->getDimensionsLabel() }}
@endif
</p>
@if ($this->selected->isImage() && $this->selected->hasConversion($profile))
@php
$pConfig = config("flux-cms.media.profiles.{$profile}", []);
@endphp
<flux:badge size="sm" color="green" class="mt-1">
{{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }}
</flux:badge>
@endif
</div>
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="clearSelection" />
</div>
@else
<div class="rounded-lg border-2 border-dashed border-zinc-300 px-4 py-3 text-center text-sm text-zinc-400 dark:border-zinc-600">
Kein Medium ausgewählt
</div>
@endif
</div>
<flux:button size="sm" variant="ghost" icon="photo" wire:click="openPicker">
{{ $label }}
</flux:button>
</div>
{{-- Picker Modal --}}
<flux:modal wire:model="showModal" class="w-full max-w-4xl space-y-4 overflow-y-auto max-h-[85vh]">
<flux:heading size="lg">{{ $label }}</flux:heading>
{{-- Quick Upload + Search --}}
<div class="flex flex-col gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass"
size="sm" />
<flux:file-upload wire:model="quickUploads" multiple
accept="{{ $type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp,.jpg,.jpeg,.png' : ($type === 'pdf' ? '.pdf,application/pdf' : '*') }}">
<flux:file-upload.dropzone
heading="Neue Datei hochladen"
text="Direkt hier hochladen und zuweisen"
with-progress />
</flux:file-upload>
@if (isset($quickUploads) && count($quickUploads) > 0)
<div class="flex flex-wrap items-center gap-2">
@foreach ($quickUploads as $index => $upload)
<flux:file-item
:heading="$upload->getClientOriginalName()"
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
? $upload->temporaryUrl()
: null"
:size="$upload->getSize()">
<x-slot name="actions">
<flux:file-item.remove wire:click="removeQuickUpload({{ $index }})" />
</x-slot>
</flux:file-item>
@endforeach
</div>
@endif
<flux:error name="quickUploads" />
</div>
@php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp
@if (!empty($profileConfig))
<flux:text class="text-xs">
Profil <strong>{{ $profile }}</strong>: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px,
{{ strtoupper($profileConfig['format'] ?? 'webp') }},
Qualität {{ $profileConfig['quality'] ?? 85 }}%
</flux:text>
@endif
{{-- Media Grid --}}
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
@forelse ($this->mediaItems as $item)
<div wire:key="pick-{{ $item->id }}"
class="group cursor-pointer overflow-hidden rounded-lg border transition-all
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
wire:click="selectMedia({{ $item->id }})">
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($item->isImage())
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isPdf())
<div class="flex h-full w-full items-center justify-center text-red-500">
<x-heroicon-o-document-text class="h-8 w-8" />
</div>
@endif
</div>
<div class="p-1.5">
<p class="truncate text-[11px] text-zinc-600 dark:text-zinc-400">{{ $item->filename }}</p>
</div>
</div>
@empty
<div class="col-span-full py-8 text-center">
<flux:text>Keine Medien gefunden.</flux:text>
</div>
@endforelse
</div>
@if ($this->mediaItems->hasPages())
<div class="mt-2">
{{ $this->mediaItems->links() }}
</div>
@endif
</flux:modal>
</div>

View file

@ -0,0 +1,8 @@
<div class="inline-flex items-center gap-2">
<input type="file" wire:model="file" accept="{{ $accept }}"
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
<div wire:loading wire:target="file">
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
</div>
</div>

View file

@ -0,0 +1,271 @@
<?php
use Flux\Flux;
use FluxCms\Core\Models\CmsNewsItem;
use FluxCms\Core\Services\HeroiconOutlineList;
use function Livewire\Volt\{state, computed, layout, on};
layout('components.layouts.cms');
state([
'editLocale' => 'de',
'showForm' => false,
'editingId' => null,
'icon' => '',
'text' => '',
'title' => '',
'excerpt' => '',
'content' => '',
'image' => '',
'imageMediaId' => null,
'pdfMediaId' => null,
'date' => null,
'author' => '',
'link' => '',
'pdf_path' => '',
'pdf_open_text' => '',
'pdf_download_text' => '',
]);
$items = computed(fn() => CmsNewsItem::ordered()->get());
$availableIcons = computed(fn () => HeroiconOutlineList::names());
$create = function () {
$this->reset(['editingId', 'icon', 'text', 'title', 'excerpt', 'content', 'image', 'imageMediaId', 'pdfMediaId', 'date', 'author', 'link', 'pdf_path', 'pdf_open_text', 'pdf_download_text']);
$this->showForm = true;
};
$edit = function (int $id) {
$item = CmsNewsItem::findOrFail($id);
$this->editingId = $id;
$this->showForm = true;
$l = $this->editLocale;
$this->icon = $item->icon ?? '';
$this->text = $item->getTranslation('text', $l) ?? '';
$this->title = $item->getTranslation('title', $l) ?? '';
$this->excerpt = $item->getTranslation('excerpt', $l) ?? '';
$this->content = $item->getTranslation('content', $l) ?? '';
$this->image = $item->image ?? '';
$this->date = $item->date?->format('Y-m-d');
$this->author = $item->author ?? '';
$this->link = $item->link ?? '';
$this->pdf_path = $item->pdf_path ?? '';
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $l) ?? '';
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $l) ?? '';
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
$this->pdfMediaId = $this->pdf_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->pdf_path)->first()?->id : null;
};
$save = function () {
$existing = $this->editingId ? CmsNewsItem::find($this->editingId) : null;
$merge = function (string $field, string $value) use ($existing) {
$t = $existing ? $existing->getTranslations($field) : [];
$t[$this->editLocale] = $value;
return $t;
};
$data = [
'icon' => $this->icon,
'text' => $merge('text', $this->text),
'title' => $merge('title', $this->title),
'excerpt' => $merge('excerpt', $this->excerpt),
'content' => $merge('content', $this->content),
'image' => $this->image,
'date' => $this->date ?: null,
'author' => $this->author,
'link' => $this->link,
'pdf_path' => $this->pdf_path,
'pdf_open_text' => $merge('pdf_open_text', $this->pdf_open_text),
'pdf_download_text' => $merge('pdf_download_text', $this->pdf_download_text),
];
if ($existing) {
$existing->update($data);
} else {
$data['order'] = CmsNewsItem::max('order') + 1;
$data['is_published'] = true;
CmsNewsItem::create($data);
}
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'News-Eintrag wurde erfolgreich gespeichert.');
};
$delete = function (int $id) {
CmsNewsItem::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'News-Eintrag wurde entfernt.');
};
$togglePublished = function (int $id) {
$item = CmsNewsItem::findOrFail($id);
$item->update(['is_published' => !$item->is_published]);
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$item = CmsNewsItem::find($this->editingId);
if ($item) {
$this->text = $item->getTranslation('text', $locale) ?? '';
$this->title = $item->getTranslation('title', $locale) ?? '';
$this->excerpt = $item->getTranslation('excerpt', $locale) ?? '';
$this->content = $item->getTranslation('content', $locale) ?? '';
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $locale) ?? '';
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $locale) ?? '';
}
}
};
$cancel = function () {
$this->showForm = false;
$this->editingId = null;
};
on([
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'news_image') {
$this->imageMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->image = $media ? $media->filename : '';
} elseif ($field === 'news_pdf') {
$this->pdfMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->pdf_path = $media ? $media->filename : '';
}
},
]);
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">News Band</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer News-Eintrag' }}
</flux:heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="title" label="Titel" />
<flux:input wire:model="text" label="Band-Text (kurz)" />
<div>
<div class="flex items-end gap-3">
<div class="flex-1">
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
placeholder="Icon auswählen...">
@foreach ($this->availableIcons as $iconName)
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
</flux:select.option>
@endforeach
</flux:select>
</div>
@if ($icon)
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
</div>
@endif
</div>
</div>
<flux:input wire:model="author" label="Autor" />
<flux:input wire:model="date" label="Datum" type="date" />
<flux:input wire:model="link" label="Link (optional)" />
<div>
<label class="mb-1 block text-sm font-medium">Bild</label>
<div class="flex items-start gap-3">
@if ($image)
<div
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="$imageMediaId" field="news_image" type="image"
profile="news" label="Bild wählen" :key="'news-img-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium">PDF-Dokument</label>
@if ($pdf_path)
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<iframe src="{{ media_url($pdf_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
loading="lazy"></iframe>
</div>
<p class="mb-1 text-xs text-zinc-400">{{ $pdf_path }}</p>
@endif
<livewire:admin.cms.media-picker :value="$pdfMediaId" field="news_pdf" type="pdf" profile=""
label="PDF wählen" :key="'news-pdf-' . ($editingId ?? 'new')" />
</div>
<flux:input wire:model="pdf_open_text" label="PDF öffnen Text" />
<flux:input wire:model="pdf_download_text" label="PDF Download Text" />
</div>
<div class="mt-4">
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
<div class="mt-4">
<flux:editor wire:model="content" label="Inhalt"
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
class="**:data-[slot=content]:min-h-[120px]!" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->items as $item)
<div wire:key="news-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($item->image)
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($item->image) }}" alt=""
class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
@if ($item->icon)
<x-dynamic-component :component="'heroicon-o-' . $item->icon" class="h-5 w-5 shrink-0 text-primary" />
@endif
<span class="font-medium">{{ $item->getTranslation('title', $editLocale) }}</span>
@unless ($item->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
{{ $item->getTranslation('text', $editLocale) }}</p>
</div>
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="edit({{ $item->id }})" />
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $item->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine News-Einträge vorhanden.</flux:text>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,452 @@
<?php
use function Livewire\Volt\{layout};
layout('components.layouts.cms');
use Flux\Flux;
use FluxCms\Core\Models\CmsSearchIndex;
use function Livewire\Volt\{state, computed, on};
state([
'search' => '',
'editingId' => null,
'editLocale' => 'de',
'itemId' => '',
'route' => '',
'routeParams' => '',
'category' => '',
'titleKey' => '',
'titleFallback' => '',
'descriptionKey' => '',
'descriptionFallbackKey' => '',
'descriptionFallbackText' => '',
'keywords' => [],
'newKeyword' => '',
'isPublished' => true,
'reindexing' => false,
]);
$items = computed(
fn () => CmsSearchIndex::query()
->when($this->search, fn ($q) => $q->where('item_id', 'like', "%{$this->search}%")
->orWhere('route', 'like', "%{$this->search}%")
->orWhere('category', 'like', "%{$this->search}%"))
->ordered()
->get()
);
$startEdit = function (int $id) {
$item = CmsSearchIndex::find($id);
if (! $item) {
return;
}
$this->editingId = $id;
$this->itemId = $item->item_id;
$this->route = $item->route;
$this->routeParams = implode(', ', $item->route_params ?? []);
$this->category = $item->getTranslation('category', $this->editLocale, false) ?? '';
$this->titleKey = $item->title_key ?? '';
$this->titleFallback = $item->getTranslation('title_fallback', $this->editLocale, false) ?? '';
$this->descriptionKey = $item->description_key ?? '';
$this->descriptionFallbackKey = $item->description_fallback_key ?? '';
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $this->editLocale, false) ?? '';
$this->keywords = $item->getTranslation('keywords', $this->editLocale, false) ?? [];
if (! is_array($this->keywords)) {
$this->keywords = [];
}
$this->isPublished = $item->is_published;
$this->newKeyword = '';
};
$switchLocale = function (string $locale) {
if ($this->editingId) {
$item = CmsSearchIndex::find($this->editingId);
if ($item) {
$this->editLocale = $locale;
$this->category = $item->getTranslation('category', $locale, false) ?? '';
$this->titleFallback = $item->getTranslation('title_fallback', $locale, false) ?? '';
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $locale, false) ?? '';
$this->keywords = $item->getTranslation('keywords', $locale, false) ?? [];
if (! is_array($this->keywords)) {
$this->keywords = [];
}
}
} else {
$this->editLocale = $locale;
}
};
$save = function () {
$item = CmsSearchIndex::find($this->editingId);
if (! $item) {
return;
}
$item->item_id = $this->itemId;
$item->route = $this->route;
$item->route_params = array_values(array_filter(array_map('trim', explode(',', $this->routeParams))));
$item->setTranslation('category', $this->editLocale, $this->category);
$item->title_key = $this->titleKey ?: null;
$item->setTranslation('title_fallback', $this->editLocale, $this->titleFallback ?: null);
$item->description_key = $this->descriptionKey ?: null;
$item->description_fallback_key = $this->descriptionFallbackKey ?: null;
$item->setTranslation('description_fallback_text', $this->editLocale, $this->descriptionFallbackText ?: null);
$cleanKeywords = array_values(array_filter($this->keywords, fn ($k) => is_string($k) && trim($k) !== ''));
$item->setTranslation('keywords', $this->editLocale, $cleanKeywords);
$item->is_published = $this->isPublished;
$item->save();
Flux::toast(variant: 'success', heading: 'Gespeichert', text: "Suchindex-Eintrag '{$item->item_id}' wurde gespeichert.");
};
$addKeyword = function () {
$keyword = trim($this->newKeyword);
if ($keyword !== '' && ! in_array($keyword, $this->keywords)) {
$this->keywords[] = $keyword;
}
$this->newKeyword = '';
};
$removeKeyword = function (int $index) {
unset($this->keywords[$index]);
$this->keywords = array_values($this->keywords);
};
$create = function () {
$maxOrder = CmsSearchIndex::max('order') ?? -1;
$item = CmsSearchIndex::create([
'item_id' => 'new-item-' . time(),
'route' => 'home',
'route_params' => [],
'category' => ['de' => 'Neu', 'en' => 'New'],
'keywords' => ['de' => [], 'en' => []],
'is_published' => false,
'order' => $maxOrder + 1,
]);
$this->editingId = $item->id;
$this->itemId = $item->item_id;
$this->route = $item->route;
$this->routeParams = '';
$this->category = 'Neu';
$this->titleKey = '';
$this->titleFallback = '';
$this->descriptionKey = '';
$this->descriptionFallbackKey = '';
$this->descriptionFallbackText = '';
$this->keywords = [];
$this->isPublished = false;
$this->newKeyword = '';
Flux::toast(variant: 'success', heading: 'Erstellt', text: 'Neuer Suchindex-Eintrag wurde erstellt.');
};
$delete = function (int $id) {
$item = CmsSearchIndex::find($id);
if ($item) {
$name = $item->item_id;
$item->delete();
if ($this->editingId === $id) {
$this->editingId = null;
}
Flux::toast(variant: 'success', heading: 'Geloescht', text: "Eintrag '{$name}' wurde entfernt.");
}
};
$togglePublished = function (int $id) {
$item = CmsSearchIndex::find($id);
if ($item) {
$item->is_published = ! $item->is_published;
$item->save();
Flux::toast(variant: 'success', heading: 'Status geaendert', text: $item->is_published ? 'Aktiviert' : 'Deaktiviert');
}
};
$moveUp = function (int $id) {
$item = CmsSearchIndex::find($id);
if (! $item) {
return;
}
$prev = CmsSearchIndex::where('order', '<', $item->order)->orderByDesc('order')->first();
if ($prev) {
$tmpOrder = $item->order;
$item->order = $prev->order;
$prev->order = $tmpOrder;
$item->save();
$prev->save();
}
};
$moveDown = function (int $id) {
$item = CmsSearchIndex::find($id);
if (! $item) {
return;
}
$next = CmsSearchIndex::where('order', '>', $item->order)->orderBy('order')->first();
if ($next) {
$tmpOrder = $item->order;
$item->order = $next->order;
$next->order = $tmpOrder;
$item->save();
$next->save();
}
};
$reindex = function () {
$this->reindexing = true;
try {
$exitCode = \Illuminate\Support\Facades\Artisan::call('search:extract-keywords', [
'--apply' => true,
'--locale' => ['de', 'en'],
]);
$deItems = [];
$enItems = [];
$dePath = lang_path('de/search_index.php');
$enPath = lang_path('en/search_index.php');
if (file_exists($dePath)) {
$deConfig = require $dePath;
$deItems = collect($deConfig['items'] ?? [])->keyBy('id');
}
if (file_exists($enPath)) {
$enConfig = require $enPath;
$enItems = collect($enConfig['items'] ?? [])->keyBy('id');
}
$updated = 0;
foreach (CmsSearchIndex::all() as $entry) {
$de = $deItems->get($entry->item_id);
$en = $enItems->get($entry->item_id);
if ($de && ! empty($de['keywords'])) {
$existing = $entry->getTranslation('keywords', 'de', false) ?? [];
$merged = array_values(array_unique(array_merge(
is_array($existing) ? $existing : [],
$de['keywords']
)));
$entry->setTranslation('keywords', 'de', $merged);
}
if ($en && ! empty($en['keywords'])) {
$existing = $entry->getTranslation('keywords', 'en', false) ?? [];
$merged = array_values(array_unique(array_merge(
is_array($existing) ? $existing : [],
$en['keywords']
)));
$entry->setTranslation('keywords', 'en', $merged);
}
if ($entry->isDirty()) {
$entry->save();
$updated++;
}
}
Flux::toast(variant: 'success', heading: 'Reindexierung abgeschlossen', text: "{$updated} Eintraege aktualisiert.");
} catch (\Exception $e) {
Flux::toast(variant: 'danger', heading: 'Fehler', text: $e->getMessage());
}
$this->reindexing = false;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<flux:heading size="xl">Suchindex</flux:heading>
<flux:text class="mt-1">Verwalte die Seiten-Suche: Keywords, Kategorien und Beschreibungen.</flux:text>
</div>
<div class="flex gap-2">
<flux:button wire:click="reindex" variant="ghost" icon="arrow-path" wire:loading.attr="disabled"
wire:target="reindex">
<span wire:loading.remove wire:target="reindex">Reindexieren</span>
<span wire:loading wire:target="reindex">Wird reindexiert...</span>
</flux:button>
<flux:button wire:click="create" variant="primary" icon="plus">Neuer Eintrag</flux:button>
</div>
</div>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suche nach ID, Route oder Kategorie..."
icon="magnifying-glass" />
</div>
<div class="flex gap-6">
{{-- Liste --}}
<div class="w-80 shrink-0 space-y-1 overflow-y-auto" style="max-height: 80vh;">
@foreach ($this->items as $item)
<div wire:key="si-{{ $item->id }}"
class="group flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition
{{ $editingId === $item->id ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950' : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600' }}
{{ ! $item->is_published ? 'opacity-50' : '' }}"
wire:click="startEdit({{ $item->id }})">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-800 dark:text-zinc-200">
{{ $item->item_id }}</p>
<p class="truncate text-xs text-zinc-400">
{{ $item->getTranslation('category', 'de', false) }}
&middot; {{ $item->route }}</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="xs" variant="ghost" icon="chevron-up"
wire:click.stop="moveUp({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
<flux:button size="xs" variant="ghost" icon="chevron-down"
wire:click.stop="moveDown({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
</div>
</div>
@endforeach
</div>
{{-- Editor --}}
<div class="flex-1">
@if ($editingId)
@php $currentItem = CmsSearchIndex::find($editingId); @endphp
@if ($currentItem)
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $currentItem->item_id }}</flux:heading>
<div class="flex items-center gap-3">
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs"
variant="{{ $editLocale === $code ? 'primary' : 'ghost' }}"
wire:click="switchLocale('{{ $code }}')">
{{ strtoupper($code) }}
</flux:button>
@endforeach
</div>
<flux:button size="sm" variant="{{ $isPublished ? 'primary' : 'ghost' }}"
wire:click="togglePublished({{ $editingId }})">
{{ $isPublished ? 'Aktiv' : 'Inaktiv' }}
</flux:button>
<flux:button size="sm" variant="danger" icon="trash"
wire:click="delete({{ $editingId }})"
wire:confirm="Suchindex-Eintrag wirklich loeschen?" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<flux:input wire:model="itemId" label="Item-ID" placeholder="z.B. home, leistungen" />
<flux:input wire:model="route" label="Route (Named)" placeholder="z.B. home, leistungen" />
</div>
<div class="mt-4">
<flux:input wire:model="routeParams" label="Route-Parameter (kommagetrennt)"
placeholder="z.B. strategische-fmcg-projektrealisierung" />
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<flux:input wire:model="category"
label="Kategorie ({{ strtoupper($editLocale) }})"
placeholder="z.B. Startseite, Leistungen" />
<flux:input wire:model="titleKey" label="Title-Key (CMS)"
placeholder="z.B. welcome.title" />
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<flux:input wire:model="titleFallback"
label="Title-Fallback ({{ strtoupper($editLocale) }})"
placeholder="Fallback wenn kein Key" />
<flux:input wire:model="descriptionKey" label="Description-Key (CMS)"
placeholder="z.B. welcome.hero.description" />
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<flux:input wire:model="descriptionFallbackKey" label="Description-Fallback-Key"
placeholder="Optionaler Fallback-Key" />
<flux:input wire:model="descriptionFallbackText"
label="Description-Fallback ({{ strtoupper($editLocale) }})"
placeholder="Statischer Fallback-Text" />
</div>
{{-- Keywords --}}
<div class="mt-6">
<label class="mb-2 block text-sm font-medium">
Keywords ({{ strtoupper($editLocale) }})
<span class="text-zinc-400">- {{ count($keywords) }} Eintraege</span>
</label>
<div class="mb-3 flex flex-wrap gap-1.5">
@foreach ($keywords as $kIdx => $keyword)
<span wire:key="kw-{{ $kIdx }}-{{ md5($keyword) }}"
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium
{{ str_contains($keyword, '.') ? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' }}">
@if (str_contains($keyword, '.'))
<x-heroicon-s-key class="h-3 w-3 opacity-50" />
@endif
{{ $keyword }}
<button wire:click="removeKeyword({{ $kIdx }})"
class="ml-0.5 text-zinc-400 hover:text-red-500">
<x-heroicon-s-x-mark class="h-3 w-3" />
</button>
</span>
@endforeach
</div>
<div class="flex gap-2">
<flux:input wire:model="newKeyword" placeholder="Neues Keyword oder CMS-Key..."
wire:keydown.enter.prevent="addKeyword" class="flex-1!" />
<flux:button wire:click="addKeyword" icon="plus" size="sm">Hinzufuegen</flux:button>
</div>
<p class="mt-1 text-xs text-zinc-400">
<x-heroicon-s-key class="inline h-3 w-3 text-blue-500" /> = CMS-Key (wird aufgeloest),
normale Keywords werden direkt verwendet.
Enter druecken oder Button klicken zum Hinzufuegen.
</p>
</div>
{{-- Vorschau --}}
@php
$preview = $currentItem->toFrontendArray($editLocale);
@endphp
<div class="mt-6 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-800">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">Vorschau
({{ strtoupper($editLocale) }})</p>
<p class="text-xs text-zinc-400">{{ $preview['category'] }}</p>
<p class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
{{ $preview['title'] ?: '(kein Titel)' }}</p>
<p class="mt-0.5 line-clamp-2 text-xs text-zinc-500">
{{ $preview['description'] ?: '(keine Beschreibung)' }}</p>
@if (! empty($preview['url']))
<p class="mt-1 truncate text-xs text-blue-500">{{ $preview['url'] }}</p>
@endif
@if (! empty($preview['keywords']))
<div class="mt-2 flex flex-wrap gap-1">
@foreach (array_slice($preview['keywords'], 0, 10) as $kw)
<span
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">{{ $kw }}</span>
@endforeach
@if (count($preview['keywords']) > 10)
<span
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">+{{ count($preview['keywords']) - 10 }}
weitere</span>
@endif
</div>
@endif
</div>
<div class="mt-4 flex justify-end">
<flux:button wire:click="save" variant="primary" icon="check">Speichern</flux:button>
</div>
</div>
@endif
@else
<div class="flex h-64 items-center justify-center rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700">
<div class="text-center">
<x-heroicon-o-magnifying-glass class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-2 text-sm text-zinc-400">Eintrag aus der Liste auswaehlen oder neuen erstellen.
</p>
</div>
</div>
@endif
</div>
</div>
</div>

View file

@ -0,0 +1,243 @@
<?php
use Flux\Flux;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use function Livewire\Volt\{state, computed, layout, on};
layout('components.layouts.cms');
state([
'editLocale' => 'de',
'showForm' => false,
'editingIndex' => null,
'name' => '',
'role' => '',
'image' => '',
'imageMediaId' => null,
'quote' => '',
'preview' => '',
'short' => '',
'linkedin' => '',
]);
$getProfiles = function (): array {
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
if (!$content) {
return [];
}
$val = $content->getTranslation('value', $this->editLocale);
return is_array($val) ? $val : [];
};
$profiles = computed(fn() => $this->getProfiles());
$create = function () {
$this->reset(['editingIndex', 'name', 'role', 'image', 'quote', 'preview', 'short', 'linkedin']);
$this->showForm = true;
};
$edit = function (int $index) {
$profiles = $this->getProfiles();
if (!isset($profiles[$index])) {
return;
}
$p = $profiles[$index];
$this->editingIndex = $index;
$this->showForm = true;
$this->name = $p['name'] ?? '';
$this->role = $p['role'] ?? '';
$this->image = $p['image'] ?? '';
$this->quote = $p['quote'] ?? '';
$this->preview = $p['preview'] ?? '';
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
$this->linkedin = $p['linkedin'] ?? '';
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
};
$save = function () {
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
if (!$content) {
return;
}
$profiles = $content->getTranslation('value', $this->editLocale);
if (!is_array($profiles)) {
$profiles = [];
}
$entry = [
'name' => $this->name,
'role' => $this->role,
'image' => $this->image,
'quote' => $this->quote,
'preview' => $this->preview,
'short' => $this->short,
'linkedin' => $this->linkedin,
];
if ($this->editingIndex !== null && isset($profiles[$this->editingIndex])) {
$profiles[$this->editingIndex] = $entry;
} else {
$profiles[] = $entry;
}
$content->setTranslation('value', $this->editLocale, array_values($profiles));
$content->save();
app(CmsContentService::class)->clearCache('team');
$this->showForm = false;
$this->editingIndex = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Teammitglied wurde gespeichert.');
};
$delete = function (int $index) {
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
if (!$content) {
return;
}
$profiles = $content->getTranslation('value', $this->editLocale);
if (!is_array($profiles) || !isset($profiles[$index])) {
return;
}
unset($profiles[$index]);
$content->setTranslation('value', $this->editLocale, array_values($profiles));
$content->save();
app(CmsContentService::class)->clearCache('team');
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Teammitglied wurde entfernt.');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingIndex !== null) {
$profiles = $this->getProfiles();
if (isset($profiles[$this->editingIndex])) {
$p = $profiles[$this->editingIndex];
$this->name = $p['name'] ?? '';
$this->role = $p['role'] ?? '';
$this->image = $p['image'] ?? '';
$this->quote = $p['quote'] ?? '';
$this->preview = $p['preview'] ?? '';
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
$this->linkedin = $p['linkedin'] ?? '';
}
}
};
$cancel = function () {
$this->showForm = false;
$this->editingIndex = null;
};
on([
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'team_image') {
$this->imageMediaId = $mediaId;
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
$this->image = $media ? $media->filename : '';
}
},
]);
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Team-Verwaltung</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
</div>
</div>
@if ($showForm)
<flux:card class="mb-6">
<flux:heading size="lg" class="mb-4">
{{ $editingIndex !== null ? 'Teammitglied bearbeiten' : 'Neues Teammitglied' }}
</flux:heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="name" label="Name" />
<flux:input wire:model="role" label="Position / Rolle" />
<flux:input wire:model="short" label="Kürzel" placeholder="z.B. PB" />
<flux:input wire:model="linkedin" label="LinkedIn-URL" />
<div>
<label class="mb-1 block text-sm font-medium">Profilbild</label>
<div class="flex items-start gap-3">
@if ($image)
<div
class="h-16 w-16 shrink-0 overflow-hidden rounded-full border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="$imageMediaId" field="team_image" type="image"
profile="avatar" label="Bild wählen" :key="'team-img-' . ($editingIndex ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
<div class="md:col-span-2">
<flux:input wire:model="preview" label="Kurzvorstellung (1 Satz)" />
</div>
</div>
<div class="mt-4">
<flux:editor wire:model="quote" label="Profil-Text (ausführlich)" toolbar="bold italic | link"
class="**:data-[slot=content]:min-h-[120px]!" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->profiles as $index => $profile)
<div wire:key="team-{{ $index }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-4 min-w-0 flex-1">
@if (!empty($profile['image']))
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($profile['image']) }}" alt="{{ $profile['name'] ?? '' }}"
class="h-full w-full object-cover" />
</div>
@else
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
{{ $profile['short'] ?? mb_substr($profile['name'] ?? '?', 0, 2) }}
</div>
@endif
<div class="min-w-0">
<div class="font-medium">{{ $profile['name'] ?? '—' }}</div>
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
{{ $profile['role'] ?? '' }}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="edit({{ $index }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="delete({{ $index }})"
wire:confirm="'{{ $profile['name'] ?? 'Dieses Mitglied' }}' wirklich löschen?" />
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine Teammitglieder vorhanden.</flux:text>
@endforelse
</div>
</flux:card>
</div>

View file

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
<a href="{{ route('cms.dashboard') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse" wire:navigate>
<x-app-logo />
</a>
<flux:navlist variant="outline">
<flux:navlist.group heading="CMS" class="grid">
<flux:navlist.item icon="home" :href="route('cms.dashboard')"
:current="request()->routeIs('cms.dashboard')" wire:navigate>
Dashboard
</flux:navlist.item>
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
:current="request()->routeIs('cms.content.*')" wire:navigate>
Inhalte
</flux:navlist.item>
<flux:navlist.item icon="newspaper" :href="route('cms.news.index')"
:current="request()->routeIs('cms.news.*')" wire:navigate>
News Band
</flux:navlist.item>
<flux:navlist.item icon="building-office" :href="route('cms.industries.index')"
:current="request()->routeIs('cms.industries.*')" wire:navigate>
Industries
</flux:navlist.item>
<flux:navlist.item icon="question-mark-circle" :href="route('cms.faqs.index')"
:current="request()->routeIs('cms.faqs.*')" wire:navigate>
FAQs
</flux:navlist.item>
<flux:navlist.item icon="chat-bubble-left-right" :href="route('cms.linkedin.index')"
:current="request()->routeIs('cms.linkedin.*')" wire:navigate>
LinkedIn
</flux:navlist.item>
<flux:navlist.item icon="arrow-down-tray" :href="route('cms.downloads.index')"
:current="request()->routeIs('cms.downloads.*')" wire:navigate>
Downloads
</flux:navlist.item>
<flux:navlist.item icon="user-group" :href="route('cms.team.index')"
:current="request()->routeIs('cms.team.*')" wire:navigate>
Team
</flux:navlist.item>
<flux:navlist.item icon="photo" :href="route('cms.media.index')"
:current="request()->routeIs('cms.media.*')" wire:navigate>
Medienbibliothek
</flux:navlist.item>
<flux:navlist.item icon="magnifying-glass" :href="route('cms.search-index')"
:current="request()->routeIs('cms.search-index')" wire:navigate>
Suchindex
</flux:navlist.item>
</flux:navlist.group>
</flux:navlist>
<flux:spacer />
<flux:navlist variant="outline">
<flux:navlist.item icon="arrow-left" :href="route('dashboard')" wire:navigate>
Zurück zum Dashboard
</flux:navlist.item>
</flux:navlist>
@auth
<flux:dropdown class="hidden lg:block" position="bottom" align="start">
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()" />
<flux:menu class="w-[220px]">
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
<span
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{{ auth()->user()->initials() }}
</span>
</span>
<div class="grid flex-1 text-start text-sm leading-tight">
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
</div>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
@endauth
</flux:sidebar>
@auth
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
<flux:menu>
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
@endauth
<flux:main>
{{ $slot }}
</flux:main>
@persist('toast')
<flux:toast />
@endpersist
@fluxScripts
</body>
</html>

View file

@ -0,0 +1,8 @@
<div class="inline-flex items-center gap-2">
<input type="file" wire:model="file" accept="{{ $accept }}"
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
<div wire:loading wire:target="file">
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
</div>
</div>

View file

@ -1,13 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
use FluxCms\Core\Http\Controllers\Admin\BlogController;
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
use FluxCms\Core\Http\Controllers\Admin\MediaController;
use FluxCms\Core\Http\Controllers\Admin\NavigationController;
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
use FluxCms\Core\Http\Controllers\PageController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------

View file

@ -1,7 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use FluxCms\Core\Http\Controllers\PageController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------

View file

@ -2,12 +2,13 @@
namespace FluxCms\Core\Commands;
use Illuminate\Console\Command;
use FluxCms\Core\Services\ComponentRegistry;
use Illuminate\Console\Command;
class ClearCacheCommand extends Command
{
protected $signature = 'flux-cms:clear-cache';
protected $description = 'Clear the Flux CMS component registry cache';
public function handle(ComponentRegistry $registry): int
@ -15,6 +16,7 @@ class ClearCacheCommand extends Command
$this->info('Clearing Flux CMS component cache...');
$registry->clearCache();
$this->info('Flux CMS component cache cleared successfully!');
return self::SUCCESS;
}
}

View file

@ -19,17 +19,17 @@ class InstallCommand extends Command
$this->info('🚀 Installing Flux CMS...');
// Check requirements
if (!$this->checkRequirements()) {
if (! $this->checkRequirements()) {
return 1;
}
// Publish configuration
if (!$this->option('no-publish')) {
if (! $this->option('no-publish')) {
$this->publishAssets();
}
// Run migrations
if (!$this->option('no-migrate')) {
if (! $this->option('no-migrate')) {
$this->runMigrations();
}
@ -70,7 +70,7 @@ class InstallCommand extends Command
}
}
if (!$allPassed) {
if (! $allPassed) {
$this->error('❌ Some requirements are not met. Please install missing dependencies.');
$this->line('Run: composer require spatie/laravel-translatable spatie/laravel-medialibrary');
}
@ -85,11 +85,12 @@ class InstallCommand extends Command
protected function checkLivewireVersion(): bool
{
if (!class_exists(\Livewire\Livewire::class)) {
if (! class_exists(\Livewire\Livewire::class)) {
return false;
}
$version = \Livewire\Livewire::VERSION ?? '2.0.0';
return version_compare($version, '3.0.0', '>=');
}
@ -129,7 +130,7 @@ class InstallCommand extends Command
protected function createStorageLink(): void
{
if (!File::exists(public_path('storage'))) {
if (! File::exists(public_path('storage'))) {
$this->info('🔗 Creating storage link...');
$this->call('storage:link');
$this->line('✅ Storage link created');

View file

@ -8,22 +8,25 @@ use Symfony\Component\Console\Input\InputArgument;
class MakeComponentCommand extends GeneratorCommand
{
protected $name = 'flux-cms:make-component';
protected $description = 'Create a new Flux CMS component';
protected $type = 'Flux CMS Component';
protected function getStub()
{
return __DIR__ . '/stubs/component.stub';
return __DIR__.'/stubs/component.stub';
}
protected function getDefaultNamespace($rootNamespace)
{
return $rootNamespace . '\Livewire\Web\Components';
return $rootNamespace.'\Livewire\Web\Components';
}
protected function buildClass($name)
{
$stub = parent::buildClass($name);
return str_replace('{{ componentName }}', $this->argument('name'), $stub);
}
@ -43,7 +46,7 @@ class MakeComponentCommand extends GeneratorCommand
protected function createView()
{
$name = $this->argument('name');
$viewPath = resource_path('views/livewire/web/components/' . strtolower($name) . '.blade.php');
$viewPath = resource_path('views/livewire/web/components/'.strtolower($name).'.blade.php');
if (! is_dir(dirname($viewPath))) {
mkdir(dirname($viewPath), 0777, true);
@ -51,10 +54,11 @@ class MakeComponentCommand extends GeneratorCommand
if (file_exists($viewPath)) {
$this->error('View already exists!');
return;
}
$stub = $this->files->get(__DIR__ . '/stubs/view.stub');
$stub = $this->files->get(__DIR__.'/stubs/view.stub');
$this->files->put($viewPath, $stub);
$this->info('View created successfully.');
}

View file

@ -3,7 +3,6 @@
namespace FluxCms\Core\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class PublishCommand extends Command
{
@ -27,6 +26,7 @@ class PublishCommand extends Command
}
$this->info('✅ Flux CMS assets published successfully!');
return 0;
}

View file

@ -5,13 +5,21 @@ namespace FluxCms\Core\FieldTypes;
abstract class BaseField
{
protected string $key;
protected string $label;
protected bool $translatable = false;
protected bool $required = false;
protected mixed $default = null;
protected array $rules = [];
protected array $attributes = [];
protected ?string $helpText = null;
protected ?string $placeholder = null;
public function __construct(string $key, string $label)
@ -31,48 +39,56 @@ abstract class BaseField
public function translatable(bool $translatable = true): static
{
$this->translatable = $translatable;
return $this;
}
public function required(bool $required = true): static
{
$this->required = $required;
return $this;
}
public function default(mixed $default): static
{
$this->default = $default;
return $this;
}
public function rules(array|string $rules): static
{
$this->rules = is_array($rules) ? $rules : [$rules];
return $this;
}
public function helpText(string $helpText): static
{
$this->helpText = $helpText;
return $this;
}
public function placeholder(string $placeholder): static
{
$this->placeholder = $placeholder;
return $this;
}
public function attributes(array $attributes): static
{
$this->attributes = array_merge($this->attributes, $attributes);
return $this;
}
public function attribute(string $key, mixed $value): static
{
$this->attributes[$key] = $value;
return $this;
}
@ -139,13 +155,15 @@ abstract class BaseField
* Abstract Methods
*/
abstract public function getType(): string;
abstract public function getValidationRules(): array;
abstract public function toArray(): array;
/**
* Value Handling
*/
public function getValue(array $content, string $locale = null): mixed
public function getValue(array $content, ?string $locale = null): mixed
{
if ($this->translatable && $locale) {
return $content[$this->key][$locale] ?? $this->default;
@ -154,7 +172,7 @@ abstract class BaseField
return $content[$this->key] ?? $this->default;
}
public function setValue(array &$content, mixed $value, string $locale = null): void
public function setValue(array &$content, mixed $value, ?string $locale = null): void
{
if ($this->translatable && $locale) {
$content[$this->key][$locale] = $value;
@ -166,7 +184,7 @@ abstract class BaseField
/**
* Validation
*/
public function validate(mixed $value, string $locale = null): array
public function validate(mixed $value, ?string $locale = null): array
{
$rules = $this->getValidationRules();
@ -177,7 +195,7 @@ abstract class BaseField
// Für übersetzbare Felder Locale zu Regeln hinzufügen
$fieldKey = $this->key;
if ($locale && $this->translatable) {
$fieldKey .= '.' . $locale;
$fieldKey .= '.'.$locale;
}
try {
@ -190,7 +208,7 @@ abstract class BaseField
return $validator->fails() ? $validator->errors()->get($fieldKey) : [];
} catch (\Exception $e) {
return ['Validation error: ' . $e->getMessage()];
return ['Validation error: '.$e->getMessage()];
}
}
@ -226,7 +244,7 @@ abstract class BaseField
/**
* Rendering
*/
public function render(array $content = [], string $locale = null): string
public function render(array $content = [], ?string $locale = null): string
{
$value = $this->getValue($content, $locale);
@ -242,8 +260,8 @@ abstract class BaseField
$viewName = "flux-cms::fields.{$this->getType()}";
if (!view()->exists($viewName)) {
$viewName = "flux-cms::fields.fallback";
if (! view()->exists($viewName)) {
$viewName = 'flux-cms::fields.fallback';
}
return view($viewName, $viewData)->render();
@ -252,7 +270,7 @@ abstract class BaseField
/**
* Wire Model für Livewire
*/
public function getWireModel(string $locale = null): string
public function getWireModel(?string $locale = null): string
{
if ($this->translatable && $locale) {
return "content.{$this->key}.{$locale}";
@ -264,12 +282,12 @@ abstract class BaseField
/**
* Field ID für Labels
*/
public function getFieldId(string $locale = null): string
public function getFieldId(?string $locale = null): string
{
$id = 'field_' . $this->key;
$id = 'field_'.$this->key;
if ($this->translatable && $locale) {
$id .= '_' . $locale;
$id .= '_'.$locale;
}
return $id;
@ -280,7 +298,7 @@ abstract class BaseField
*/
public function getCssClasses(bool $hasError = false): string
{
$classes = ['flux-cms-field', 'flux-cms-field--' . $this->getType()];
$classes = ['flux-cms-field', 'flux-cms-field--'.$this->getType()];
if ($this->required) {
$classes[] = 'flux-cms-field--required';
@ -324,4 +342,4 @@ abstract class BaseField
{
return $value;
}
}
}

View file

@ -5,37 +5,44 @@ namespace FluxCms\Core\FieldTypes;
class BooleanField extends BaseField
{
protected string $trueLabel = 'Yes';
protected string $falseLabel = 'No';
protected string $displayType = 'checkbox'; // checkbox, toggle, radio
public function labels(string $trueLabel, string $falseLabel): static
{
$this->trueLabel = $trueLabel;
$this->falseLabel = $falseLabel;
return $this;
}
public function displayType(string $type): static
{
$this->displayType = $type;
return $this;
}
public function toggle(): static
{
$this->displayType = 'toggle';
return $this;
}
public function radio(): static
{
$this->displayType = 'radio';
return $this;
}
public function checkbox(): static
{
$this->displayType = 'checkbox';
return $this;
}
@ -79,15 +86,17 @@ class BooleanField extends BaseField
// Convert various truthy values to boolean
if (is_string($value)) {
$value = strtolower($value);
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
return (bool) $value;
}
public function getValue(array $content, string $locale = null): mixed
public function getValue(array $content, ?string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
return $this->sanitizeValue($value);
}
@ -112,4 +121,4 @@ class BooleanField extends BaseField
'attributes' => $this->attributes,
];
}
}
}

View file

@ -5,16 +5,23 @@ namespace FluxCms\Core\FieldTypes;
class MediaField extends BaseField
{
protected array $acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
protected bool $multiple = false;
protected int $maxFiles = 1;
protected ?string $collection = null;
protected array $conversions = [];
protected int $maxFileSize = 10240; // 10MB in KB
protected bool $showPreview = true;
public function acceptedMimeTypes(array $mimeTypes): static
{
$this->acceptedMimeTypes = $mimeTypes;
return $this;
}
@ -22,30 +29,35 @@ class MediaField extends BaseField
{
$this->multiple = $multiple;
$this->maxFiles = $maxFiles;
return $this;
}
public function collection(string $collection): static
{
$this->collection = $collection;
return $this;
}
public function conversions(array $conversions): static
{
$this->conversions = $conversions;
return $this;
}
public function maxFileSize(int $sizeInKb): static
{
$this->maxFileSize = $sizeInKb;
return $this;
}
public function showPreview(bool $show = true): static
{
$this->showPreview = $show;
return $this;
}
@ -53,6 +65,7 @@ class MediaField extends BaseField
{
$this->acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
$this->collection = 'images';
return $this;
}
@ -64,10 +77,11 @@ class MediaField extends BaseField
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv'
'text/csv',
];
$this->collection = 'documents';
$this->showPreview = false;
return $this;
}
@ -75,6 +89,7 @@ class MediaField extends BaseField
{
$this->acceptedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg'];
$this->collection = 'videos';
return $this;
}
@ -83,6 +98,7 @@ class MediaField extends BaseField
$this->acceptedMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg'];
$this->collection = 'audio';
$this->showPreview = false;
return $this;
}
@ -157,38 +173,37 @@ class MediaField extends BaseField
public function isImageType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'image/')));
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'image/')));
}
public function isVideoType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'video/')));
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'video/')));
}
public function isAudioType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'audio/')));
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'audio/')));
}
public function isDocumentType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) =>
str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
));
}
public function getValue(array $content, string $locale = null): mixed
public function getValue(array $content, ?string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
// Ensure array for multiple fields
if ($this->multiple && !is_array($value)) {
if ($this->multiple && ! is_array($value)) {
return $value ? [$value] : [];
}
// Ensure integer for single fields
if (!$this->multiple && is_array($value)) {
return !empty($value) ? (int) $value[0] : null;
if (! $this->multiple && is_array($value)) {
return ! empty($value) ? (int) $value[0] : null;
}
return $value;
@ -230,4 +245,4 @@ class MediaField extends BaseField
'attributes' => $this->attributes,
];
}
}
}

View file

@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes;
class NumberField extends BaseField
{
protected ?float $min = null;
protected ?float $max = null;
protected float $step = 1;
protected bool $decimal = false;
public function min(float $min): static
{
$this->min = $min;
return $this;
}
public function max(float $max): static
{
$this->max = $max;
return $this;
}
public function step(float $step): static
{
$this->step = $step;
return $this;
}
@ -33,6 +39,7 @@ class NumberField extends BaseField
if ($decimal && $this->step === 1) {
$this->step = 0.01;
}
return $this;
}
@ -41,6 +48,7 @@ class NumberField extends BaseField
$this->decimal(true);
$this->step(0.01);
$this->min(0);
return $this;
}
@ -50,6 +58,7 @@ class NumberField extends BaseField
$this->min(0);
$this->max(100);
$this->step(0.1);
return $this;
}
@ -141,4 +150,4 @@ class NumberField extends BaseField
'attributes' => $this->attributes,
];
}
}
}

View file

@ -5,31 +5,38 @@ namespace FluxCms\Core\FieldTypes;
class SelectField extends BaseField
{
protected array $options = [];
protected bool $multiple = false;
protected bool $searchable = false;
protected ?string $emptyOption = null;
public function options(array $options): static
{
$this->options = $options;
return $this;
}
public function multiple(bool $multiple = true): static
{
$this->multiple = $multiple;
return $this;
}
public function searchable(bool $searchable = true): static
{
$this->searchable = $searchable;
return $this;
}
public function emptyOption(string $text): static
{
$this->emptyOption = $text;
return $this;
}
@ -72,34 +79,34 @@ class SelectField extends BaseField
$rules[] = 'string';
}
if (!empty($this->options)) {
if (! empty($this->options)) {
$validValues = array_keys($this->options);
if ($this->multiple) {
$rules[] = 'array';
// Each value must be in the valid options
foreach ($validValues as $value) {
$rules[] = "array";
$rules[] = 'array';
}
} else {
$rules[] = "in:" . implode(',', $validValues);
$rules[] = 'in:'.implode(',', $validValues);
}
}
return array_merge($rules, $this->rules);
}
public function getValue(array $content, string $locale = null): mixed
public function getValue(array $content, ?string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
// Ensure array for multiple selects
if ($this->multiple && !is_array($value)) {
if ($this->multiple && ! is_array($value)) {
return $value ? [$value] : [];
}
// Ensure string for single selects
if (!$this->multiple && is_array($value)) {
return !empty($value) ? (string) $value[0] : '';
if (! $this->multiple && is_array($value)) {
return ! empty($value) ? (string) $value[0] : '';
}
return $value;
@ -122,4 +129,4 @@ class SelectField extends BaseField
'attributes' => $this->attributes,
];
}
}
}

View file

@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes;
class TextField extends BaseField
{
protected int $maxLength = 255;
protected int $minLength = 0;
protected ?string $pattern = null;
protected string $inputType = 'text';
public function maxLength(int $maxLength): static
{
$this->maxLength = $maxLength;
return $this;
}
public function minLength(int $minLength): static
{
$this->minLength = $minLength;
return $this;
}
public function pattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
@ -31,6 +37,7 @@ class TextField extends BaseField
{
$this->inputType = 'email';
$this->rules(['email']);
return $this;
}
@ -38,24 +45,28 @@ class TextField extends BaseField
{
$this->inputType = 'url';
$this->rules(['url']);
return $this;
}
public function password(): static
{
$this->inputType = 'password';
return $this;
}
public function tel(): static
{
$this->inputType = 'tel';
return $this;
}
public function search(): static
{
$this->inputType = 'search';
return $this;
}
@ -117,7 +128,7 @@ class TextField extends BaseField
public function sanitizeValue(mixed $value): mixed
{
if (!is_string($value)) {
if (! is_string($value)) {
return $value;
}
@ -128,8 +139,8 @@ class TextField extends BaseField
$value = strtolower($value);
} elseif ($this->inputType === 'url') {
// Ensure URL has protocol
if ($value && !preg_match('/^https?:\/\//', $value)) {
$value = 'https://' . $value;
if ($value && ! preg_match('/^https?:\/\//', $value)) {
$value = 'https://'.$value;
}
}
@ -163,4 +174,4 @@ class TextField extends BaseField
'regex' => 'The :attribute field format is invalid.',
]);
}
}
}

View file

@ -5,45 +5,56 @@ namespace FluxCms\Core\FieldTypes;
class WysiwygField extends BaseField
{
protected array $toolbar = ['bold', 'italic', 'link', 'bulletList', 'orderedList'];
protected int $minHeight = 200;
protected bool $allowImages = true;
protected bool $allowTables = false;
protected bool $allowCode = true;
protected string $editor = 'tiptap'; // tiptap, tinymce, quill
public function toolbar(array $toolbar): static
{
$this->toolbar = $toolbar;
return $this;
}
public function minHeight(int $minHeight): static
{
$this->minHeight = $minHeight;
return $this;
}
public function allowImages(bool $allowImages = true): static
{
$this->allowImages = $allowImages;
return $this;
}
public function allowTables(bool $allowTables = true): static
{
$this->allowTables = $allowTables;
return $this;
}
public function allowCode(bool $allowCode = true): static
{
$this->allowCode = $allowCode;
return $this;
}
public function editor(string $editor): static
{
$this->editor = $editor;
return $this;
}
@ -53,6 +64,7 @@ class WysiwygField extends BaseField
$this->allowImages = false;
$this->allowTables = false;
$this->allowCode = false;
return $this;
}
@ -64,11 +76,12 @@ class WysiwygField extends BaseField
'bulletList', 'orderedList',
'link', 'image', 'table',
'code', 'codeBlock',
'quote', 'rule'
'quote', 'rule',
];
$this->allowImages = true;
$this->allowTables = true;
$this->allowCode = true;
return $this;
}
@ -120,7 +133,7 @@ class WysiwygField extends BaseField
public function sanitizeValue(mixed $value): mixed
{
if (!is_string($value)) {
if (! is_string($value)) {
return $value;
}
@ -130,8 +143,8 @@ class WysiwygField extends BaseField
// Remove dangerous tags
$dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'];
foreach ($dangerousTags as $tag) {
$value = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $value);
$value = preg_replace('/<' . $tag . '[^>]*\/?>/is', '', $value);
$value = preg_replace('/<'.$tag.'[^>]*>.*?<\/'.$tag.'>/is', '', $value);
$value = preg_replace('/<'.$tag.'[^>]*\/?>/is', '', $value);
}
// Remove javascript: links
@ -145,13 +158,13 @@ class WysiwygField extends BaseField
public function transformForDisplay(mixed $value): mixed
{
if (!is_string($value)) {
if (! is_string($value)) {
return $value;
}
// Convert relative URLs to absolute
$value = preg_replace_callback('/src="(\/[^"]*)"/', function ($matches) {
return 'src="' . url($matches[1]) . '"';
return 'src="'.url($matches[1]).'"';
}, $value);
return $value;
@ -176,4 +189,4 @@ class WysiwygField extends BaseField
'attributes' => $this->attributes,
];
}
}
}

View file

@ -2,152 +2,63 @@
namespace FluxCms\Core;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use FluxCms\Core\Services\CmsContentService;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Support\Facades\Gate;
use FluxCms\Core\Services\ComponentRegistry;
use FluxCms\Core\Commands\InstallCommand;
use FluxCms\Core\Commands\PublishCommand;
use FluxCms\Core\Commands\ClearCacheCommand;
use FluxCms\Core\Commands\MakeComponentCommand;
use Illuminate\Support\ServiceProvider;
class FluxCmsServiceProvider extends ServiceProvider
{
/**
* Register services
*/
public function register(): void
{
// Merge config
$this->mergeConfigFrom(__DIR__ . '/../config/flux-cms.php', 'flux-cms');
$this->mergeConfigFrom(__DIR__.'/../config/flux-cms.php', 'flux-cms');
// Register services
$this->app->singleton(ComponentRegistry::class, function ($app) {
return new ComponentRegistry();
$this->app->singleton(CmsContentService::class, function () {
return new CmsContentService;
});
// Register aliases
$this->app->alias(ComponentRegistry::class, 'flux-cms.registry');
$this->app->alias(CmsContentService::class, 'flux-cms.content');
$this->app->singleton(MediaConversionService::class, function () {
return new MediaConversionService;
});
}
/**
* Bootstrap services
*/
public function boot(): void
{
$this->bootPublishing();
$this->bootMigrations();
$this->bootViews();
$this->bootCommands();
$this->bootRoutes();
$this->bootMiddleware();
$this->bootGates();
$this->bootTranslations();
}
/**
* Boot publishing
*/
protected function bootPublishing(): void
{
if ($this->app->runningInConsole()) {
// Publish config
$this->publishes([
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
__DIR__.'/../config/flux-cms.php' => config_path('flux-cms.php'),
], 'flux-cms-config');
// Publish migrations
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'flux-cms-migrations');
// Publish views
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
__DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms'),
], 'flux-cms-views');
// Publish all
$this->publishes([
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
__DIR__ . '/../database/migrations' => database_path('migrations'),
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
], 'flux-cms');
}
}
/**
* Boot migrations
*/
protected function bootMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
/**
* Boot views
*/
protected function bootViews(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms');
}
/**
* Boot commands
*/
protected function bootCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
InstallCommand::class,
PublishCommand::class,
ClearCacheCommand::class,
MakeComponentCommand::class,
]);
}
}
/**
* Boot routes
*/
protected function bootRoutes(): void
{
if (config('flux-cms.routes.enabled', true)) {
$this->loadRoutesFromDirectory(__DIR__ . '/../routes');
}
}
/**
* Boot middleware
*/
protected function bootMiddleware(): void
{
$router = $this->app['router'];
// Register middleware aliases
$router->aliasMiddleware('flux-cms:cms-access', \FluxCms\Core\Http\Middleware\CmsAccess::class);
$router->aliasMiddleware('flux-cms:domain-detection', \FluxCms\Core\Http\Middleware\DomainDetection::class);
$router->aliasMiddleware('flux-cms:preview-mode', \FluxCms\Core\Http\Middleware\PreviewMode::class);
}
/**
* Load routes from directory
*/
protected function loadRoutesFromDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}
$files = glob($directory . '/*.php');
foreach ($files as $file) {
$this->loadRoutesFrom($file);
}
}
/**
* Boot authorization gates
*/
protected function bootGates(): void
{
Gate::define('flux-cms.view', function ($user) {
@ -171,52 +82,22 @@ class FluxCmsServiceProvider extends ServiceProvider
});
}
/**
* Check if user has CMS permission
*/
protected function userHasCmsPermission($user, string $permission): bool
{
// If no user, deny access
if (!$user) {
if (! $user) {
return false;
}
// Check for Spatie Permission package
if (method_exists($user, 'can')) {
return $user->can("flux-cms.{$permission}") || $user->hasRole('flux-cms') || $user->hasRole('admin');
if (method_exists($user, 'hasRole')) {
return $user->can("flux-cms.{$permission}")
|| $user->hasRole('flux-cms')
|| $user->hasRole('admin');
}
// Fallback: Check if user has admin role property
if (isset($user->is_admin)) {
return $user->is_admin;
}
// Default: Allow access for authenticated users (can be overridden in config)
return config('flux-cms.auth.default_access', false);
}
/**
* Boot translations
*/
protected function bootTranslations(): void
{
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'flux-cms');
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../resources/lang' => resource_path('lang/vendor/flux-cms'),
], 'flux-cms-translations');
}
}
/**
* Get the services provided by the provider
*/
public function provides(): array
{
return [
ComponentRegistry::class,
'flux-cms.registry',
];
return config('flux-cms.auth.default_access', true);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Services\MediaConversionService;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaLibraryUploader extends Component
{
use WithFileUploads;
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $uploads = [];
public function updatedUploads(): void
{
$this->validate([
'uploads' => 'nullable|array|max:20',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
foreach ($this->uploads as $file) {
$media = $service->storeUpload($file);
$this->dispatch('media-library-uploaded', mediaId: $media->id);
}
$this->uploads = [];
}
public function removeUpload(int $index): void
{
if (isset($this->uploads[$index])) {
unset($this->uploads[$index]);
$this->uploads = array_values($this->uploads);
}
}
public function render()
{
return view('livewire.admin.cms.media-library-uploader');
}
}

View file

@ -0,0 +1,139 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
class MediaPicker extends Component
{
use WithFileUploads;
use WithPagination;
public ?int $value = null;
public string $field = 'media_id';
public string $type = 'image';
public string $profile = 'thumbnail';
public string $label = 'Bild auswählen';
public bool $showModal = false;
public string $search = '';
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $quickUploads = [];
public function mount(?int $value = null): void
{
$this->value = $value;
}
public function openPicker(): void
{
$this->showModal = true;
}
public function selectMedia(int $id): void
{
$media = CmsMedia::find($id);
if (! $media) {
return;
}
if ($media->isImage() && $this->profile) {
$service = app(MediaConversionService::class);
if (! $media->hasConversion($this->profile)) {
$service->convert($media, $this->profile);
$media->refresh();
}
}
$this->value = $media->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile));
}
public function clearSelection(): void
{
$this->value = null;
$this->dispatch('media-selected', field: $this->field, mediaId: null, url: null);
}
public function updatedQuickUploads(): void
{
$this->validate([
'quickUploads' => 'nullable|array|max:5',
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
$lastMedia = null;
foreach ($this->quickUploads as $file) {
$lastMedia = $service->storeUpload($file);
if ($lastMedia->isImage() && $this->profile) {
$service->convert($lastMedia, $this->profile);
$lastMedia->refresh();
}
}
$this->quickUploads = [];
if ($lastMedia) {
$this->value = $lastMedia->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile));
}
}
public function removeQuickUpload(int $index): void
{
if (isset($this->quickUploads[$index])) {
unset($this->quickUploads[$index]);
$this->quickUploads = array_values($this->quickUploads);
}
}
public function render(): View
{
return view('livewire.admin.cms.media-picker', [
'selectedMedia' => $this->resolveSelectedMedia(),
'mediaItems' => $this->resolveMediaItems(),
]);
}
private function resolveSelectedMedia(): ?CmsMedia
{
if (! $this->value) {
return null;
}
return CmsMedia::find($this->value);
}
/**
* @return LengthAwarePaginator<int, CmsMedia>
*/
private function resolveMediaItems(): LengthAwarePaginator
{
return CmsMedia::query()
->when($this->type === 'image', fn ($q) => $q->images())
->when($this->type === 'pdf', fn ($q) => $q->pdfs())
->when($this->type === 'document', fn ($q) => $q->documents())
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(18);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin\Cms;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaUploader extends Component
{
use WithFileUploads;
public string $field = '';
public string $accept = 'image/*';
public string $disk = 'public';
public string $directory = 'cms/uploads';
#[Validate('file|max:10240')]
public $file;
public function updatedFile(): void
{
$this->validate();
$path = $this->file->store($this->directory, $this->disk);
$this->dispatch('media-uploaded', field: $this->field, path: '/storage/'.$path);
$this->file = null;
}
public function render()
{
return view('livewire.admin.cms.media-uploader');
}
}

View file

@ -0,0 +1,85 @@
<?php
use FluxCms\Core\Services\CmsContentService;
if (! function_exists('cms')) {
/**
* Resolve a CMS content value by dot-notation key.
*
* First segment is the group, rest is the key: "welcome.hero.heading"
* Falls back to __() if no DB entry exists.
*
* @param array<string, string> $replace
*/
function cms(string $key, array $replace = [], ?string $locale = null): mixed
{
return app(CmsContentService::class)->get($key, $replace, $locale);
}
}
if (! function_exists('tcms')) {
/**
* CMS content with automatic tooltip replacement.
*
* @param array<string, string> $replace
*/
function tcms(string $key, array $replace = [], ?string $locale = null): string
{
$text = cms($key, $replace, $locale);
if (! is_string($text)) {
$text = (string) $text;
}
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}
if (! function_exists('t__')) {
/**
* Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips
*
* @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description')
* @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max'])
* @param string|null $locale Optionale Locale (Standard: aktuelle Locale)
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function t__(string $key, array $replace = [], ?string $locale = null): string
{
// Holt den übersetzten Text
$text = __($key, $replace, $locale);
// Wendet automatische Tooltip-Ersetzung an
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}
if (! function_exists('trans_tooltip')) {
/**
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
*
* @param string $key Der Übersetzungsschlüssel
* @param array $replace Optionale Ersetzungen
* @param string|null $locale Optionale Locale
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string
{
return t__($key, $replace, $locale);
}
}
if (! function_exists('tooltip')) {
/**
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
*
* @param string $key Der Übersetzungsschlüssel
* @param array $replace Optionale Ersetzungen
* @param string|null $locale Optionale Locale
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function tooltip(string $text): string
{
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}

View file

@ -0,0 +1,144 @@
<?php
use FluxCms\Core\Services\CmsContentService;
if (! function_exists('cms')) {
/**
* Resolve a CMS content value by dot-notation key.
*
* First segment is the group, rest is the key: "welcome.hero.heading"
* Falls back to __() if no DB entry exists.
*
* @param array<string, string> $replace
*/
function cms(string $key, array $replace = [], ?string $locale = null): mixed
{
return app(CmsContentService::class)->get($key, $replace, $locale);
}
}
if (! function_exists('media_url')) {
/**
* Resolve a media library filename to its storage URL.
*
* Looks up CmsMedia by filename and returns the URL.
* Falls back to asset('assets/images/...') if not found.
*/
function media_url(string $filename, ?string $profile = null): string
{
static $cache = [];
$cacheKey = $filename.'|'.($profile ?? '');
if (isset($cache[$cacheKey])) {
return $cache[$cacheKey];
}
$media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first();
if (! $media) {
return $cache[$cacheKey] = asset('assets/images/'.$filename);
}
if ($profile && $media->hasConversion($profile)) {
return $cache[$cacheKey] = $media->getConversionUrl($profile);
}
return $cache[$cacheKey] = $media->getUrl();
}
}
if (! function_exists('cms_media_url')) {
/**
* Resolve a CMS content key to a media library URL.
*
* The CMS entry stores a CmsMedia filename.
* Returns the original URL or a conversion URL if a profile is specified.
*/
function cms_media_url(string $key, ?string $profile = null): string
{
$filename = cms($key);
if (! $filename || ! is_string($filename)) {
return '';
}
$media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first();
if (! $media) {
return asset('assets/images/'.$filename);
}
if ($profile && $media->hasConversion($profile)) {
return $media->getConversionUrl($profile);
}
return $media->getUrl();
}
}
if (! function_exists('tcms')) {
/**
* CMS content with automatic tooltip replacement.
*
* @param array<string, string> $replace
*/
function tcms(string $key, array $replace = [], ?string $locale = null): string
{
$text = cms($key, $replace, $locale);
if (! is_string($text)) {
$text = (string) $text;
}
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}
if (! function_exists('t__')) {
/**
* Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips
*
* @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description')
* @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max'])
* @param string|null $locale Optionale Locale (Standard: aktuelle Locale)
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function t__(string $key, array $replace = [], ?string $locale = null): string
{
// Holt den übersetzten Text
$text = __($key, $replace, $locale);
// Wendet automatische Tooltip-Ersetzung an
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}
if (! function_exists('trans_tooltip')) {
/**
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
*
* @param string $key Der Übersetzungsschlüssel
* @param array $replace Optionale Ersetzungen
* @param string|null $locale Optionale Locale
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string
{
return t__($key, $replace, $locale);
}
}
if (! function_exists('tooltip')) {
/**
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
*
* @param string $key Der Übersetzungsschlüssel
* @param array $replace Optionale Ersetzungen
* @param string|null $locale Optionale Locale
* @return string Der übersetzte Text mit automatischen Tooltips
*/
function tooltip(string $text): string
{
return \App\Helpers\TooltipHelper::autoTooltip($text);
}
}

View file

@ -2,9 +2,9 @@
namespace FluxCms\Core\Http\Controllers\Admin;
use FluxCms\Core\Models\BlogPost;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\BlogPost;
class BlogController extends Controller
{
@ -36,6 +36,7 @@ class BlogController extends Controller
public function edit(BlogPost $blogPost)
{
$this->authorize('flux-cms.edit');
return view('flux-cms::admin.blog.edit', ['post' => $blogPost]);
}
}

View file

@ -2,8 +2,8 @@
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Routing\Controller;
use FluxCms\Core\Services\ComponentRegistry;
use Illuminate\Routing\Controller;
class ComponentController extends Controller
{

Some files were not shown because too many files have changed in this diff Show more