10-04-2026

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,9 +2,9 @@
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Core\Models\Page;
use Illuminate\Routing\Controller;
class DashboardController extends Controller
{

View file

@ -2,9 +2,9 @@
namespace FluxCms\Core\Http\Controllers\Admin;
use FluxCms\Core\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
class PageController extends Controller
{
@ -42,6 +42,7 @@ class PageController extends Controller
$this->authorize('flux-cms.edit');
$domains = $this->getAvailableDomains();
$locales = config('flux-cms.locales');
return view('flux-cms::admin.pages.create', compact('domains', 'locales'));
}
@ -75,6 +76,7 @@ class PageController extends Controller
public function edit(Page $page)
{
$this->authorize('flux-cms.edit');
return view('flux-cms::admin.pages.edit', compact('page'));
}
@ -92,7 +94,7 @@ class PageController extends Controller
$page->update([
'title' => $validated['title'],
'is_published' => $request->boolean('is_published'),
'published_at' => $request->boolean('is_published') && !$page->published_at ? now() : $page->published_at,
'published_at' => $request->boolean('is_published') && ! $page->published_at ? now() : $page->published_at,
]);
foreach ($validated['slugs'] as $locale => $slug) {
@ -110,12 +112,13 @@ class PageController extends Controller
{
$this->authorize('flux-cms.delete');
$page->delete();
return redirect()->route('admin.cms.pages.index')->with('success', 'Page deleted successfully!');
}
private function getAvailableDomains(): array
{
if (!config('flux-cms.domains.enabled')) {
if (! config('flux-cms.domains.enabled')) {
return ['default' => 'Default'];
}

View file

@ -2,10 +2,10 @@
namespace FluxCms\Core\Http\Controllers;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Core\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
class PageController extends Controller
{
@ -117,7 +117,7 @@ class PageController extends Controller
*/
protected function getCurrentDomainKey(Request $request): string
{
if (!config('flux-cms.domains.enabled')) {
if (! config('flux-cms.domains.enabled')) {
return config('flux-cms.domains.default_domain', 'default');
}

View file

@ -16,12 +16,12 @@ class CmsAccess
{
$user = Auth::user();
if (!$user) {
if (! $user) {
return redirect()->route('login');
}
// Check if user has CMS permission
if (!$this->userHasCmsPermission($user, $permission)) {
if (! $this->userHasCmsPermission($user, $permission)) {
abort(403, 'Access denied. You do not have permission to access the CMS.');
}

View file

@ -13,15 +13,15 @@ class DomainDetection
*/
public function handle(Request $request, Closure $next): Response
{
if (!config('flux-cms.domains.enabled')) {
if (! config('flux-cms.domains.enabled')) {
return $next($request);
}
$domainKey = $this->detectDomainKey($request);
// Set domain key in request for later use
$request->attributes->set('flux_cms_domain_key', $domainKey);
// Set locale based on domain if configured
$this->setLocaleFromDomain($request, $domainKey);
@ -54,7 +54,7 @@ class DomainDetection
protected function setLocaleFromDomain(Request $request, string $domainKey): void
{
$domains = config('domains.domains', []);
if (isset($domains[$domainKey]['locale'])) {
$locale = $domains[$domainKey]['locale'];
app()->setLocale($locale);

View file

@ -16,7 +16,7 @@ class PreviewMode
// Check if preview mode is enabled via query parameter
if ($request->has('preview') && $request->boolean('preview')) {
// Verify user has preview permission
if (!$this->userCanPreview($request)) {
if (! $this->userCanPreview($request)) {
abort(403, 'Preview access denied');
}
@ -34,14 +34,14 @@ class PreviewMode
{
$user = $request->user();
if (!$user) {
if (! $user) {
return false;
}
// Check for Spatie Permission package
if (method_exists($user, 'can')) {
return $user->can('flux-cms.view') ||
$user->hasRole('flux-cms') ||
return $user->can('flux-cms.view') ||
$user->hasRole('flux-cms') ||
$user->hasRole('admin');
}

View file

@ -3,20 +3,19 @@
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Tags\HasTags;
use Spatie\Translatable\HasTranslations;
class BlogPost extends Model implements HasMedia
{
use HasTranslations, InteractsWithMedia, HasTags;
use HasTags, HasTranslations, InteractsWithMedia;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'blog_posts';
return config('flux-cms.database.table_prefix', 'flux_cms_').'blog_posts';
}
protected $fillable = [
@ -39,7 +38,7 @@ class BlogPost extends Model implements HasMedia
'excerpt',
'content',
'meta_title',
'meta_description'
'meta_description',
];
protected $casts = [
@ -101,15 +100,16 @@ class BlogPost extends Model implements HasMedia
return $query->orderBy('published_at', 'desc')->limit($limit);
}
public function scopeBySlug($query, string $slug, string $locale = null)
public function scopeBySlug($query, string $slug, ?string $locale = null)
{
$locale = $locale ?? app()->getLocale();
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
$q->where('slug', $slug)->where('locale', $locale);
});
}
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
public function scopeBySlugWithFallback($query, string $slug, ?string $locale = null)
{
$locale = $locale ?? app()->getLocale();
$fallbackLocale = config('app.fallback_locale');
@ -126,7 +126,7 @@ class BlogPost extends Model implements HasMedia
/**
* Get the URL for this blog post
*/
public function getUrl(string $locale = null): string
public function getUrl(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$slug = $this->slugs()->where('locale', $locale)->first();
@ -141,6 +141,7 @@ class BlogPost extends Model implements HasMedia
{
$content = strip_tags($this->getTranslation('content', app()->getLocale()));
$wordCount = str_word_count($content);
return max(1, ceil($wordCount / 200)); // Assuming 200 words per minute
}
@ -173,7 +174,7 @@ class BlogPost extends Model implements HasMedia
/**
* Get SEO title with fallback to title
*/
public function getSeoTitle(string $locale = null): string
public function getSeoTitle(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
@ -184,7 +185,7 @@ class BlogPost extends Model implements HasMedia
/**
* Get SEO description with fallback to excerpt
*/
public function getSeoDescription(string $locale = null): string
public function getSeoDescription(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
@ -205,7 +206,7 @@ class BlogPost extends Model implements HasMedia
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
}
public function registerMediaConversions(Media $media = null): void
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
@ -233,7 +234,7 @@ class BlogPost extends Model implements HasMedia
{
$media = $this->getFeaturedImage();
if (!$media) {
if (! $media) {
return null;
}

View file

@ -0,0 +1,50 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsContent extends Model
{
use HasTranslations;
protected $table = 'flux_cms_contents';
protected $fillable = [
'group',
'key',
'type',
'value',
'order',
];
/** @var array<string> */
public array $translatable = ['value'];
protected function casts(): array
{
return [
'value' => 'array',
'order' => 'integer',
];
}
public function scopeForGroup($query, string $group)
{
return $query->where('group', $group);
}
public function scopeByKey($query, string $key)
{
return $query->where('key', $key);
}
public function getValueForLocale(?string $locale = null): mixed
{
$locale = $locale ?? app()->getLocale();
return $this->getTranslation('value', $locale)
?? $this->getTranslation('value', config('app.fallback_locale', 'de'));
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsDownload extends Model
{
use HasTranslations;
protected $table = 'flux_cms_downloads';
protected $fillable = [
'title',
'description',
'category',
'icon',
'sub_category',
'type_label',
'alt',
'file_path',
'thumbnail',
'open_text',
'download_text',
'highlights',
'checkpoints',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'title',
'description',
'type_label',
'alt',
'file_path',
'open_text',
'download_text',
];
protected function casts(): array
{
return [
'title' => 'array',
'description' => 'array',
'type_label' => 'array',
'alt' => 'array',
'file_path' => 'array',
'open_text' => 'array',
'download_text' => 'array',
'highlights' => 'array',
'checkpoints' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
/**
* @return array<string, mixed>
*/
public function toFrontendArray(?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
return [
'alt' => $this->getTranslation('alt', $locale) ?: $this->getTranslation('title', $locale),
'icon' => $this->icon ?? 'document-text',
'image' => $this->thumbnail ? media_url($this->thumbnail) : '',
'title' => $this->getTranslation('title', $locale),
'category' => $this->sub_category ?? $this->category,
'pdf_path' => $this->getTranslation('file_path', $locale) ? media_url($this->getTranslation('file_path', $locale)) : '',
'open_text' => $this->getTranslation('open_text', $locale) ?: __('PDF öffnen'),
'type_label' => $this->getTranslation('type_label', $locale) ?: ucfirst(str_replace('_', ' ', $this->category)),
'highlights' => $this->highlights ?? [],
'checkpoints' => $this->checkpoints ?? [],
'description' => $this->getTranslation('description', $locale),
'download_text' => $this->getTranslation('download_text', $locale) ?: __('PDF downloaden'),
];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsFaq extends Model
{
use HasTranslations;
protected $table = 'flux_cms_faqs';
protected $fillable = [
'category',
'question',
'answer',
'help',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['question', 'answer', 'help'];
protected function casts(): array
{
return [
'question' => 'array',
'answer' => 'array',
'help' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsIndustry extends Model
{
use HasTranslations;
protected $table = 'flux_cms_industries';
protected $fillable = [
'name',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['name'];
protected function casts(): array
{
return [
'name' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsLinkedinPost extends Model
{
use HasTranslations;
protected $table = 'flux_cms_linkedin_posts';
protected $fillable = [
'linkedin_id',
'title',
'excerpt',
'content',
'author',
'date',
'url',
'image',
'tags',
'source',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['title', 'excerpt', 'content'];
protected function casts(): array
{
return [
'title' => 'array',
'excerpt' => 'array',
'content' => 'array',
'tags' => 'array',
'date' => 'date',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('order')->orderByDesc('date');
}
public function scopeManual($query)
{
return $query->where('source', 'manual');
}
public function scopeFromApi($query)
{
return $query->where('source', 'api');
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Spatie\Translatable\HasTranslations;
class CmsMedia extends Model
{
use HasTranslations;
protected $table = 'flux_cms_media';
protected $fillable = [
'filename',
'disk',
'path',
'type',
'mime_type',
'file_size',
'original_width',
'original_height',
'alt_text',
'title',
'collection',
'conversions',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['alt_text', 'title'];
protected function casts(): array
{
return [
'alt_text' => 'array',
'title' => 'array',
'conversions' => 'array',
'file_size' => 'integer',
'original_width' => 'integer',
'original_height' => 'integer',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeImages($query)
{
return $query->where('type', 'image');
}
public function scopePdfs($query)
{
return $query->where('type', 'pdf');
}
public function scopeDocuments($query)
{
return $query->whereIn('type', ['pdf', 'document']);
}
public function scopeInCollection($query, string $collection)
{
return $query->where('collection', $collection);
}
public function scopeOrdered($query)
{
return $query->orderByDesc('created_at');
}
public function getUrl(): string
{
return Storage::disk($this->disk)->url($this->path);
}
/**
* Get the URL for a specific conversion profile.
* Falls back to original if conversion doesn't exist.
*/
public function getConversionUrl(string $profile): string
{
$conversions = $this->conversions ?? [];
if (isset($conversions[$profile]) && Storage::disk($this->disk)->exists($conversions[$profile])) {
return Storage::disk($this->disk)->url($conversions[$profile]);
}
return $this->getUrl();
}
/**
* Check if a specific conversion exists.
*/
public function hasConversion(string $profile): bool
{
$conversions = $this->conversions ?? [];
return isset($conversions[$profile])
&& Storage::disk($this->disk)->exists($conversions[$profile]);
}
/**
* @return array<string, string>
*/
public function getExistingConversions(): array
{
return $this->conversions ?? [];
}
public function isImage(): bool
{
return $this->type === 'image';
}
public function isPdf(): bool
{
return $this->type === 'pdf';
}
public function getHumanFileSize(): string
{
$bytes = $this->file_size;
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1).' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 0).' KB';
}
return $bytes.' B';
}
public function getDimensionsLabel(): string
{
if ($this->original_width && $this->original_height) {
return $this->original_width.' × '.$this->original_height;
}
return '';
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsNewsItem extends Model
{
use HasTranslations;
protected $table = 'flux_cms_news_items';
protected $fillable = [
'icon',
'text',
'title',
'excerpt',
'content',
'image',
'date',
'author',
'link',
'pdf_path',
'pdf_open_text',
'pdf_download_text',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'text',
'title',
'excerpt',
'content',
'pdf_open_text',
'pdf_download_text',
];
protected function casts(): array
{
return [
'text' => 'array',
'title' => 'array',
'excerpt' => 'array',
'content' => 'array',
'pdf_open_text' => 'array',
'pdf_download_text' => 'array',
'date' => 'date',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
/**
* @return array<string, mixed>
*/
public function toFrontendArray(?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
return [
'icon' => $this->icon,
'text' => $this->getTranslation('text', $locale),
'title' => $this->getTranslation('title', $locale),
'excerpt' => $this->getTranslation('excerpt', $locale),
'content' => $this->getTranslation('content', $locale),
'image' => $this->image ? media_url($this->image) : '',
'date' => $this->date?->format('Y-m-d'),
'author' => $this->author,
'link' => $this->link,
'pdf_path' => $this->pdf_path ? media_url($this->pdf_path) : '',
'pdf_open_text' => $this->getTranslation('pdf_open_text', $locale),
'pdf_download_text' => $this->getTranslation('pdf_download_text', $locale),
];
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
class CmsSearchIndex extends Model
{
use HasTranslations;
protected $table = 'flux_cms_search_index';
protected $fillable = [
'item_id',
'route',
'route_params',
'category',
'title_key',
'title_fallback',
'description_key',
'description_fallback_key',
'description_fallback_text',
'keywords',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'category',
'title_fallback',
'description_fallback_text',
'keywords',
];
protected function casts(): array
{
return [
'route_params' => 'array',
'keywords' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
/**
* Build a frontend-ready array for the search index.
*/
public function toFrontendArray(?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$url = '';
try {
$url = route($this->route, $this->route_params ?? []);
} catch (\Exception $e) {
// Route not found
}
$title = $this->resolveTitle($locale);
$description = $this->resolveDescription($locale);
$category = $this->getTranslation('category', $locale, false) ?? '';
$keywords = $this->resolveKeywords($locale);
return [
'id' => $this->item_id,
'url' => $url,
'category' => $category,
'title' => $title,
'description' => $description,
'keywords' => $keywords,
];
}
protected function resolveTitle(?string $locale = null): string
{
if ($this->title_key) {
$value = $this->resolveContentKey($this->title_key, $locale);
if ($value !== '') {
return $value;
}
}
$fallback = $this->getTranslation('title_fallback', $locale, false);
return is_string($fallback) ? $fallback : '';
}
protected function resolveDescription(?string $locale = null): string
{
if ($this->description_key) {
$value = $this->resolveContentKey($this->description_key, $locale);
if ($value !== '') {
return $value;
}
}
if ($this->description_fallback_key) {
$value = $this->resolveContentKey($this->description_fallback_key, $locale);
if ($value !== '') {
return $value;
}
}
$fallback = $this->getTranslation('description_fallback_text', $locale, false);
return is_string($fallback) ? $fallback : '';
}
/**
* Resolve keywords: translation-key keywords get resolved to their text value,
* plain text keywords are kept as-is.
*/
protected function resolveKeywords(?string $locale = null): array
{
$raw = $this->getTranslation('keywords', $locale, false);
if (! is_array($raw)) {
return [];
}
$resolved = [];
foreach ($raw as $keyword) {
if (! is_string($keyword) || $keyword === '') {
continue;
}
if (str_contains($keyword, '.')) {
$value = $this->resolveContentKey($keyword, $locale);
if ($value !== '') {
$resolved[] = $value;
continue;
}
}
$resolved[] = $keyword;
}
return array_values(array_unique($resolved));
}
/**
* Resolve a CMS/translation key to plain text.
*/
protected function resolveContentKey(string $key, ?string $locale = null): string
{
if (function_exists('cms')) {
$value = cms($key, [], $locale);
if (is_string($value) && $value !== $key) {
return trim(strip_tags($value));
}
}
$value = __($key, [], $locale ?? app()->getLocale());
if (is_array($value) || $value === $key) {
return '';
}
return trim(strip_tags((string) $value));
}
}

View file

@ -12,7 +12,7 @@ class Navigation extends Model
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigations';
return config('flux-cms.database.table_prefix', 'flux_cms_').'navigations';
}
protected $fillable = [
@ -24,7 +24,7 @@ class Navigation extends Model
];
protected $translatable = [
'display_name'
'display_name',
];
protected $casts = [
@ -36,15 +36,15 @@ class Navigation extends Model
public function items(): HasMany
{
return $this->hasMany(NavigationItem::class, 'navigation_id')
->where('is_active', true)
->whereNull('parent_id')
->orderBy('order');
->where('is_active', true)
->whereNull('parent_id')
->orderBy('order');
}
public function allItems(): HasMany
{
return $this->hasMany(NavigationItem::class, 'navigation_id')
->orderBy('order');
->orderBy('order');
}
/**
@ -89,4 +89,4 @@ class Navigation extends Model
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}
}

View file

@ -13,7 +13,7 @@ class NavigationItem extends Model
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigation_items';
return config('flux-cms.database.table_prefix', 'flux_cms_').'navigation_items';
}
protected $fillable = [
@ -29,7 +29,7 @@ class NavigationItem extends Model
];
protected $translatable = [
'title'
'title',
];
protected $casts = [
@ -52,14 +52,14 @@ class NavigationItem extends Model
public function children(): HasMany
{
return $this->hasMany(NavigationItem::class, 'parent_id')
->where('is_active', true)
->orderBy('order');
->where('is_active', true)
->orderBy('order');
}
public function allChildren(): HasMany
{
return $this->hasMany(NavigationItem::class, 'parent_id')
->orderBy('order');
->orderBy('order');
}
public function page(): BelongsTo
@ -112,6 +112,7 @@ class NavigationItem extends Model
{
$ids = [];
$this->collectDescendantIds($ids);
return $ids;
}
@ -137,4 +138,4 @@ class NavigationItem extends Model
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}
}

View file

@ -4,10 +4,10 @@ namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Translatable\HasTranslations;
class Page extends Model implements HasMedia
{
@ -15,7 +15,7 @@ class Page extends Model implements HasMedia
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'pages';
return config('flux-cms.database.table_prefix', 'flux_cms_').'pages';
}
protected $fillable = [
@ -34,7 +34,7 @@ class Page extends Model implements HasMedia
'title',
'meta_description',
'meta_keywords',
'og_image'
'og_image',
];
protected $casts = [
@ -84,7 +84,7 @@ class Page extends Model implements HasMedia
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
}
public function registerMediaConversions(Media $media = null): void
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('og_thumb')
->width(1200)
@ -114,15 +114,16 @@ class Page extends Model implements HasMedia
});
}
public function scopeBySlug($query, string $slug, string $locale = null)
public function scopeBySlug($query, string $slug, ?string $locale = null)
{
$locale = $locale ?? app()->getLocale();
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
$q->where('slug', $slug)->where('locale', $locale);
});
}
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
public function scopeBySlugWithFallback($query, string $slug, ?string $locale = null)
{
$locale = $locale ?? app()->getLocale();
$fallbackLocale = config('app.fallback_locale');
@ -147,7 +148,7 @@ class Page extends Model implements HasMedia
/**
* Version Management
*/
public function createVersion(string $changeDescription = null, ?Model $user = null): PageVersion
public function createVersion(?string $changeDescription = null, ?Model $user = null): PageVersion
{
$version = $this->versions()->make([
'page_data' => $this->toArray(),
@ -161,13 +162,15 @@ class Page extends Model implements HasMedia
}
$version->save();
return $version;
}
protected function generateVersionName(): string
{
$count = $this->versions()->count();
return "Version " . ($count + 1) . " - " . now()->format('Y-m-d H:i');
return 'Version '.($count + 1).' - '.now()->format('Y-m-d H:i');
}
/**
@ -189,7 +192,7 @@ class Page extends Model implements HasMedia
/**
* SEO Methods
*/
public function getSeoTitle(string $locale = null): string
public function getSeoTitle(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$title = $this->getTranslation('title', $locale);
@ -198,9 +201,10 @@ class Page extends Model implements HasMedia
return $title ? "{$title} - {$siteName}" : $siteName;
}
public function getSeoDescription(string $locale = null): string
public function getSeoDescription(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
return $this->getTranslation('meta_description', $locale) ?? '';
}
@ -212,12 +216,12 @@ class Page extends Model implements HasMedia
/**
* URL Generation
*/
public function getUrl(string $locale = null): string
public function getUrl(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$slug = $this->slugs()->where('locale', $locale)->first();
if (!$slug || $slug->slug === '/') {
if (! $slug || $slug->slug === '/') {
return url('/');
}

View file

@ -2,13 +2,13 @@
namespace FluxCms\Core\Models;
use FluxCms\Core\Services\ComponentRegistry;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use FluxCms\Core\Services\ComponentRegistry;
use Spatie\Translatable\HasTranslations;
class PageComponent extends Model implements HasMedia
{
@ -16,7 +16,7 @@ class PageComponent extends Model implements HasMedia
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_components';
return config('flux-cms.database.table_prefix', 'flux_cms_').'page_components';
}
protected $fillable = [
@ -29,7 +29,7 @@ class PageComponent extends Model implements HasMedia
];
protected $translatable = [
'content'
'content',
];
protected $casts = [
@ -49,36 +49,36 @@ class PageComponent extends Model implements HasMedia
public function registerMediaCollections(): void
{
$this->addMediaCollection('component_images')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
$this->addMediaCollection('component_files')
->acceptsMimeTypes([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]);
->acceptsMimeTypes([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
]);
$this->addMediaCollection('component_videos')
->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']);
->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']);
}
public function registerMediaConversions(Media $media = null): void
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
->height(300)
->sharpen(10);
->width(300)
->height(300)
->sharpen(10);
$this->addMediaConversion('medium')
->width(800)
->height(600)
->sharpen(10);
->width(800)
->height(600)
->sharpen(10);
$this->addMediaConversion('large')
->width(1200)
->height(900)
->sharpen(10);
->width(1200)
->height(900)
->sharpen(10);
}
/**
@ -86,16 +86,17 @@ class PageComponent extends Model implements HasMedia
*/
public function getComponentConfig(): array
{
if (!class_exists($this->component_class)) {
if (! class_exists($this->component_class)) {
return [
'name' => 'Unknown Component',
'fields' => [],
'category' => 'Unknown',
'error' => 'Component class not found: ' . $this->component_class
'error' => 'Component class not found: '.$this->component_class,
];
}
$registry = app(ComponentRegistry::class);
return $registry->getComponentConfig($this->component_class);
}
@ -104,11 +105,12 @@ class PageComponent extends Model implements HasMedia
*/
public function validateContent(): array
{
if (!class_exists($this->component_class)) {
if (! class_exists($this->component_class)) {
return ['component_class' => 'Component class not found'];
}
$registry = app(ComponentRegistry::class);
return $registry->validateComponentContent($this->component_class, $this->content ?? []);
}
@ -133,7 +135,7 @@ class PageComponent extends Model implements HasMedia
/**
* Content Management
*/
public function getTranslatedContent(string $locale = null): array
public function getTranslatedContent(?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$content = $this->getTranslations('content');
@ -141,7 +143,7 @@ class PageComponent extends Model implements HasMedia
return $content[$locale] ?? $content[config('app.fallback_locale')] ?? [];
}
public function setTranslatedContent(array $content, string $locale = null): void
public function setTranslatedContent(array $content, ?string $locale = null): void
{
$locale = $locale ?? app()->getLocale();
$translations = $this->getTranslations('content');
@ -150,13 +152,14 @@ class PageComponent extends Model implements HasMedia
$this->save();
}
public function getContentValue(string $key, string $locale = null): mixed
public function getContentValue(string $key, ?string $locale = null): mixed
{
$content = $this->getTranslatedContent($locale);
return data_get($content, $key);
}
public function setContentValue(string $key, mixed $value, string $locale = null): void
public function setContentValue(string $key, mixed $value, ?string $locale = null): void
{
$content = $this->getTranslatedContent($locale);
data_set($content, $key, $value);
@ -174,19 +177,21 @@ class PageComponent extends Model implements HasMedia
public function getComponentName(): string
{
$config = $this->getComponentConfig();
return $config['name'] ?? class_basename($this->component_class);
}
public function getComponentCategory(): string
{
$config = $this->getComponentConfig();
return $config['category'] ?? 'General';
}
/**
* Duplication
*/
public function duplicate(int $newOrder = null): self
public function duplicate(?int $newOrder = null): self
{
$duplicate = $this->replicate();
$duplicate->order = $newOrder ?? ($this->page->allComponents()->max('order') + 1);
@ -214,4 +219,4 @@ class PageComponent extends Model implements HasMedia
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}
}

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