10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
39
packages/acme/CookieConsent/composer.json
Normal file
39
packages/acme/CookieConsent/composer.json
Normal 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
|
||||
}
|
||||
127
packages/acme/CookieConsent/config/cookie-consent.php
Normal file
127
packages/acme/CookieConsent/config/cookie-consent.php
Normal 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'),
|
||||
],
|
||||
|
||||
];
|
||||
138
packages/acme/CookieConsent/lang/de/cookie-consent.php
Normal file
138
packages/acme/CookieConsent/lang/de/cookie-consent.php
Normal 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:',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
138
packages/acme/CookieConsent/lang/en/cookie-consent.php
Normal file
138
packages/acme/CookieConsent/lang/en/cookie-consent.php
Normal 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:',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
365
packages/acme/CookieConsent/readme.md
Normal file
365
packages/acme/CookieConsent/readme.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
303
packages/acme/contact-form/README.md
Normal file
303
packages/acme/contact-form/README.md
Normal 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
|
||||
38
packages/acme/contact-form/composer.json
Normal file
38
packages/acme/contact-form/composer.json
Normal 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
|
||||
}
|
||||
126
packages/acme/contact-form/config/contact-form.php
Normal file
126
packages/acme/contact-form/config/contact-form.php
Normal 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']],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
9
packages/acme/contact-form/lang/de/contact-form.php
Normal file
9
packages/acme/contact-form/lang/de/contact-form.php
Normal 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...',
|
||||
];
|
||||
9
packages/acme/contact-form/lang/en/contact-form.php
Normal file
9
packages/acme/contact-form/lang/en/contact-form.php
Normal 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...',
|
||||
];
|
||||
92
packages/acme/contact-form/resources/views/form.blade.php
Normal file
92
packages/acme/contact-form/resources/views/form.blade.php
Normal 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>
|
||||
135
packages/acme/contact-form/src/ContactFormService.php
Normal file
135
packages/acme/contact-form/src/ContactFormService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal file
109
packages/acme/contact-form/src/Livewire/ContactForm.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
165
packages/acme/contact-form/src/SpamDetector.php
Normal file
165
packages/acme/contact-form/src/SpamDetector.php
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
264
packages/flux-cms/MIGRATION.md
Normal file
264
packages/flux-cms/MIGRATION.md
Normal 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
|
||||
458
packages/flux-cms/README-FILE-UPLOAD.md
Normal file
458
packages/flux-cms/README-FILE-UPLOAD.md
Normal 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
493
packages/flux-cms/SETUP.md
Normal 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
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -29,4 +29,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_page_components');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -30,4 +30,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_page_versions');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -27,4 +27,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_navigations');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -32,4 +32,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_navigation_items');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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> ']],
|
||||
],
|
||||
[
|
||||
'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> ']],
|
||||
],
|
||||
// === 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> ']],
|
||||
],
|
||||
[
|
||||
'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> ']],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] ?? [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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) }}
|
||||
· {{ $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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Http\Controllers\PageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
Normal file
45
packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
139
packages/flux-cms/core/src/Helpers/MediaPicker.php
Normal file
139
packages/flux-cms/core/src/Helpers/MediaPicker.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
packages/flux-cms/core/src/Helpers/MediaUploader.php
Normal file
39
packages/flux-cms/core/src/Helpers/MediaUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
85
packages/flux-cms/core/src/Helpers/cms_helpers.php
Normal file
85
packages/flux-cms/core/src/Helpers/cms_helpers.php
Normal 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);
|
||||
}
|
||||
}
|
||||
144
packages/flux-cms/core/src/Helpers/helpers.php
Normal file
144
packages/flux-cms/core/src/Helpers/helpers.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue