10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,597 +1,14 @@
|
|||
# Flux CMS Installation Guide
|
||||
# Installation
|
||||
|
||||
This guide will walk you through installing and setting up Flux CMS in your Laravel application.
|
||||
> **Hinweis:** Diese Datei beschreibt das Legacy-Installationsverfahren (Page/Component-basiertes CMS).
|
||||
> Für die aktuelle Content-basierte Implementierung siehe **[SETUP.md](./SETUP.md)**.
|
||||
|
||||
## System Requirements
|
||||
Die aktuelle Version von Flux CMS arbeitet mit einem Key-Value Content Store und spezialisierten Models
|
||||
(News, Industries, FAQs, Downloads, LinkedIn Posts, Team, Suchindex). Die vollständige Dokumentation
|
||||
findest du in:
|
||||
|
||||
- **PHP**: 8.2 or higher
|
||||
- **Laravel**: 11.0 or higher
|
||||
- **Livewire**: 3.0 or higher
|
||||
- **Database**: MySQL 8.0+ or PostgreSQL 13+
|
||||
- **Node.js**: 18+ (for asset compilation)
|
||||
|
||||
## Required Laravel Packages
|
||||
|
||||
Flux CMS requires these packages to be installed:
|
||||
|
||||
- `spatie/laravel-translatable`: For multilingual content
|
||||
- `spatie/laravel-medialibrary`: For media management
|
||||
- `spatie/laravel-tags`: For tagging blog posts
|
||||
- `livewire/livewire`: For reactive components
|
||||
- `livewire/flux`: For UI components (recommended)
|
||||
|
||||
## Step-by-Step Installation
|
||||
|
||||
### 1. Install Required Dependencies
|
||||
|
||||
```bash
|
||||
# Install required packages
|
||||
composer require spatie/laravel-translatable spatie/laravel-medialibrary spatie/laravel-tags
|
||||
|
||||
# Install Livewire if not already installed
|
||||
composer require livewire/livewire
|
||||
|
||||
# Install Flux UI (recommended for admin interface)
|
||||
composer require livewire/flux
|
||||
```
|
||||
|
||||
### 2. Install Flux CMS Packages
|
||||
|
||||
```bash
|
||||
# Install core package
|
||||
composer require flux-cms/core
|
||||
|
||||
# Install components package (recommended)
|
||||
composer require flux-cms/components
|
||||
|
||||
# Install starter components (optional)
|
||||
composer require flux-cms/starter-components
|
||||
```
|
||||
|
||||
### 3. Run Installation Command
|
||||
|
||||
```bash
|
||||
# This will publish config, run migrations, and set up permissions
|
||||
php artisan flux-cms:install
|
||||
|
||||
# Or with options
|
||||
php artisan flux-cms:install --no-migrate --no-publish
|
||||
```
|
||||
|
||||
The installation command will:
|
||||
- ✅ Check system requirements
|
||||
- 📦 Publish configuration files
|
||||
- 🗃️ Run database migrations
|
||||
- 🔗 Create storage link
|
||||
- 📝 Create sample content (optional)
|
||||
- 🔐 Set up permissions (optional)
|
||||
|
||||
### 4. Configure Your Application
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
Add these to your `.env` file:
|
||||
|
||||
```env
|
||||
# Flux CMS Configuration
|
||||
FLUX_CMS_DEFAULT_LOCALE=de
|
||||
FLUX_CMS_CACHE_ENABLED=true
|
||||
FLUX_CMS_ROUTES_ENABLED=true
|
||||
|
||||
# Media Configuration
|
||||
FLUX_CMS_MEDIA_DISK=public
|
||||
FLUX_CMS_MAX_FILE_SIZE=10240
|
||||
|
||||
# Multi-domain (if using)
|
||||
FLUX_CMS_MULTI_DOMAIN=true
|
||||
FLUX_CMS_AUTO_DETECT_DOMAIN=true
|
||||
```
|
||||
|
||||
#### Update App Configuration
|
||||
|
||||
```php
|
||||
// config/app.php
|
||||
'locale' => env('APP_LOCALE', 'de'),
|
||||
'fallback_locale' => 'de',
|
||||
|
||||
// Add available locales
|
||||
'available_locales' => ['de', 'en'],
|
||||
```
|
||||
|
||||
### 5. Set Up Routes
|
||||
|
||||
#### Admin Routes
|
||||
|
||||
Add to your `routes/web.php` or create `routes/admin.php`:
|
||||
|
||||
```php
|
||||
// Admin routes (protected)
|
||||
Route::middleware(['web', 'auth'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::prefix('cms')->name('cms.')->group(function () {
|
||||
Route::get('/', [Admin\DashboardController::class, 'index'])->name('index');
|
||||
Route::resource('/pages', Admin\PageController::class)->except(['show']);
|
||||
Route::get('/blog', [Admin\BlogController::class, 'index'])->name('blog.index');
|
||||
Route::get('/media', [Admin\MediaController::class, 'index'])->name('media.index');
|
||||
Route::get('/navigation', [Admin\NavigationController::class, 'index'])->name('navigation.index');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Frontend Routes
|
||||
|
||||
Add to your `routes/web.php` (MUST be at the end):
|
||||
|
||||
```php
|
||||
// SEO routes
|
||||
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
|
||||
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
|
||||
|
||||
// Blog routes (if using blog)
|
||||
Route::prefix('blog')->name('blog.')->group(function () {
|
||||
Route::get('/', [PageController::class, 'blogIndex'])->name('index');
|
||||
Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show');
|
||||
});
|
||||
|
||||
// CMS pages - MUST BE LAST!
|
||||
Route::get('/{slug?}', [PageController::class, 'show'])
|
||||
->where('slug', '.*')
|
||||
->name('cms.page');
|
||||
```
|
||||
|
||||
### 6. Create Controllers
|
||||
|
||||
#### Page Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $slug = '/')
|
||||
{
|
||||
// Get domain key from existing domains config
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$locale = app()->getLocale();
|
||||
|
||||
// Find page
|
||||
$page = Page::forDomain($domainKey)
|
||||
->bySlug($slug, $locale)
|
||||
->published()
|
||||
->firstOrFail();
|
||||
|
||||
// Load components
|
||||
$components = $page->components()->get();
|
||||
|
||||
return view('pages.show', compact('page', 'components'));
|
||||
}
|
||||
|
||||
public function sitemap(Request $request)
|
||||
{
|
||||
$domainKey = $this->getCurrentDomainKey($request);
|
||||
$pages = Page::forDomain($domainKey)->published()->get();
|
||||
|
||||
return response()->view('sitemap', compact('pages'))
|
||||
->header('Content-Type', 'application/xml');
|
||||
}
|
||||
|
||||
protected function getCurrentDomainKey(Request $request): string
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $key => $config) {
|
||||
if (isset($config['url']) && parse_url($config['url'], PHP_URL_HOST) === $host) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return config('flux-cms.domains.default_domain', 'default');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Admin Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use FluxCms\Core\Models\Page;
|
||||
|
||||
class CmsController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('can:flux-cms.view');
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'pages' => Page::count(),
|
||||
'published_pages' => Page::published()->count(),
|
||||
'draft_pages' => Page::where('is_published', false)->count(),
|
||||
];
|
||||
|
||||
return view('admin.cms.index', compact('stats'));
|
||||
}
|
||||
|
||||
public function pages()
|
||||
{
|
||||
$pages = Page::with(['components'])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.cms.pages', compact('pages'));
|
||||
}
|
||||
|
||||
public function editPage(Page $page)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
|
||||
return view('admin.cms.edit-page', compact('page'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Create Views
|
||||
|
||||
#### Frontend Page Template
|
||||
|
||||
```blade
|
||||
{{-- resources/views/pages/show.blade.php --}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $page->getSeoTitle())
|
||||
@section('description', $page->getSeoDescription())
|
||||
|
||||
@push('meta')
|
||||
<meta property="og:title" content="{{ $page->getTranslation('title') }}">
|
||||
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
|
||||
<meta property="og:url" content="{{ request()->url() }}">
|
||||
@if($page->getTranslation('og_image'))
|
||||
<meta property="og:image" content="{{ $page->getTranslation('og_image') }}">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<main class="cms-page">
|
||||
@foreach($components as $component)
|
||||
@if($component->canRender())
|
||||
<div class="cms-component" data-component="{{ class_basename($component->component_class) }}">
|
||||
@livewire($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
], key('component-' . $component->id))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</main>
|
||||
@endsection
|
||||
```
|
||||
|
||||
#### Admin Views
|
||||
|
||||
```blade
|
||||
{{-- resources/views/admin/cms/edit-page.blade.php --}}
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Edit Page: ' . $page->getTranslation('title'))
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@livewire('flux-cms::page-editor', ['page' => $page])
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Enable drag & drop sorting
|
||||
Livewire.on('components-reordered', (orderedIds) => {
|
||||
// Handle reordering feedback
|
||||
console.log('Components reordered:', orderedIds);
|
||||
});
|
||||
|
||||
// Preview functionality
|
||||
Livewire.on('open-preview', (url) => {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
```
|
||||
|
||||
### 8. Set Up Permissions
|
||||
|
||||
If you're using Spatie Laravel Permission:
|
||||
|
||||
```php
|
||||
// database/seeders/CmsPermissionSeeder.php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class CmsPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
// Create permissions
|
||||
$permissions = [
|
||||
'flux-cms.view',
|
||||
'flux-cms.edit',
|
||||
'flux-cms.publish',
|
||||
'flux-cms.delete',
|
||||
'flux-cms.admin',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
Permission::firstOrCreate(['name' => $permission]);
|
||||
}
|
||||
|
||||
// Create CMS role
|
||||
$cmsRole = Role::firstOrCreate(['name' => 'flux-cms']);
|
||||
$cmsRole->syncPermissions($permissions);
|
||||
|
||||
// Assign to users
|
||||
// User::find(1)->assignRole('flux-cms');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the seeder:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=CmsPermissionSeeder
|
||||
```
|
||||
|
||||
### 9. Configure Multi-Domain (Optional)
|
||||
|
||||
If you're using multi-domain setup, ensure your `config/domains.php` is configured:
|
||||
|
||||
```php
|
||||
// config/domains.php
|
||||
return [
|
||||
'domains' => [
|
||||
'main' => [
|
||||
'name' => 'Main Site',
|
||||
'url' => env('APP_URL', 'https://example.com'),
|
||||
'theme' => 'default',
|
||||
],
|
||||
'blog' => [
|
||||
'name' => 'Blog Site',
|
||||
'url' => 'https://blog.example.com',
|
||||
'theme' => 'blog',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Update Flux CMS config:
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
'domains' => [
|
||||
'enabled' => true,
|
||||
'config_source' => 'domains', // Use domains.php config
|
||||
'auto_detect' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### 10. Create Your First Component
|
||||
|
||||
Generate a new component:
|
||||
|
||||
```bash
|
||||
php artisan make:livewire Components/WelcomeHero
|
||||
```
|
||||
|
||||
Update the component:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\WysiwygField;
|
||||
|
||||
class WelcomeHero extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(array $content = [])
|
||||
{
|
||||
$this->content = $content;
|
||||
}
|
||||
|
||||
public static function getCmsName(): string
|
||||
{
|
||||
return 'Welcome Hero';
|
||||
}
|
||||
|
||||
public static function getCmsCategory(): string
|
||||
{
|
||||
return 'Content';
|
||||
}
|
||||
|
||||
public static function getCmsFields(): array
|
||||
{
|
||||
return [
|
||||
TextField::make('headline', 'Headline')
|
||||
->translatable()
|
||||
->required(),
|
||||
|
||||
WysiwygField::make('description', 'Description')
|
||||
->translatable(),
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.components.welcome-hero');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the view:
|
||||
|
||||
```blade
|
||||
{{-- resources/views/livewire/components/welcome-hero.blade.php --}}
|
||||
<section class="hero bg-gradient-to-r from-blue-500 to-purple-600 text-white py-20">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
@if($headline = $this->content['headline'][app()->getLocale()] ?? '')
|
||||
<h1 class="text-5xl font-bold mb-6">{{ $headline }}</h1>
|
||||
@endif
|
||||
|
||||
@if($description = $this->content['description'][app()->getLocale()] ?? '')
|
||||
<div class="text-xl prose prose-lg prose-invert mx-auto">
|
||||
{!! $description !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
### 11. Create Your First Page
|
||||
|
||||
```php
|
||||
// database/seeders/CmsContentSeeder.php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use FluxCms\Core\Models\Page;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$homepage = Page::create([
|
||||
'domain_key' => 'main',
|
||||
'title' => [
|
||||
'de' => 'Startseite',
|
||||
'en' => 'Homepage'
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/',
|
||||
'en' => '/'
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Willkommen auf unserer Website',
|
||||
'en' => 'Welcome to our website'
|
||||
],
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
// Add welcome hero component
|
||||
$homepage->allComponents()->create([
|
||||
'component_class' => \App\Livewire\Components\WelcomeHero::class,
|
||||
'order' => 1,
|
||||
'content' => [
|
||||
'headline' => [
|
||||
'de' => 'Willkommen bei Flux CMS',
|
||||
'en' => 'Welcome to Flux CMS'
|
||||
],
|
||||
'description' => [
|
||||
'de' => 'Das moderne Content Management System für Laravel.',
|
||||
'en' => 'The modern Content Management System for Laravel.'
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run the seeder:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=CmsContentSeeder
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, verify everything works:
|
||||
|
||||
1. **Visit admin interface**: `/admin/cms`
|
||||
2. **Edit a page**: Click on your homepage
|
||||
3. **Add components**: Try adding components to your page
|
||||
4. **Check frontend**: Visit your homepage
|
||||
5. **Test translations**: Switch languages if configured
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Component Registry Empty
|
||||
|
||||
```bash
|
||||
# Clear cache and refresh
|
||||
php artisan flux-cms:clear-cache
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
#### 2. Permission Denied
|
||||
|
||||
```bash
|
||||
# Check if user has CMS role
|
||||
php artisan tinker
|
||||
> User::find(1)->assignRole('flux-cms');
|
||||
```
|
||||
|
||||
#### 3. Media Files Not Loading
|
||||
|
||||
```bash
|
||||
# Ensure storage link exists
|
||||
php artisan storage:link
|
||||
|
||||
# Check disk configuration
|
||||
php artisan tinker
|
||||
> Storage::disk('public')->exists('test.txt');
|
||||
```
|
||||
|
||||
#### 4. Routes Not Working
|
||||
|
||||
- Ensure CMS routes are at the **end** of `routes/web.php`
|
||||
- Check middleware and permissions
|
||||
- Verify domain configuration if using multi-domain
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug mode in config:
|
||||
|
||||
```php
|
||||
// config/flux-cms.php
|
||||
'development' => [
|
||||
'debug_mode' => true,
|
||||
'show_component_info' => true,
|
||||
'log_queries' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
- 📖 **Documentation**: Check the full documentation
|
||||
- 💬 **Community**: Join GitHub Discussions
|
||||
- 🐛 **Issues**: Report bugs on GitHub
|
||||
- 📧 **Support**: Contact support team
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create custom components** for your specific needs
|
||||
2. **Set up navigation** using the navigation manager
|
||||
3. **Configure domains** if using multi-domain
|
||||
4. **Customize styling** to match your brand
|
||||
5. **Set up deployment** with proper caching
|
||||
|
||||
You're now ready to start building amazing content with Flux CMS! 🚀
|
||||
- **[README.md](./README.md)** — Überblick, Features, alle Modelle, Helper-Funktionen
|
||||
- **[SETUP.md](./SETUP.md)** — Schritt-für-Schritt Installationsanleitung (Erstinstallation)
|
||||
- **[MIGRATION.md](./MIGRATION.md)** — Migration in ein neues Projekt (Checkliste)
|
||||
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — Technische Details, Datenbankschema, Datenfluss
|
||||
- **[README-FILE-UPLOAD.md](./README-FILE-UPLOAD.md)** — Medienbibliothek und Bildoptimierung
|
||||
|
|
|
|||
264
packages/flux-cms/MIGRATION.md
Normal file
264
packages/flux-cms/MIGRATION.md
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
# Flux CMS — Migration in ein neues Projekt
|
||||
|
||||
Diese Checkliste führt dich durch alle Schritte, um Flux CMS aus einem bestehenden Projekt in ein neues Laravel-Projekt zu migrieren.
|
||||
|
||||
> **Voraussetzungen:** Das neue Projekt benötigt Laravel 11+, Livewire 3, Volt, Flux UI (Free oder Pro) und Tailwind CSS v4.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1 — Package-Verzeichnis kopieren
|
||||
|
||||
```bash
|
||||
# Das gesamte Package-Verzeichnis ins neue Projekt kopieren
|
||||
cp -r altes-projekt/package/ neues-projekt/package/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 2 — composer.json anpassen
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{ "type": "path", "url": "package/flux-cms/core" }
|
||||
],
|
||||
"require": {
|
||||
"flux-cms/core": "@dev",
|
||||
"intervention/image": "^3.0",
|
||||
"blade-ui-kit/blade-heroicons": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": ["app/helpers.php"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
composer update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 3 — Konfiguration und Migrations
|
||||
|
||||
```bash
|
||||
# Config publizieren
|
||||
php artisan vendor:publish --tag=flux-cms-config
|
||||
|
||||
# Migrations ausführen (erstellt alle flux_cms_* Tabellen)
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
**Hinweis:** Falls das Quell-Projekt zusätzliche Migrations enthält (z.B. `add_detail_columns_to_flux_cms_downloads_table`), diese ebenfalls kopieren:
|
||||
|
||||
```bash
|
||||
cp altes-projekt/database/migrations/*flux_cms*.php neues-projekt/database/migrations/
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 4 — Helper-Funktionen einrichten
|
||||
|
||||
Datei `app/helpers.php` erstellen (vollständiger Inhalt in [SETUP.md](SETUP.md#21-datei-erstellen)).
|
||||
|
||||
```bash
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 5 — Livewire-Komponenten kopieren
|
||||
|
||||
```bash
|
||||
mkdir -p app/Livewire/Admin/Cms
|
||||
|
||||
# Aus Package-Referenz kopieren
|
||||
cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php app/Livewire/Admin/Cms/
|
||||
cp package/flux-cms/core/src/Helpers/MediaPicker.php app/Livewire/Admin/Cms/
|
||||
cp package/flux-cms/core/src/Helpers/MediaUploader.php app/Livewire/Admin/Cms/
|
||||
```
|
||||
|
||||
Namespace in allen drei Dateien anpassen:
|
||||
```php
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 6 — Admin-Views einrichten
|
||||
|
||||
```bash
|
||||
mkdir -p resources/views/livewire/admin/cms
|
||||
mkdir -p resources/views/components/layouts
|
||||
|
||||
# Alle Admin-Views kopieren
|
||||
cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \
|
||||
resources/views/livewire/admin/cms/
|
||||
|
||||
# Admin-Layout kopieren
|
||||
cp package/flux-cms/core/resources/views/admin-reference/layout-cms.blade.php \
|
||||
resources/views/components/layouts/cms.blade.php
|
||||
```
|
||||
|
||||
**Anpassen:**
|
||||
- `resources/views/components/layouts/cms.blade.php`: Branding/Logo anpassen
|
||||
- `route('dashboard')` im Layout ggf. anpassen (Redirect nach Login)
|
||||
|
||||
---
|
||||
|
||||
## Schritt 7 — Routes registrieren
|
||||
|
||||
In `routes/web.php`:
|
||||
|
||||
```php
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Volt::route('admin/cms', 'admin.cms.dashboard')->name('cms.dashboard');
|
||||
Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index');
|
||||
Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index');
|
||||
Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index');
|
||||
Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index');
|
||||
Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index');
|
||||
Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index');
|
||||
Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index');
|
||||
Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index');
|
||||
Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 8 — Storage-Link erstellen
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 9 — Seeders kopieren (optional)
|
||||
|
||||
Falls Inhalte aus dem alten Projekt übernommen werden sollen:
|
||||
|
||||
```bash
|
||||
# Referenz-Seeders als Ausgangspunkt
|
||||
cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/
|
||||
```
|
||||
|
||||
Die Seeders müssen für das neue Projekt angepasst werden (andere Inhalte, andere Bilder).
|
||||
Ausführungsreihenfolge in `DatabaseSeeder`:
|
||||
|
||||
```php
|
||||
$this->call([
|
||||
CmsContentSeeder::class,
|
||||
CmsMediaSeeder::class,
|
||||
CmsNewsItemSeeder::class,
|
||||
CmsIndustrySeeder::class,
|
||||
CmsFaqSeeder::class,
|
||||
CmsLinkedinPostSeeder::class,
|
||||
CmsDownloadSeeder::class,
|
||||
CmsSearchIndexSeeder::class,
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 10 — Medien migrieren (optional)
|
||||
|
||||
Falls Medien aus dem alten Projekt übernommen werden sollen:
|
||||
|
||||
```bash
|
||||
# Storage-Verzeichnis kopieren
|
||||
cp -r altes-projekt/storage/app/public/cms/ neues-projekt/storage/app/public/cms/
|
||||
```
|
||||
|
||||
Dann den `CmsMediaSeeder` ausführen, um die DB-Einträge wiederherzustellen:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=CmsMediaSeeder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 11 — Vite Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
# oder für Entwicklung:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 12 — Tests einrichten (optional)
|
||||
|
||||
```bash
|
||||
# Referenz-Tests kopieren
|
||||
cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms
|
||||
|
||||
# Tests ausführen
|
||||
php artisan test --filter=Cms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkliste
|
||||
|
||||
- [ ] Package-Verzeichnis kopiert (`package/flux-cms/`)
|
||||
- [ ] `composer.json` angepasst (Repository, Require, Autoload)
|
||||
- [ ] `composer update` ausgeführt
|
||||
- [ ] Config publiziert (`vendor:publish --tag=flux-cms-config`)
|
||||
- [ ] Migrations ausgeführt (inkl. projektspezifische)
|
||||
- [ ] `app/helpers.php` erstellt
|
||||
- [ ] `composer dump-autoload` ausgeführt
|
||||
- [ ] Livewire-Komponenten kopiert + Namespace angepasst
|
||||
- [ ] Admin-Views kopiert
|
||||
- [ ] Layout angepasst (Branding, Route-Namen)
|
||||
- [ ] Routes registriert
|
||||
- [ ] `storage:link` ausgeführt
|
||||
- [ ] Seeders angepasst und ausgeführt (optional)
|
||||
- [ ] Medien migriert (optional)
|
||||
- [ ] Vite Build ausgeführt
|
||||
- [ ] Admin-Login testen: `/admin/cms`
|
||||
|
||||
---
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
### "View not found" für `livewire.admin.cms.*`
|
||||
→ Prüfen ob Volt-Mount-Path `resources/views/livewire/` in `VoltServiceProvider` registriert ist.
|
||||
|
||||
### Bilder werden nicht angezeigt
|
||||
→ `php artisan storage:link` ausführen. Prüfen ob `APP_URL` in `.env` korrekt gesetzt ist.
|
||||
|
||||
### "Route [cms.dashboard] not defined"
|
||||
→ Sicherstellen dass alle CMS-Routes in `routes/web.php` registriert sind.
|
||||
|
||||
### Bilder hinter HTTPS-Proxy haben falsche URLs
|
||||
→ In `bootstrap/app.php`:
|
||||
```php
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->trustProxies(at: '*');
|
||||
})
|
||||
```
|
||||
Und in `AppServiceProvider::boot()`:
|
||||
```php
|
||||
if (request()->header('X-Forwarded-Proto') === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
```
|
||||
|
||||
### Flux UI `<flux:toast />` fehlt
|
||||
→ Das Layout `resources/views/components/layouts/cms.blade.php` muss `<flux:toast />` enthalten (bereits im Referenz-Layout vorhanden).
|
||||
|
||||
---
|
||||
|
||||
## Weiterführende Dokumentation
|
||||
|
||||
- [README.md](README.md) — Überblick, alle Features, Helper-Funktionen
|
||||
- [SETUP.md](SETUP.md) — Detaillierte Schritt-für-Schritt-Anleitung
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — Datenbankschema, Datenfluss, Komponenten-Architektur
|
||||
- [README-FILE-UPLOAD.md](README-FILE-UPLOAD.md) — Medienbibliothek im Detail
|
||||
458
packages/flux-cms/README-FILE-UPLOAD.md
Normal file
458
packages/flux-cms/README-FILE-UPLOAD.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# File Upload mit Livewire Volt + Flux UI
|
||||
|
||||
Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist.
|
||||
|
||||
---
|
||||
|
||||
## Überblick
|
||||
|
||||
Der Upload nutzt:
|
||||
- **Livewire `WithFileUploads`** – verwaltet temporäre Uploads via signierter URL
|
||||
- **Flux UI `flux:file-upload`** – UI-Komponente (Dropzone + Vorschau)
|
||||
- **Laravel `Storage::disk('public')`** – Permanente Speicherung
|
||||
- **Polymorphe `media`-Tabelle** – Zuordnung von Dateien zu beliebigen Models
|
||||
- **Alpine.js** – Drag-&-Drop-Sortierung der vorhandenen Bilder
|
||||
|
||||
---
|
||||
|
||||
## 1. PHP / Livewire Volt – Komponentenlogik
|
||||
|
||||
### Trait einbinden
|
||||
|
||||
```php
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new class extends Component {
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $mainImages = [];
|
||||
```
|
||||
|
||||
`WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs.
|
||||
|
||||
### Validierung
|
||||
|
||||
```php
|
||||
'mainImages' => 'nullable|array|min:0|max:10',
|
||||
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
|
||||
```
|
||||
|
||||
- `mainImages` ist ein **Array** (wegen `multiple`-Upload)
|
||||
- `mainImages.*` validiert jede einzelne Datei
|
||||
- `max:10240` = 10 MB in Kilobyte
|
||||
- Livewire hat intern ein Default-Limit von 12 MB – das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen)
|
||||
|
||||
### Einzelnes Bild entfernen (Vorschauliste)
|
||||
|
||||
```php
|
||||
public function removePhoto(int $index): void
|
||||
{
|
||||
if (isset($this->mainImages[$index])) {
|
||||
unset($this->mainImages[$index]);
|
||||
$this->mainImages = array_values($this->mainImages);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen – sonst bricht `@foreach` mit `$index` im Template.
|
||||
|
||||
### Vorhandenes Bild aus der DB löschen
|
||||
|
||||
```php
|
||||
public function removeExistingMedia(int $mediaId): void
|
||||
{
|
||||
$media = $this->product->media()->find($mediaId);
|
||||
if ($media) {
|
||||
Storage::disk('public')->delete($media->file_path);
|
||||
$media->delete();
|
||||
$this->existingMedia = collect($this->existingMedia)
|
||||
->reject(fn ($m) => $m['id'] === $mediaId)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert.
|
||||
|
||||
### Reihenfolge aktualisieren (Drag & Drop)
|
||||
|
||||
```php
|
||||
public function updateMediaOrder(array $orderedIds): void
|
||||
{
|
||||
foreach ($orderedIds as $position => $mediaId) {
|
||||
$this->product->media()
|
||||
->where('id', $mediaId)
|
||||
->update(['order_column' => $position + 1]);
|
||||
}
|
||||
|
||||
// Lokalen State synchronisieren
|
||||
$this->existingMedia = collect($orderedIds)
|
||||
->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id)
|
||||
? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1])
|
||||
: null
|
||||
)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
```
|
||||
|
||||
### Bilder permanent speichern (Neu-Anlage)
|
||||
|
||||
```php
|
||||
$index = 1;
|
||||
foreach ($this->mainImages as $image) {
|
||||
$path = $image->store('products/' . $product->id, 'public');
|
||||
$product->media()->create([
|
||||
'file_path' => $path,
|
||||
'type' => 'image',
|
||||
'alt_text' => $this->name,
|
||||
'order_column' => $index++,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk.
|
||||
|
||||
### Bilder permanent speichern (Bearbeiten – neue Bilder hinzufügen)
|
||||
|
||||
```php
|
||||
$maxOrder = $this->product->media()->max('order_column') ?? 0;
|
||||
$index = $maxOrder + 1;
|
||||
foreach ($this->mainImages as $image) {
|
||||
$path = $image->store('products/' . $this->product->id, 'public');
|
||||
$this->product->media()->create([
|
||||
'file_path' => $path,
|
||||
'type' => 'image',
|
||||
'alt_text' => $this->name,
|
||||
'order_column' => $index++,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen.
|
||||
|
||||
### Nach dem Speichern zurücksetzen
|
||||
|
||||
```php
|
||||
// Neue Bilder leeren
|
||||
$this->mainImages = [];
|
||||
|
||||
// Vorhandene Bilder aus DB neu laden (mit sortBy)
|
||||
$this->existingMedia = $this->product->fresh()->media
|
||||
->sortBy('order_column')
|
||||
->values()
|
||||
->map(fn ($m) => [
|
||||
'id' => $m->id,
|
||||
'file_path' => $m->file_path,
|
||||
'alt_text' => $m->alt_text,
|
||||
'order_column' => $m->order_column,
|
||||
])
|
||||
->toArray();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Blade / Flux UI – Template
|
||||
|
||||
### Upload-Dropzone
|
||||
|
||||
```blade
|
||||
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Bilder hochladen"
|
||||
text="Nur JPEG oder PNG – max. 10 MB"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
```
|
||||
|
||||
- `wire:model="mainImages"` – bindet an das Array-Property
|
||||
- `multiple` – erlaubt Mehrfachauswahl
|
||||
- `accept` – schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!)
|
||||
- `with-progress` – zeigt Upload-Fortschrittsbalken
|
||||
|
||||
### Vorschauliste der neu hinzugefügten Bilder
|
||||
|
||||
```blade
|
||||
@if (isset($mainImages) && count($mainImages) > 0)
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
@foreach ($mainImages as $index => $image)
|
||||
<flux:file-item
|
||||
:heading="$image->getClientOriginalName()"
|
||||
:image="(str_starts_with($image->getMimeType() ?? '', 'image/') && $image->isPreviewable())
|
||||
? $image->temporaryUrl()
|
||||
: null"
|
||||
:size="$image->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove
|
||||
wire:click="removePhoto({{ $index }})"
|
||||
aria-label="{{ 'Remove file: ' . $image->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Wichtig bei `temporaryUrl()`:**
|
||||
Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler.
|
||||
|
||||
### Fehleranzeige
|
||||
|
||||
```blade
|
||||
<flux:error name="mainImages" />
|
||||
```
|
||||
|
||||
Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt.").
|
||||
Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet.
|
||||
|
||||
### Drag-&-Drop-Sortierung vorhandener Bilder
|
||||
|
||||
```blade
|
||||
<div x-data="{
|
||||
dragging: null,
|
||||
dragOver: null,
|
||||
items: @js(collect($existingMedia)->pluck('id')->toArray()),
|
||||
onDragStart(e, id) {
|
||||
this.dragging = id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
},
|
||||
onDragOver(e, id) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
this.dragOver = id;
|
||||
},
|
||||
onDrop(e, targetId) {
|
||||
e.preventDefault();
|
||||
if (this.dragging === targetId) { this.dragOver = null; return; }
|
||||
const fromIdx = this.items.indexOf(this.dragging);
|
||||
const toIdx = this.items.indexOf(targetId);
|
||||
this.items.splice(fromIdx, 1);
|
||||
this.items.splice(toIdx, 0, this.dragging);
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
$wire.updateMediaOrder(this.items);
|
||||
},
|
||||
onDragEnd() {
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
}
|
||||
}" class="flex flex-wrap items-start gap-3">
|
||||
|
||||
@foreach ($existingMedia as $mediaIndex => $media)
|
||||
<div wire:key="existing-media-{{ $media['id'] }}"
|
||||
draggable="true"
|
||||
x-on:dragstart="onDragStart($event, {{ $media['id'] }})"
|
||||
x-on:dragover="onDragOver($event, {{ $media['id'] }})"
|
||||
x-on:drop="onDrop($event, {{ $media['id'] }})"
|
||||
x-on:dragend="onDragEnd()"
|
||||
:class="{
|
||||
'opacity-50 scale-95': dragging === {{ $media['id'] }},
|
||||
'ring-2 ring-blue-400 ring-offset-2': dragOver === {{ $media['id'] }} && dragging !== {{ $media['id'] }}
|
||||
}"
|
||||
class="group relative cursor-grab active:cursor-grabbing transition-all duration-150">
|
||||
|
||||
@if ($mediaIndex === 0)
|
||||
<div class="absolute -top-1 -left-1 z-10 bg-blue-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-md shadow">
|
||||
Standard
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<img src="{{ Storage::url($media['file_path']) }}"
|
||||
alt="{{ $media['alt_text'] ?? '' }}"
|
||||
class="h-24 w-24 rounded-lg object-cover border border-zinc-200 dark:border-zinc-700
|
||||
{{ $mediaIndex === 0 ? 'ring-2 ring-blue-500' : '' }}" />
|
||||
|
||||
<flux:button
|
||||
wire:click="removeExistingMedia({{ $media['id'] }})"
|
||||
wire:confirm="Bild wirklich löschen?"
|
||||
variant="filled" size="xs" icon="trash"
|
||||
class="absolute -top-2 -right-2 !bg-red-500 !text-white hover:!bg-red-600" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
```
|
||||
|
||||
**Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank – Media-Tabelle
|
||||
|
||||
```php
|
||||
// Migration: database/migrations/xxxx_create_media_table.php
|
||||
|
||||
Schema::create('media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('model_type'); // z. B. "App\Models\Product"
|
||||
$table->unsignedBigInteger('model_id');
|
||||
$table->string('file_path'); // relativer Pfad auf dem public-Disk
|
||||
$table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model'
|
||||
$table->string('alt_text')->nullable();
|
||||
$table->integer('order_column')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['model_type', 'model_id']);
|
||||
});
|
||||
```
|
||||
|
||||
### Media-Model (`app/Models/Media.php`)
|
||||
|
||||
```php
|
||||
class Media extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'model_type', 'model_id', 'file_path', 'type', 'alt_text', 'order_column',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['order_column' => 'integer'];
|
||||
}
|
||||
|
||||
/** Polymorphe Beziehung zum Eltern-Model */
|
||||
public function model(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Beziehung im Parent-Model (`app/Models/Product.php`)
|
||||
|
||||
```php
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
public function media(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Media::class, 'model');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Filesystem-Konfiguration (`config/filesystems.php`)
|
||||
|
||||
```php
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL') . '/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
],
|
||||
```
|
||||
|
||||
### Symlink anlegen
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
Erstellt `public/storage` → `storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Kritische System-Anpassungen
|
||||
|
||||
### 5a. `bootstrap/app.php` – Reverse-Proxy / HTTPS
|
||||
|
||||
```php
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen
|
||||
// ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy
|
||||
$middleware->trustProxies(at: '*');
|
||||
})
|
||||
```
|
||||
|
||||
**Warum?**
|
||||
Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` – die Signatur stimmt nicht, Upload schlägt fehl mit `403`.
|
||||
|
||||
### 5b. `app/Providers/AppServiceProvider.php` – Schema erzwingen
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
// X-Forwarded-Proto auswerten und Schema erzwingen
|
||||
// Nötig für Livewire Upload-URLs hinter Traefik
|
||||
$scheme = request()->header('X-Forwarded-Proto')
|
||||
?? request()->server('HTTP_X_FORWARDED_PROTO')
|
||||
?? (request()->secure() ? 'https' : 'http');
|
||||
|
||||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Warum zusätzlich zum `trustProxies`?**
|
||||
`trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben.
|
||||
|
||||
**Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console – besonders frustrierend, weil kein PHP-Fehler erscheint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Livewire-Konfiguration (`config/livewire.php`)
|
||||
|
||||
```php
|
||||
'temporary_file_upload' => [
|
||||
'disk' => null, // null = default-Disk (meist 'local')
|
||||
'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default)
|
||||
'directory' => null, // null = 'livewire-tmp'
|
||||
'middleware' => null, // null = 'throttle:60,1'
|
||||
'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl()
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp',
|
||||
'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ...
|
||||
],
|
||||
'max_upload_time' => 5, // Minuten bis Upload ungültig wird
|
||||
'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen
|
||||
],
|
||||
```
|
||||
|
||||
**Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden.
|
||||
|
||||
---
|
||||
|
||||
## 7. Checkliste für ein neues Projekt
|
||||
|
||||
| Schritt | Was | Wo |
|
||||
|---------|-----|----|
|
||||
| ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse |
|
||||
| ✅ | `public array $images = []` Property anlegen | Komponentenklasse |
|
||||
| ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode |
|
||||
| ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode |
|
||||
| ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode |
|
||||
| ✅ | `php artisan storage:link` ausführen | Terminal / Deploy |
|
||||
| ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` |
|
||||
| ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` |
|
||||
| ✅ | `wire:key` in Foreach-Schleifen | Blade-Template |
|
||||
| ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` |
|
||||
| ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template |
|
||||
|
||||
---
|
||||
|
||||
## 8. Häufige Fallstricke
|
||||
|
||||
### Upload schlägt fehl mit 403 (Signature mismatch)
|
||||
→ Reverse-Proxy-Problem. Siehe Punkt 5a und 5b.
|
||||
|
||||
### Vorschau-Thumbnail zeigt nichts an
|
||||
→ `isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen.
|
||||
|
||||
### Nach `removePhoto()` stimmen die Indizes nicht
|
||||
→ `array_values()` vergessen. Livewire sendet den Index als Parameter – ohne Reindizierung kommt es zu Off-by-One-Fehlern.
|
||||
|
||||
### Upload-Limit-Fehler vor der Validierung
|
||||
→ PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten.
|
||||
|
||||
### `temporaryUrl()` wirft eine Exception
|
||||
→ Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern.
|
||||
|
||||
### Bilder nach Deploy nicht sichtbar
|
||||
→ `php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert.
|
||||
File diff suppressed because it is too large
Load diff
493
packages/flux-cms/SETUP.md
Normal file
493
packages/flux-cms/SETUP.md
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
# Flux CMS — Schritt-für-Schritt Setup-Anleitung
|
||||
|
||||
Diese Anleitung beschreibt die Integration von Flux CMS in ein neues Laravel-Projekt.
|
||||
|
||||
> **Migrierst du ein bestehendes Projekt?** Dann nutze stattdessen die kompakte **[MIGRATION.md](MIGRATION.md)** Checkliste.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Laravel 11+ oder 12
|
||||
- Livewire 4 mit Volt
|
||||
- Flux UI (Free oder Pro)
|
||||
- `spatie/laravel-translatable` (wird vom Package mitgebracht)
|
||||
- `intervention/image` v3 (für Bildoptimierung)
|
||||
- Tailwind CSS v4
|
||||
- Heroicons (via `blade-ui-kit/blade-heroicons`)
|
||||
- Mehrsprachige `lang/`-Dateien (DE/EN oder andere)
|
||||
|
||||
---
|
||||
|
||||
## 1. Package installieren
|
||||
|
||||
### 1.1 Repository registrieren
|
||||
|
||||
```json
|
||||
// composer.json
|
||||
{
|
||||
"repositories": [
|
||||
{ "type": "path", "url": "package/flux-cms/core" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Dependencies hinzufügen
|
||||
|
||||
```bash
|
||||
composer require flux-cms/core:@dev
|
||||
composer require intervention/image
|
||||
```
|
||||
|
||||
### 1.3 Konfiguration publizieren
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=flux-cms-config
|
||||
```
|
||||
|
||||
### 1.4 Migrations ausführen
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
Dies erstellt folgende Tabellen:
|
||||
- `flux_cms_contents` — Alle Seiteninhalte (Key-Value mit Übersetzungen)
|
||||
- `flux_cms_news_items` — Nachrichteneinträge
|
||||
- `flux_cms_industries` — Branchen-Band
|
||||
- `flux_cms_faqs` — FAQ-Einträge
|
||||
- `flux_cms_downloads` — Downloads (PDFs, etc.)
|
||||
- `flux_cms_linkedin_posts` — LinkedIn-Posts
|
||||
- `flux_cms_media` — Medienbibliothek (Bilder, PDFs)
|
||||
|
||||
**Hinweis:** Je nach Projekt-Erweiterung können zusätzliche Migrations nötig sein (z.B. für erweiterte Downloads mit Highlights/Checkpoints).
|
||||
|
||||
---
|
||||
|
||||
## 2. Helper-Funktionen einrichten
|
||||
|
||||
### 2.1 Datei erstellen
|
||||
|
||||
Erstelle `app/helpers.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
if (! function_exists('cms')) {
|
||||
function cms(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
return app(CmsContentService::class)->get($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tcms')) {
|
||||
function tcms(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
$text = cms($key, $replace, $locale);
|
||||
return is_string($text) ? $text : (string) $text;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms_media_url')) {
|
||||
/**
|
||||
* Resolve a CmsContent key (type=image) to a full media URL.
|
||||
* Falls back to asset('assets/images/...') if not found in media library.
|
||||
*/
|
||||
function cms_media_url(string $key, string $profile = ''): string
|
||||
{
|
||||
$filename = cms($key);
|
||||
if (! $filename || ! is_string($filename) || $filename === $key) {
|
||||
$fallback = str_replace('.', '/', $key);
|
||||
return asset('assets/images/' . basename($fallback));
|
||||
}
|
||||
return media_url($filename, $profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('media_url')) {
|
||||
/**
|
||||
* Resolve a CmsMedia filename to its full storage URL.
|
||||
* Uses in-memory cache to avoid repeated DB queries.
|
||||
*/
|
||||
function media_url(?string $filename, string $profile = ''): string
|
||||
{
|
||||
if (! $filename || $filename === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
static $resolved = [];
|
||||
$cacheKey = $filename . '|' . $profile;
|
||||
|
||||
if (isset($resolved[$cacheKey])) {
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
|
||||
$media = CmsMedia::where('filename', $filename)->first();
|
||||
if (! $media) {
|
||||
$resolved[$cacheKey] = asset('assets/images/' . $filename);
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
|
||||
if ($profile && $media->hasConversion($profile)) {
|
||||
$resolved[$cacheKey] = $media->getConversionUrl($profile);
|
||||
} else {
|
||||
$resolved[$cacheKey] = $media->getUrl();
|
||||
}
|
||||
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Autoload registrieren
|
||||
|
||||
```json
|
||||
// composer.json → autoload
|
||||
{
|
||||
"autoload": {
|
||||
"files": ["app/helpers.php"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Admin-Oberfläche einrichten
|
||||
|
||||
### 3.1 Layout erstellen
|
||||
|
||||
Kopiere `core/resources/views/admin-reference/layout-cms.blade.php` nach `resources/views/components/layouts/cms.blade.php`.
|
||||
|
||||
Passe die Sidebar-Navigation an:
|
||||
- Branding/Logo
|
||||
- Navigationspunkte: Dashboard, Inhalte, Medienbibliothek, News, Industries, Downloads, Team, FAQs, LinkedIn
|
||||
- Benutzer-Menü
|
||||
|
||||
**Wichtig:** Das Layout muss `<flux:toast />` enthalten für die Benachrichtigungen.
|
||||
|
||||
### 3.2 Admin Views kopieren
|
||||
|
||||
```bash
|
||||
mkdir -p resources/views/livewire/admin/cms/
|
||||
|
||||
cp package/flux-cms/core/resources/views/admin-reference/cms/*.blade.php \
|
||||
resources/views/livewire/admin/cms/
|
||||
```
|
||||
|
||||
| View | Datei | Beschreibung |
|
||||
|------|-------|-------------|
|
||||
| Dashboard | `dashboard-index.blade.php` | Übersicht mit Statistiken |
|
||||
| Inhalte | `content-index.blade.php` | Haupteditor (Text/HTML/Image/JSON) |
|
||||
| Medienbibliothek | `media-index.blade.php` | Zentrale Medienverwaltung (Grid+Liste) |
|
||||
| News | `news-index.blade.php` | CRUD mit MediaPicker für Bild + PDF |
|
||||
| Industries | `industries-index.blade.php` | CRUD mit Sortierung |
|
||||
| FAQs | `faqs-index.blade.php` | CRUD nach Kategorien |
|
||||
| LinkedIn | `linkedin-index.blade.php` | CRUD mit MediaPicker |
|
||||
| Downloads | `downloads-index.blade.php` | CRUD für Case Studies/Capabilities/Stories |
|
||||
| Team | `team-index.blade.php` | CRUD mit MediaPicker für Profilbilder |
|
||||
| Suchindex | `search-index.blade.php` | Seitensuche: Keywords, Kategorien, Vorschau |
|
||||
|
||||
### 3.3 Livewire-Komponenten einrichten
|
||||
|
||||
Funktionale Volt-Komponenten können kein `WithFileUploads` verwenden. Daher gibt es class-based Livewire-Komponenten:
|
||||
|
||||
```bash
|
||||
# Multi-File Upload
|
||||
cp package/flux-cms/core/src/Helpers/MediaLibraryUploader.php \
|
||||
app/Livewire/Admin/Cms/MediaLibraryUploader.php
|
||||
|
||||
# Medienauswahl-Modal
|
||||
cp package/flux-cms/core/src/Helpers/MediaPicker.php \
|
||||
app/Livewire/Admin/Cms/MediaPicker.php
|
||||
```
|
||||
|
||||
Namespace in beiden Dateien anpassen:
|
||||
```php
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
```
|
||||
|
||||
### 3.4 Blade-Views für Livewire-Komponenten
|
||||
|
||||
```bash
|
||||
# MediaLibraryUploader View
|
||||
cp package/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php \
|
||||
resources/views/livewire/admin/cms/media-library-uploader.blade.php
|
||||
|
||||
# MediaPicker View
|
||||
cp package/flux-cms/core/resources/views/admin-reference/cms/media-picker.blade.php \
|
||||
resources/views/livewire/admin/cms/media-picker.blade.php
|
||||
```
|
||||
|
||||
### 3.5 Routes registrieren
|
||||
|
||||
```php
|
||||
// routes/web.php
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Volt::route('admin/cms', 'admin.cms.dashboard-index')->name('cms.dashboard');
|
||||
Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index');
|
||||
Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index');
|
||||
Volt::route('admin/cms/news', 'admin.cms.news-index')->name('cms.news.index');
|
||||
Volt::route('admin/cms/industries', 'admin.cms.industries-index')->name('cms.industries.index');
|
||||
Volt::route('admin/cms/faqs', 'admin.cms.faqs-index')->name('cms.faqs.index');
|
||||
Volt::route('admin/cms/linkedin', 'admin.cms.linkedin-index')->name('cms.linkedin.index');
|
||||
Volt::route('admin/cms/downloads', 'admin.cms.downloads-index')->name('cms.downloads.index');
|
||||
Volt::route('admin/cms/team', 'admin.cms.team-index')->name('cms.team.index');
|
||||
Volt::route('admin/cms/search-index', 'admin.cms.search-index')->name('cms.search-index');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Medienbibliothek einrichten
|
||||
|
||||
### 4.1 Storage-Link
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
### 4.2 Storage-Verzeichnisse
|
||||
|
||||
Die Verzeichnisse werden automatisch erstellt beim ersten Upload:
|
||||
- `storage/app/public/cms/media/originals/` — Original-Uploads
|
||||
- `storage/app/public/cms/media/conversions/` — Generierte Bildgrößen
|
||||
- `storage/app/public/cms/media/thumbnails/` — Auto-Thumbnails
|
||||
|
||||
### 4.3 Bildprofile konfigurieren
|
||||
|
||||
In `config/flux-cms.php` die Conversion-Profile an dein Projekt anpassen:
|
||||
|
||||
```php
|
||||
'media' => [
|
||||
'max_upload_size' => 20480, // KB
|
||||
'allowed_types' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'pdf', 'doc', 'docx'],
|
||||
'storage_disk' => 'public',
|
||||
'profiles' => [
|
||||
'hero' => ['width' => 1920, 'height' => 800, 'format' => 'webp', 'quality' => 85],
|
||||
'card' => ['width' => 768, 'height' => 512, 'format' => 'webp', 'quality' => 80],
|
||||
'thumbnail' => ['width' => 400, 'height' => 300, 'format' => 'webp', 'quality' => 75],
|
||||
'avatar' => ['width' => 400, 'height' => 400, 'format' => 'webp', 'quality' => 80],
|
||||
'news' => ['width' => 1200, 'height' => 630, 'format' => 'webp', 'quality' => 80],
|
||||
'thumb' => ['width' => 200, 'height' => 200, 'format' => 'webp', 'quality' => 70],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### 4.4 HTTPS / Proxy
|
||||
|
||||
Falls hinter einem Reverse Proxy, in `bootstrap/app.php`:
|
||||
|
||||
```php
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->trustProxies(at: '*');
|
||||
})
|
||||
```
|
||||
|
||||
Und in `AppServiceProvider::boot()`:
|
||||
|
||||
```php
|
||||
if (request()->header('X-Forwarded-Proto') === 'https' || app()->environment('production')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Inhalte importieren (Seeder)
|
||||
|
||||
### 5.1 Seeders kopieren & anpassen
|
||||
|
||||
```bash
|
||||
cp package/flux-cms/core/database/seeders-reference/*.php database/seeders/
|
||||
```
|
||||
|
||||
### 5.2 CmsContentSeeder anpassen
|
||||
|
||||
```php
|
||||
protected array $skipFiles = [
|
||||
'faqs', 'sections', 'validation', 'auth', 'passwords', 'pagination',
|
||||
];
|
||||
|
||||
protected array $skipKeys = [
|
||||
'components' => ['news_band', 'industries_band'],
|
||||
];
|
||||
```
|
||||
|
||||
### 5.3 CmsMediaSeeder erstellen
|
||||
|
||||
Erstelle einen Seeder, der alle hochgeladenen Medien und die `CmsContent`-Einträge vom Typ `image` wiederherstellt. Dieser wird nach dem `CmsContentSeeder` ausgeführt, um Image-Keys zu erzeugen (z.B. `welcome.hero.image` → Dateiname).
|
||||
|
||||
### 5.4 CmsDownloadSeeder erstellen (optional)
|
||||
|
||||
Falls du Downloads (Case Studies, Capabilities, Success Stories) verwendest, erstelle einen Seeder mit den vollständigen Inline-Daten.
|
||||
|
||||
### 5.5 DatabaseSeeder registrieren
|
||||
|
||||
```php
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
CmsContentSeeder::class,
|
||||
CmsMediaSeeder::class,
|
||||
CmsNewsItemSeeder::class,
|
||||
CmsIndustrySeeder::class,
|
||||
CmsFaqSeeder::class,
|
||||
CmsLinkedinPostSeeder::class,
|
||||
CmsDownloadSeeder::class,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Seeding ausführen
|
||||
|
||||
```bash
|
||||
php artisan db:seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend umstellen
|
||||
|
||||
### 6.1 `__()` durch `cms()` ersetzen
|
||||
|
||||
```diff
|
||||
- {{ __('welcome.hero.heading') }}
|
||||
+ {{ cms('welcome.hero.heading') }}
|
||||
```
|
||||
|
||||
### 6.2 Bilder über Medienbibliothek laden
|
||||
|
||||
```diff
|
||||
- <img src="{{ asset('assets/images/keyvisual.webp') }}" />
|
||||
+ <img src="{{ cms_media_url('welcome.hero.image', 'hero') }}" />
|
||||
```
|
||||
|
||||
Oder direkt über Dateiname:
|
||||
```blade
|
||||
<img src="{{ media_url($item['image'], 'card') }}" />
|
||||
```
|
||||
|
||||
### 6.3 Downloads über CmsDownload
|
||||
|
||||
```blade
|
||||
@foreach (CmsDownload::published()->byCategory('case_study')->ordered()->get() as $dl)
|
||||
<x-download-article-card :article="$dl->toFrontendArray()" :index="$loop->index" />
|
||||
@endforeach
|
||||
```
|
||||
|
||||
### 6.4 News über CmsNewsItem
|
||||
|
||||
```blade
|
||||
@php
|
||||
$items = CmsNewsItem::published()->ordered()->get()
|
||||
->map(fn($i) => $i->toFrontendArray())->toArray();
|
||||
@endphp
|
||||
```
|
||||
|
||||
### 6.5 :highlight Pattern
|
||||
|
||||
```blade
|
||||
{!! cms('welcome.solutions.heading', [
|
||||
'highlight' => '<span class="text-gradient-premium">'
|
||||
. cms('welcome.solutions.heading_highlight') . '</span>',
|
||||
]) !!}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests einrichten
|
||||
|
||||
### 7.1 Test-Dateien kopieren
|
||||
|
||||
```bash
|
||||
cp -r package/flux-cms/core/tests-reference/Feature/Cms tests/Feature/Cms
|
||||
```
|
||||
|
||||
### 7.2 Tests ausführen
|
||||
|
||||
```bash
|
||||
php artisan test --filter=Cms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Anpassungen für dein Projekt
|
||||
|
||||
### Sprachen hinzufügen
|
||||
|
||||
`config/flux-cms.php`:
|
||||
```php
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
'fr' => 'Français',
|
||||
],
|
||||
```
|
||||
|
||||
### Neue Content-Typen
|
||||
|
||||
1. Migration + Model mit `HasTranslations`
|
||||
2. Admin-View als Volt-Komponente
|
||||
3. Route registrieren, Sidebar-Link hinzufügen
|
||||
4. `toFrontendArray()` Methode für Frontend-Integration
|
||||
5. Seeder erstellen
|
||||
|
||||
### Editor-Toolbar
|
||||
|
||||
- **Standard** (`bold italic`): Normale Texte
|
||||
- **Voll** (`heading | bold italic underline strike | bullet ordered blockquote | link`): Impressum, Datenschutz, News-Body
|
||||
|
||||
### Icon-Auswahl (Performance)
|
||||
|
||||
Die Icon-Auswahl nutzt Blade Heroicons. Nur der Name wird im Dropdown angezeigt, das SVG nur als Vorschau neben dem Select-Feld — damit werden nicht 2800+ SVGs gerendert.
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnisstruktur nach Installation
|
||||
|
||||
```
|
||||
dein-projekt/
|
||||
├── app/
|
||||
│ ├── helpers.php # cms(), tcms(), cms_media_url(), media_url()
|
||||
│ └── Livewire/Admin/Cms/
|
||||
│ ├── MediaLibraryUploader.php # Multi-File Upload
|
||||
│ └── MediaPicker.php # Medienauswahl-Modal
|
||||
├── config/
|
||||
│ └── flux-cms.php # CMS + Media Konfiguration
|
||||
├── database/
|
||||
│ ├── migrations/
|
||||
│ │ └── *_create_flux_cms_*.php # Automatisch vom Package
|
||||
│ └── seeders/
|
||||
│ ├── CmsContentSeeder.php # Lang → DB
|
||||
│ ├── CmsMediaSeeder.php # Medien + Image-Content
|
||||
│ ├── CmsDownloadSeeder.php # Case Studies etc.
|
||||
│ ├── CmsNewsItemSeeder.php
|
||||
│ ├── CmsIndustrySeeder.php
|
||||
│ ├── CmsFaqSeeder.php
|
||||
│ └── CmsLinkedinPostSeeder.php
|
||||
├── package/flux-cms/core/ # Das Package
|
||||
├── resources/views/
|
||||
│ ├── components/layouts/
|
||||
│ │ └── cms.blade.php # Admin-Layout
|
||||
│ └── livewire/admin/cms/
|
||||
│ ├── content-index.blade.php # Content-Editor
|
||||
│ ├── media-index.blade.php # Medienbibliothek
|
||||
│ ├── media-library-uploader.blade.php
|
||||
│ ├── media-picker.blade.php
|
||||
│ ├── news-index.blade.php
|
||||
│ ├── downloads-index.blade.php
|
||||
│ ├── team-index.blade.php
|
||||
│ ├── linkedin-index.blade.php
|
||||
│ ├── industries-index.blade.php
|
||||
│ ├── faqs-index.blade.php
|
||||
│ ├── search-index.blade.php # Suchindex-Verwaltung
|
||||
│ └── dashboard.blade.php # CMS-Dashboard
|
||||
└── routes/web.php # CMS-Routes
|
||||
```
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace FluxCms\Components;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use ReflectionClass;
|
||||
|
||||
class FluxCmsComponentsServiceProvider extends ServiceProvider
|
||||
|
|
@ -33,7 +33,7 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
|
|||
*/
|
||||
protected function bootViews(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-components');
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms-components');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,12 +44,12 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
|
|||
if ($this->app->runningInConsole()) {
|
||||
// Publish views
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-components'),
|
||||
__DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-views');
|
||||
|
||||
// Publish assets
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/assets' => public_path('vendor/flux-cms-components'),
|
||||
__DIR__.'/../resources/assets' => public_path('vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-assets');
|
||||
}
|
||||
}
|
||||
|
|
@ -59,22 +59,22 @@ class FluxCmsComponentsServiceProvider extends ServiceProvider
|
|||
*/
|
||||
protected function bootLivewireComponents(): void
|
||||
{
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
|
||||
$this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
|
||||
$this->registerLivewireComponentsFrom(__DIR__.'/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
|
||||
}
|
||||
|
||||
protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void
|
||||
{
|
||||
$filesystem = new Filesystem();
|
||||
if (!$filesystem->isDirectory($path)) {
|
||||
$filesystem = new Filesystem;
|
||||
if (! $filesystem->isDirectory($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($filesystem->allFiles($path) as $file) {
|
||||
$class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
|
||||
$class = $namespace.'\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) {
|
||||
$alias = $aliasPrefix . Str::kebab(class_basename($class));
|
||||
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && ! (new ReflectionClass($class))->isAbstract()) {
|
||||
$alias = $aliasPrefix.Str::kebab(class_basename($class));
|
||||
Livewire::component($alias, $class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Spatie\Tags\Tag;
|
||||
use Livewire\Component;
|
||||
|
||||
class BlogEditor extends Component
|
||||
{
|
||||
public BlogPost $post;
|
||||
|
||||
public string $tags = '';
|
||||
|
||||
public function mount(BlogPost $post)
|
||||
|
|
|
|||
|
|
@ -2,21 +2,26 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
|
||||
public array $availableLanguages = [];
|
||||
|
||||
public string $currentLocale = 'de';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public ?BlogPost $editingPost = null;
|
||||
|
||||
// Form data
|
||||
|
|
@ -48,12 +53,12 @@ class BlogManager extends Component
|
|||
$query = BlogPost::forDomain($this->domainKey);
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
if (! empty($this->search)) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('title->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('title->en', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->en', 'like', '%' . $this->search . '%');
|
||||
$q->where('title->de', 'like', '%'.$this->search.'%')
|
||||
->orWhere('title->en', 'like', '%'.$this->search.'%')
|
||||
->orWhere('content->de', 'like', '%'.$this->search.'%')
|
||||
->orWhere('content->en', 'like', '%'.$this->search.'%');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +180,7 @@ class BlogManager extends Component
|
|||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving blog post: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error saving blog post: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +196,7 @@ class BlogManager extends Component
|
|||
session()->flash('success', 'Blog post deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting blog post: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error deleting blog post: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +218,7 @@ class BlogManager extends Component
|
|||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating publish status: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error updating publish status: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -225,12 +230,12 @@ class BlogManager extends Component
|
|||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
$post->update(['is_featured' => !$post->is_featured]);
|
||||
$post->update(['is_featured' => ! $post->is_featured]);
|
||||
$message = $post->is_featured ? 'Post marked as featured.' : 'Post removed from featured.';
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating featured status: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error updating featured status: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,14 +254,14 @@ class BlogManager extends Component
|
|||
// Update title to indicate it's a copy
|
||||
$titles = $duplicate->getTranslations('title');
|
||||
foreach ($titles as $locale => $title) {
|
||||
$titles[$locale] = $title . ' (Copy)';
|
||||
$titles[$locale] = $title.' (Copy)';
|
||||
}
|
||||
$duplicate->title = $titles;
|
||||
|
||||
// Update slugs to avoid conflicts
|
||||
$slugs = $duplicate->getTranslations('slug');
|
||||
foreach ($slugs as $locale => $slug) {
|
||||
$slugs[$locale] = $slug . '-copy-' . time();
|
||||
$slugs[$locale] = $slug.'-copy-'.time();
|
||||
}
|
||||
$duplicate->slug = $slugs;
|
||||
|
||||
|
|
@ -264,7 +269,7 @@ class BlogManager extends Component
|
|||
session()->flash('success', 'Blog post duplicated successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error duplicating blog post: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error duplicating blog post: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +281,7 @@ class BlogManager extends Component
|
|||
$title = $this->postData['title'][$locale] ?? '';
|
||||
if ($title) {
|
||||
$slug = \Illuminate\Support\Str::slug($title);
|
||||
$this->postData['slug'][$locale] = '/' . $slug;
|
||||
$this->postData['slug'][$locale] = '/'.$slug;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,4 +320,4 @@ class BlogManager extends Component
|
|||
'featured' => BlogPost::forDomain($this->domainKey)->featured()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,22 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Livewire\Component;
|
||||
|
||||
class ComponentEditor extends Component
|
||||
{
|
||||
public PageComponent $component;
|
||||
|
||||
public array $content = [];
|
||||
|
||||
public array $availableLanguages = [];
|
||||
|
||||
public string $currentLocale = 'de';
|
||||
|
||||
public bool $expanded = false;
|
||||
|
||||
public array $validationErrors = [];
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
|
@ -56,7 +61,7 @@ class ComponentEditor extends Component
|
|||
*/
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = !$this->expanded;
|
||||
$this->expanded = ! $this->expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,21 +90,22 @@ class ComponentEditor extends Component
|
|||
{
|
||||
$this->validateContent();
|
||||
|
||||
if (!empty($this->validationErrors)) {
|
||||
if (! empty($this->validationErrors)) {
|
||||
session()->flash('error', 'Please correct validation errors.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
'content' => $this->content,
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Component saved successfully.');
|
||||
$this->dispatch('component-saved', componentId: $this->component->id);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving component: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error saving component: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +117,7 @@ class ComponentEditor extends Component
|
|||
if (empty($this->validationErrors)) {
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
'content' => $this->content,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Silent fail for auto-save
|
||||
|
|
@ -189,6 +195,7 @@ class ComponentEditor extends Component
|
|||
public function hasFieldError(string $fieldKey, ?string $locale = null): bool
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
|
||||
return isset($this->validationErrors[$errorKey]);
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +205,7 @@ class ComponentEditor extends Component
|
|||
public function getFieldErrors(string $fieldKey, ?string $locale = null): array
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
|
||||
return $this->validationErrors[$errorKey] ?? [];
|
||||
}
|
||||
|
||||
|
|
@ -225,4 +233,4 @@ class ComponentEditor extends Component
|
|||
$this->content = $this->component->getTranslations('content');
|
||||
$this->validationErrors = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,24 +2,32 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class MediaManager extends Component
|
||||
{
|
||||
use WithFileUploads, WithPagination;
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public ?string $targetComponentId = null;
|
||||
|
||||
public ?string $targetFieldKey = null;
|
||||
|
||||
public ?string $targetLocale = null;
|
||||
|
||||
public array $uploadingFiles = [];
|
||||
|
||||
public string $searchTerm = '';
|
||||
|
||||
public string $filterType = 'all';
|
||||
|
||||
public array $selectedMedia = [];
|
||||
|
||||
public bool $multiSelect = false;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
|
@ -71,7 +79,7 @@ class MediaManager extends Component
|
|||
public function uploadFiles()
|
||||
{
|
||||
$this->validate([
|
||||
'uploadingFiles.*' => 'file|max:' . config('flux-cms.media.max_file_size', 10240),
|
||||
'uploadingFiles.*' => 'file|max:'.config('flux-cms.media.max_file_size', 10240),
|
||||
]);
|
||||
|
||||
try {
|
||||
|
|
@ -83,7 +91,7 @@ class MediaManager extends Component
|
|||
session()->flash('success', 'Files uploaded successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error uploading files: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error uploading files: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +102,10 @@ class MediaManager extends Component
|
|||
{
|
||||
// Create a temporary model for media library
|
||||
// In real implementation, you'd use a dedicated media model
|
||||
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia {
|
||||
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia
|
||||
{
|
||||
use \Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
protected $table = 'flux_cms_media'; // Would exist in real implementation
|
||||
};
|
||||
|
||||
|
|
@ -114,7 +124,7 @@ class MediaManager extends Component
|
|||
{
|
||||
if ($this->multiSelect) {
|
||||
if (in_array($mediaId, $this->selectedMedia)) {
|
||||
$this->selectedMedia = array_filter($this->selectedMedia, fn($id) => $id !== $mediaId);
|
||||
$this->selectedMedia = array_filter($this->selectedMedia, fn ($id) => $id !== $mediaId);
|
||||
} else {
|
||||
$this->selectedMedia[] = $mediaId;
|
||||
}
|
||||
|
|
@ -165,7 +175,7 @@ class MediaManager extends Component
|
|||
session()->flash('success', 'Media deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting media: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error deleting media: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,13 +187,13 @@ class MediaManager extends Component
|
|||
$query = Media::query()->orderBy('created_at', 'desc');
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->searchTerm)) {
|
||||
$query->where('name', 'like', '%' . $this->searchTerm . '%');
|
||||
if (! empty($this->searchTerm)) {
|
||||
$query->where('name', 'like', '%'.$this->searchTerm.'%');
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if ($this->filterType !== 'all') {
|
||||
$query->where('mime_type', 'like', $this->filterType . '%');
|
||||
$query->where('mime_type', 'like', $this->filterType.'%');
|
||||
}
|
||||
|
||||
return $query->paginate(20);
|
||||
|
|
@ -271,6 +281,6 @@ class MediaManager extends Component
|
|||
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
return round($bytes, 2).' '.$units[$pow];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,35 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use FluxCms\Core\Models\NavigationItem;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavigationManager extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
|
||||
public Collection $navigations;
|
||||
|
||||
public ?Navigation $selectedNavigation = null;
|
||||
|
||||
public Collection $navigationItems;
|
||||
|
||||
public array $availableLanguages = [];
|
||||
|
||||
public string $currentLocale = 'de';
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
|
||||
public bool $showItemModal = false;
|
||||
|
||||
public ?NavigationItem $editingItem = null;
|
||||
|
||||
// Form data
|
||||
public array $navigationData = [];
|
||||
|
||||
public array $itemData = [];
|
||||
|
||||
public function mount(string $domainKey)
|
||||
|
|
@ -58,8 +67,9 @@ class NavigationManager extends Component
|
|||
*/
|
||||
public function loadNavigationItems()
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
if (! $this->selectedNavigation) {
|
||||
$this->navigationItems = collect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +134,7 @@ class NavigationManager extends Component
|
|||
session()->flash('success', 'Navigation created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error creating navigation: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error creating navigation: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +182,7 @@ class NavigationManager extends Component
|
|||
// Validate that either page or external URL is provided
|
||||
if (empty($this->itemData['page_id']) && empty($this->itemData['external_url'])) {
|
||||
$this->addError('itemData.page_id', 'Either select a page or provide an external URL.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +216,7 @@ class NavigationManager extends Component
|
|||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving navigation item: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error saving navigation item: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +233,7 @@ class NavigationManager extends Component
|
|||
session()->flash('success', 'Navigation item deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation item: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error deleting navigation item: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,11 +245,11 @@ class NavigationManager extends Component
|
|||
try {
|
||||
$item = NavigationItem::find($itemId);
|
||||
if ($item) {
|
||||
$item->update(['is_active' => !$item->is_active]);
|
||||
$item->update(['is_active' => ! $item->is_active]);
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error toggling navigation item: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error toggling navigation item: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +267,7 @@ class NavigationManager extends Component
|
|||
session()->flash('success', 'Navigation order updated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating order: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error updating order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +290,7 @@ class NavigationManager extends Component
|
|||
session()->flash('success', 'Navigation deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation: ' . $e->getMessage());
|
||||
session()->flash('error', 'Error deleting navigation: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +299,7 @@ class NavigationManager extends Component
|
|||
*/
|
||||
public function getAvailableParentsProperty(): Collection
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
if (! $this->selectedNavigation) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
|
|
@ -327,4 +338,4 @@ class NavigationManager extends Component
|
|||
$this->navigationData = [];
|
||||
$this->itemData = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,29 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\On;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class PageEditor extends Component
|
||||
{
|
||||
public Page $page;
|
||||
|
||||
public Collection $components;
|
||||
|
||||
public array $availableLanguages = [];
|
||||
|
||||
public string $currentLocale = 'de';
|
||||
|
||||
public bool $showComponentModal = false;
|
||||
|
||||
public array $availableComponents = [];
|
||||
|
||||
public string $selectedCategory = 'all';
|
||||
|
||||
public bool $isLoading = false;
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
|
@ -39,7 +46,7 @@ class PageEditor extends Component
|
|||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.backend.page-editor')
|
||||
->layout('flux-cms-components::layouts.admin');
|
||||
->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,8 +106,9 @@ class PageEditor extends Component
|
|||
*/
|
||||
public function addComponent(string $componentClass)
|
||||
{
|
||||
if (!$this->componentRegistry->isValidComponent($componentClass)) {
|
||||
if (! $this->componentRegistry->isValidComponent($componentClass)) {
|
||||
$this->addError('component', 'Invalid component selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +129,7 @@ class PageEditor extends Component
|
|||
session()->flash('success', 'Component added successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error adding component: ' . $e->getMessage());
|
||||
$this->addError('component', 'Error adding component: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,8 +141,9 @@ class PageEditor extends Component
|
|||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
if (! $component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +154,7 @@ class PageEditor extends Component
|
|||
session()->flash('success', 'Component deleted successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error deleting component: ' . $e->getMessage());
|
||||
$this->addError('component', 'Error deleting component: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +166,9 @@ class PageEditor extends Component
|
|||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
if (! $component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +179,7 @@ class PageEditor extends Component
|
|||
session()->flash('success', 'Component duplicated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error duplicating component: ' . $e->getMessage());
|
||||
$this->addError('component', 'Error duplicating component: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,15 +191,15 @@ class PageEditor extends Component
|
|||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
if (! $component) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component->update(['is_active' => !$component->is_active]);
|
||||
$component->update(['is_active' => ! $component->is_active]);
|
||||
$this->loadComponents();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error toggling component: ' . $e->getMessage());
|
||||
$this->addError('component', 'Error toggling component: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +218,7 @@ class PageEditor extends Component
|
|||
session()->flash('success', 'Component order updated.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('order', 'Error updating order: ' . $e->getMessage());
|
||||
$this->addError('order', 'Error updating order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +247,7 @@ class PageEditor extends Component
|
|||
}
|
||||
|
||||
foreach ($config['fields'] as $field) {
|
||||
if (!$field instanceof \FluxCms\Core\FieldTypes\BaseField) {
|
||||
if (! $field instanceof \FluxCms\Core\FieldTypes\BaseField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +281,7 @@ class PageEditor extends Component
|
|||
session()->flash('success', 'Page data saved successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('page', 'Error saving page: ' . $e->getMessage());
|
||||
$this->addError('page', 'Error saving page: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -292,21 +302,21 @@ class PageEditor extends Component
|
|||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('publish', 'Error updating publish status: ' . $e->getMessage());
|
||||
$this->addError('publish', 'Error updating publish status: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version
|
||||
*/
|
||||
public function createVersion(string $description = null)
|
||||
public function createVersion(?string $description = null)
|
||||
{
|
||||
try {
|
||||
$this->page->createVersion($description, auth()->id());
|
||||
session()->flash('success', 'Version created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('version', 'Error creating version: ' . $e->getMessage());
|
||||
$this->addError('version', 'Error creating version: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,10 +330,11 @@ class PageEditor extends Component
|
|||
|
||||
if (empty($slug)) {
|
||||
$this->addError('preview', 'No slug available for current language.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->page->getUrl($locale) . '?preview=1';
|
||||
$url = $this->page->getUrl($locale).'?preview=1';
|
||||
$this->dispatch('open-preview', url: $url);
|
||||
}
|
||||
|
||||
|
|
@ -351,6 +362,7 @@ class PageEditor extends Component
|
|||
foreach ($this->availableComponents as $category => $categoryComponents) {
|
||||
$components = array_merge($components, $categoryComponents);
|
||||
}
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +374,7 @@ class PageEditor extends Component
|
|||
*/
|
||||
public function getPageStatusProperty(): string
|
||||
{
|
||||
if (!$this->page->is_published) {
|
||||
if (! $this->page->is_published) {
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
|
|
@ -372,4 +384,4 @@ class PageEditor extends Component
|
|||
|
||||
return 'published';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,34 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class BlogList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
|
||||
public int $perPage = 12;
|
||||
|
||||
public bool $showFeatured = true;
|
||||
|
||||
public bool $showPagination = true;
|
||||
|
||||
public string $orderBy = 'published_at';
|
||||
|
||||
public string $orderDirection = 'desc';
|
||||
|
||||
public array $classes = [];
|
||||
|
||||
// Filtering
|
||||
public string $search = '';
|
||||
|
||||
public array $tags = [];
|
||||
|
||||
public ?string $category = null;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
|
@ -63,12 +71,12 @@ class BlogList extends Component
|
|||
$query = BlogPost::forDomain($this->domainKey)->published();
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
if (! empty($this->search)) {
|
||||
$locale = app()->getLocale();
|
||||
$query->where(function ($q) use ($locale) {
|
||||
$q->where("title->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("content->{$locale}", 'like', '%' . $this->search . '%');
|
||||
$q->where("title->{$locale}", 'like', '%'.$this->search.'%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%'.$this->search.'%')
|
||||
->orWhere("content->{$locale}", 'like', '%'.$this->search.'%');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +127,7 @@ class BlogList extends Component
|
|||
public function getPostTitle(BlogPost $post): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +194,7 @@ class BlogList extends Component
|
|||
{
|
||||
$defaultClasses = ['flux-cms-blog-list', 'blog-list'];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +238,7 @@ class BlogList extends Component
|
|||
public function getSearchPlaceholder(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $locale === 'de' ? 'Blog durchsuchen...' : 'Search blog...';
|
||||
}
|
||||
|
||||
|
|
@ -238,10 +249,10 @@ class BlogList extends Component
|
|||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if (!empty($this->search)) {
|
||||
if (! empty($this->search)) {
|
||||
return $locale === 'de'
|
||||
? 'Keine Artikel für "' . $this->search . '" gefunden.'
|
||||
: 'No posts found for "' . $this->search . '".';
|
||||
? 'Keine Artikel für "'.$this->search.'" gefunden.'
|
||||
: 'No posts found for "'.$this->search.'".';
|
||||
}
|
||||
|
||||
return $locale === 'de'
|
||||
|
|
@ -262,4 +273,4 @@ class BlogList extends Component
|
|||
|
||||
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost as BlogPostModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class BlogPost extends Component
|
||||
{
|
||||
public BlogPostModel $post;
|
||||
|
||||
public string $domainKey;
|
||||
|
||||
public bool $showRelated = true;
|
||||
|
||||
public bool $showAuthor = true;
|
||||
|
||||
public bool $showMeta = true;
|
||||
|
||||
public bool $showSocial = true;
|
||||
|
||||
public array $classes = [];
|
||||
|
||||
public function mount(
|
||||
|
|
@ -65,7 +71,7 @@ class BlogPost extends Component
|
|||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'title' => $this->getTitle() . ' - Blog',
|
||||
'title' => $this->getTitle().' - Blog',
|
||||
'description' => $this->getExcerpt(160),
|
||||
'keywords' => $this->post->getTranslation('meta_keywords', $locale),
|
||||
'og_title' => $this->getTitle(),
|
||||
|
|
@ -85,6 +91,7 @@ class BlogPost extends Component
|
|||
public function getTitle(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $this->post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +101,7 @@ class BlogPost extends Component
|
|||
public function getContent(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $this->post->getTranslation('content', $locale);
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +236,7 @@ class BlogPost extends Component
|
|||
public function getRelatedPostTitle(BlogPostModel $relatedPost): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $relatedPost->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
|
|
@ -311,4 +320,4 @@ class BlogPost extends Component
|
|||
'next' => $nextPost,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavigationRenderer extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
|
||||
public string $navigationName;
|
||||
|
||||
public ?Navigation $navigation = null;
|
||||
|
||||
public Collection $navigationItems;
|
||||
|
||||
public string $currentUrl = '';
|
||||
|
||||
public array $classes = [];
|
||||
|
||||
public bool $showInactive = false;
|
||||
|
||||
public function mount(
|
||||
|
|
@ -49,7 +55,7 @@ class NavigationRenderer extends Component
|
|||
$this->navigationItems = $this->navigation->getHierarchicalItems();
|
||||
|
||||
// Filter inactive items if needed
|
||||
if (!$this->showInactive) {
|
||||
if (! $this->showInactive) {
|
||||
$this->navigationItems = $this->navigationItems->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -79,6 +85,7 @@ class NavigationRenderer extends Component
|
|||
public function getItemLabel($item): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $item->getTranslation('label', $locale);
|
||||
}
|
||||
|
||||
|
|
@ -95,9 +102,10 @@ class NavigationRenderer extends Component
|
|||
*/
|
||||
public function getChildren($item): Collection
|
||||
{
|
||||
if (!$this->showInactive) {
|
||||
if (! $this->showInactive) {
|
||||
return $item->children->where('is_active', true);
|
||||
}
|
||||
|
||||
return $item->children;
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +122,9 @@ class NavigationRenderer extends Component
|
|||
*/
|
||||
public function getNavigationClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-navigation', 'navigation-' . $this->navigationName];
|
||||
$defaultClasses = ['flux-cms-navigation', 'navigation-'.$this->navigationName];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +147,7 @@ class NavigationRenderer extends Component
|
|||
$classes[] = 'nav-item--has-children';
|
||||
}
|
||||
|
||||
if (!$item->is_active) {
|
||||
if (! $item->is_active) {
|
||||
$classes[] = 'nav-item--inactive';
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +186,7 @@ class NavigationRenderer extends Component
|
|||
$attributeStrings = [];
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attributeStrings[] = $key . '="' . htmlspecialchars($value) . '"';
|
||||
$attributeStrings[] = $key.'="'.htmlspecialchars($value).'"';
|
||||
}
|
||||
|
||||
return implode(' ', $attributeStrings);
|
||||
|
|
@ -239,11 +248,12 @@ class NavigationRenderer extends Component
|
|||
*/
|
||||
public function getNavigationDisplayName(): string
|
||||
{
|
||||
if (!$this->navigation) {
|
||||
if (! $this->navigation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $this->navigation->getTranslation('display_name', $locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@
|
|||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class PageRenderer extends Component
|
||||
{
|
||||
public Page $page;
|
||||
|
||||
public Collection $components;
|
||||
|
||||
public bool $isPreview = false;
|
||||
|
||||
public array $seoData = [];
|
||||
|
||||
public function mount(Page $page, bool $isPreview = false)
|
||||
|
|
@ -24,10 +27,10 @@ class PageRenderer extends Component
|
|||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.frontend.page-renderer')
|
||||
->layout('flux-cms-components::layouts.frontend', [
|
||||
'seoData' => $this->seoData,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
->layout('flux-cms-components::layouts.frontend', [
|
||||
'seoData' => $this->seoData,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,6 +87,7 @@ class PageRenderer extends Component
|
|||
public function getComponentContent(PageComponent $component): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $component->getTranslatedContent($locale);
|
||||
}
|
||||
|
||||
|
|
@ -93,23 +97,24 @@ class PageRenderer extends Component
|
|||
public function renderComponent(PageComponent $component): string
|
||||
{
|
||||
try {
|
||||
if (!$this->canRenderComponent($component)) {
|
||||
if (! $this->canRenderComponent($component)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = $this->getComponentContent($component);
|
||||
|
||||
// Check if component class exists
|
||||
if (!class_exists($component->component_class)) {
|
||||
if (! class_exists($component->component_class)) {
|
||||
if ($this->isPreview) {
|
||||
return $this->renderComponentError($component, 'Component class not found');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render component
|
||||
$componentHtml = \Livewire\Livewire::mount($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
'content' => $component->getTranslations('content'),
|
||||
])->html();
|
||||
|
||||
// Wrap component if enabled
|
||||
|
|
@ -123,7 +128,7 @@ class PageRenderer extends Component
|
|||
\Log::error('Error rendering component', [
|
||||
'component_id' => $component->id,
|
||||
'component_class' => $component->component_class,
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($this->isPreview) {
|
||||
|
|
@ -141,10 +146,10 @@ class PageRenderer extends Component
|
|||
{
|
||||
$classes = [
|
||||
'flux-cms-component',
|
||||
'flux-cms-component--' . class_basename($component->component_class),
|
||||
'flux-cms-component--'.class_basename($component->component_class),
|
||||
];
|
||||
|
||||
if (!$component->is_active) {
|
||||
if (! $component->is_active) {
|
||||
$classes[] = 'flux-cms-component--inactive';
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +162,7 @@ class PageRenderer extends Component
|
|||
}
|
||||
|
||||
$attributeString = collect($attributes)
|
||||
->map(fn($value, $key) => "{$key}=\"{$value}\"")
|
||||
->map(fn ($value, $key) => "{$key}=\"{$value}\"")
|
||||
->implode(' ');
|
||||
|
||||
$classString = implode(' ', $classes);
|
||||
|
|
@ -196,9 +201,9 @@ class PageRenderer extends Component
|
|||
public function getRelatedPages(): Collection
|
||||
{
|
||||
return Page::forDomain($this->page->domain_key)
|
||||
->published()
|
||||
->where('id', '!=', $this->page->id)
|
||||
->limit(3)
|
||||
->get();
|
||||
->published()
|
||||
->where('id', '!=', $this->page->id)
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace FluxCms\Components\Tests\Feature\Backend;
|
||||
|
||||
use Livewire\Livewire;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Components\Livewire\Backend\BlogEditor;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class BlogEditorTest extends TestCase
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@
|
|||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^11.0|^12.0",
|
||||
"spatie/laravel-translatable": "^6.0",
|
||||
"spatie/laravel-medialibrary": "^11.0",
|
||||
"spatie/laravel-tags": "^4.0"
|
||||
"spatie/laravel-translatable": "^6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^9.0",
|
||||
|
|
@ -53,4 +51,4 @@
|
|||
"pestphp/pest-plugin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -143,27 +143,61 @@ return [
|
|||
'media' => [
|
||||
'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'),
|
||||
'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB
|
||||
'originals_path' => 'cms/media/originals',
|
||||
'conversions_path' => 'cms/media/conversions',
|
||||
'allowed_extensions' => [
|
||||
'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'],
|
||||
'videos' => ['mp4', 'webm', 'ogg', 'avi', 'mov'],
|
||||
'audio' => ['mp3', 'wav', 'ogg', 'flac'],
|
||||
],
|
||||
'conversions' => [
|
||||
'profiles' => [
|
||||
'thumb' => [
|
||||
'width' => 300,
|
||||
'height' => 300,
|
||||
'fit' => 'crop',
|
||||
'format' => 'webp',
|
||||
'quality' => 80,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'medium' => [
|
||||
'hero' => [
|
||||
'width' => 1920,
|
||||
'height' => 800,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'service' => [
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'fit' => 'contain',
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'large' => [
|
||||
'avatar' => [
|
||||
'width' => 400,
|
||||
'height' => 400,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'news' => [
|
||||
'width' => 1200,
|
||||
'height' => 900,
|
||||
'fit' => 'contain',
|
||||
'height' => 400,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'thumbnail' => [
|
||||
'width' => 600,
|
||||
'height' => 400,
|
||||
'format' => 'webp',
|
||||
'quality' => 80,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'og_image' => [
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'format' => 'jpg',
|
||||
'quality' => 90,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
@ -184,12 +218,22 @@ return [
|
|||
'basic' => ['bold', 'italic'],
|
||||
'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
|
||||
'full' => [
|
||||
'bold', 'italic', 'underline', 'strike',
|
||||
'heading1', 'heading2', 'heading3',
|
||||
'bulletList', 'orderedList',
|
||||
'link', 'image', 'table',
|
||||
'code', 'codeBlock',
|
||||
'quote', 'rule'
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'link',
|
||||
'image',
|
||||
'table',
|
||||
'code',
|
||||
'codeBlock',
|
||||
'quote',
|
||||
'rule',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
@ -302,4 +346,4 @@ return [
|
|||
'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true),
|
||||
],
|
||||
|
||||
];
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_contents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('group')->index();
|
||||
$table->string('key');
|
||||
$table->string('type')->default('text');
|
||||
$table->json('value')->nullable();
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['group', 'key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_contents');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_downloads', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->json('title');
|
||||
$table->json('description')->nullable();
|
||||
$table->string('category');
|
||||
$table->string('file_path')->nullable();
|
||||
$table->string('thumbnail')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('category');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_downloads');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_linkedin_posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('linkedin_id')->nullable()->unique();
|
||||
$table->json('title');
|
||||
$table->json('excerpt')->nullable();
|
||||
$table->json('content')->nullable();
|
||||
$table->string('author')->nullable();
|
||||
$table->date('date')->nullable();
|
||||
$table->string('url')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->string('source')->default('manual');
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_linkedin_posts');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_faqs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('category')->index();
|
||||
$table->json('question');
|
||||
$table->json('answer');
|
||||
$table->json('help')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_faqs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_news_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('icon')->nullable();
|
||||
$table->json('text');
|
||||
$table->json('title');
|
||||
$table->json('excerpt')->nullable();
|
||||
$table->json('content')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->date('date')->nullable();
|
||||
$table->string('author')->nullable();
|
||||
$table->string('link')->nullable();
|
||||
$table->string('pdf_path')->nullable();
|
||||
$table->json('pdf_open_text')->nullable();
|
||||
$table->json('pdf_download_text')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_news_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_industries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->json('name');
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_industries');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('filename');
|
||||
$table->string('disk')->default('public');
|
||||
$table->string('path');
|
||||
$table->string('type')->default('image');
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->unsignedBigInteger('file_size')->default(0);
|
||||
$table->unsignedInteger('original_width')->nullable();
|
||||
$table->unsignedInteger('original_height')->nullable();
|
||||
$table->json('alt_text')->nullable();
|
||||
$table->json('title')->nullable();
|
||||
$table->string('collection')->nullable()->index();
|
||||
$table->json('conversions')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->unsignedInteger('order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['type', 'collection']);
|
||||
$table->index('is_published');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_media');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('flux_cms_search_index', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('item_id')->unique();
|
||||
$table->string('route');
|
||||
$table->json('route_params')->nullable();
|
||||
$table->json('category');
|
||||
$table->string('title_key')->nullable();
|
||||
$table->json('title_fallback')->nullable();
|
||||
$table->string('description_key')->nullable();
|
||||
$table->string('description_fallback_key')->nullable();
|
||||
$table->json('description_fallback_text')->nullable();
|
||||
$table->json('keywords');
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('flux_cms_search_index');
|
||||
}
|
||||
};
|
||||
|
|
@ -29,4 +29,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_page_components');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -30,4 +30,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_page_versions');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -27,4 +27,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_navigations');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -32,4 +32,4 @@ return new class extends Migration
|
|||
{
|
||||
Schema::dropIfExists('flux_cms_navigation_items');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Files to skip entirely (handled by dedicated seeders or irrelevant).
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $skipFiles = [
|
||||
'faqs',
|
||||
'sections',
|
||||
'search_index',
|
||||
'validation',
|
||||
'auth',
|
||||
'passwords',
|
||||
'pagination',
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys to skip within specific groups (handled by dedicated models).
|
||||
*
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
protected array $skipKeys = [
|
||||
'components' => ['news_band', 'industries_band'],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
|
||||
$deFiles = glob(lang_path('de/*.php'));
|
||||
if (! $deFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($deFiles as $filePath) {
|
||||
$group = pathinfo($filePath, PATHINFO_FILENAME);
|
||||
|
||||
if (in_array($group, $this->skipFiles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$translations = [];
|
||||
foreach ($locales as $locale) {
|
||||
$localePath = lang_path("{$locale}/{$group}.php");
|
||||
if (file_exists($localePath)) {
|
||||
$translations[$locale] = require $localePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($translations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deData = $translations['de'] ?? [];
|
||||
$enData = $translations['en'] ?? [];
|
||||
|
||||
$flatDe = $this->flatten($deData);
|
||||
$flatEn = $this->flatten($enData);
|
||||
|
||||
$allKeys = array_keys($flatDe);
|
||||
foreach (array_keys($flatEn) as $enKey) {
|
||||
if (! in_array($enKey, $allKeys)) {
|
||||
$allKeys[] = $enKey;
|
||||
}
|
||||
}
|
||||
|
||||
$order = 0;
|
||||
foreach ($allKeys as $key) {
|
||||
if ($this->shouldSkipKey($group, $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deValue = $flatDe[$key] ?? null;
|
||||
$enValue = $flatEn[$key] ?? null;
|
||||
|
||||
$type = $this->detectType($deValue ?? $enValue);
|
||||
|
||||
$translatedValue = [];
|
||||
if ($deValue !== null) {
|
||||
$translatedValue['de'] = is_string($deValue) ? $this->cleanHtml($deValue) : $deValue;
|
||||
}
|
||||
if ($enValue !== null) {
|
||||
$translatedValue['en'] = is_string($enValue) ? $this->cleanHtml($enValue) : $enValue;
|
||||
}
|
||||
|
||||
CmsContent::updateOrCreate(
|
||||
['group' => $group, 'key' => $key],
|
||||
[
|
||||
'type' => $type,
|
||||
'value' => $translatedValue,
|
||||
'order' => $order++,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a nested array into dot-notation keys.
|
||||
* Arrays of objects (indexed arrays) are stored as JSON type.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function flatten(array $array, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
$fullKey = $prefix ? "{$prefix}.{$key}" : (string) $key;
|
||||
|
||||
if (is_array($value) && ! Arr::isAssoc($value)) {
|
||||
$result[$fullKey] = $value;
|
||||
} elseif (is_array($value)) {
|
||||
$result = array_merge($result, $this->flatten($value, $fullKey));
|
||||
} else {
|
||||
$result[$fullKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function detectType(mixed $value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
if (preg_match('/<[a-z][\s\S]*>/i', $value)) {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (preg_match('/\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i', $value)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (preg_match('/\.(pdf|doc|docx)$/i', $value)) {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean HTML by converting font-weight spans to <strong> tags.
|
||||
* Preserves text-gradient-premium spans and email-protection spans.
|
||||
*/
|
||||
protected function cleanHtml(string $value): string
|
||||
{
|
||||
$value = preg_replace(
|
||||
'/<span\s+class="[^"]*(?:font-semibold|font-bold)[^"]*">(\s*)(.*?)<\/span>/si',
|
||||
'$1<strong>$2</strong>',
|
||||
$value
|
||||
);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function shouldSkipKey(string $group, string $key): bool
|
||||
{
|
||||
if (! isset($this->skipKeys[$group])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->skipKeys[$group] as $skipPrefix) {
|
||||
if ($key === $skipPrefix || str_starts_with($key, "{$skipPrefix}.")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsDownload;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsDownloadSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$items = $this->getItems();
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
CmsDownload::updateOrCreate(
|
||||
[
|
||||
'category' => $item['category'],
|
||||
'order' => $index,
|
||||
],
|
||||
[
|
||||
'title' => $item['title'],
|
||||
'description' => $item['description'],
|
||||
'icon' => $item['icon'],
|
||||
'sub_category' => $item['sub_category'],
|
||||
'type_label' => $item['type_label'],
|
||||
'alt' => $item['alt'],
|
||||
'thumbnail' => $item['thumbnail'],
|
||||
'file_path' => $item['file_path'],
|
||||
'open_text' => $item['open_text'],
|
||||
'download_text' => $item['download_text'],
|
||||
'highlights' => $item['highlights'] ?? null,
|
||||
'checkpoints' => $item['checkpoints'] ?? null,
|
||||
'is_published' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getItems(): array
|
||||
{
|
||||
return [
|
||||
// === Case Studies ===
|
||||
[
|
||||
'category' => 'case_study',
|
||||
'title' => ['de' => 'Hair-Care R&D Product Support', 'en' => 'Hair-Care R&D Product Support'],
|
||||
'description' => ['de' => 'Ein globaler FMCG-Kunde im Bereich Hair Care musste eine komplexe R&D-Roadmap umsetzen und wir die Koordination von rund 5 parallelen Entwicklungsinitiativen koordinierten.', 'en' => 'A global FMCG client in the hair care segment needed to implement a complex R&D roadmap. We coordinated approximately 5 parallel development initiatives.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'R&D Product Support',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Hair-Care R&D Product Support', 'en' => 'Case Study Hair-Care R&D Product Support'],
|
||||
'thumbnail' => 'case-study-7011.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'en' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '100%', 'label' => 'Dokumentations-<br>konformität'], ['value' => '5', 'label' => 'parallele<br>Entwicklungsinitiativen']],
|
||||
],
|
||||
[
|
||||
'category' => 'case_study',
|
||||
'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'],
|
||||
'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Lab Support Data Integration',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'],
|
||||
'thumbnail' => 'case-study-7012.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration<br> ']],
|
||||
],
|
||||
[
|
||||
'category' => 'case_study',
|
||||
'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'],
|
||||
'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Fragrance Pump Experience',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'],
|
||||
'thumbnail' => 'case-study-7013.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '∞', 'label' => 'Objektivierung-<br>von Emotionen'], ['value' => '∞', 'label' => 'Globale<br>Differenzierung']],
|
||||
],
|
||||
[
|
||||
'category' => 'case_study',
|
||||
'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'],
|
||||
'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Master Data Excellence',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'],
|
||||
'thumbnail' => 'case-study-7010.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz<br> ']],
|
||||
],
|
||||
// === Capabilities ===
|
||||
[
|
||||
'category' => 'capability',
|
||||
'title' => ['de' => 'Global Player', 'en' => 'Global Player'],
|
||||
'description' => ['de' => 'Beherrschen Sie globale Komplexität. Skalieren Sie Innovationen sicher über Märkte und Werke hinweg.', 'en' => 'Master global complexity. Scale innovations safely across markets and plants.'],
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => 'Globale Player & Internationale Projekte',
|
||||
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
|
||||
'alt' => ['de' => 'Capability Profile Global Player', 'en' => 'Capability Profile Global Player'],
|
||||
'thumbnail' => 'global-player.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'en' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'checkpoints' => [['value' => 'Etablieren Sie globale Exzellenz'], ['value' => 'Sichern Sie Ihre "License to Operate"'], ['value' => 'Synchronisieren Sie Zentrale und Werke']],
|
||||
],
|
||||
[
|
||||
'category' => 'capability',
|
||||
'title' => ['de' => 'Nationale Champions', 'en' => 'National Champions'],
|
||||
'description' => ['de' => 'Realisieren Sie große Ideen mit pragmatischer Schlagkraft. Nutzen Sie bewährte Methoden maßgeschneidert für Ihre Strukturen.', 'en' => 'Turn big ideas into reality with pragmatic impact. Use proven methods tailored to your structures.'],
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => 'Nationale Champions & Regionale Akteure',
|
||||
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
|
||||
'alt' => ['de' => 'Capability Profile Nationale Champions', 'en' => 'Capability Profile National Champions'],
|
||||
'thumbnail' => 'nationale-champions.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'en' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'checkpoints' => [['value' => 'Erweitern Sie Ihre Handlungsfähigkeit'], ['value' => 'Profitieren Sie von Best-Practices'], ['value' => 'Steigern Sie Ihre Marge']],
|
||||
],
|
||||
[
|
||||
'category' => 'capability',
|
||||
'title' => ['de' => 'Leistungsübersicht', 'en' => 'Service Overview'],
|
||||
'description' => ['de' => 'Bündeln Sie Ihre Anforderungen. Wir bieten Ihnen ein integriertes Spektrum aus Packaging, Engineering, Projektmanagement und spezialisiertem Consulting.', 'en' => 'We offer you an integrated spectrum of Packaging, Engineering, Project Management and specialized consulting.'],
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => 'Ihr Leistungsportfolio für technische Exzellenz.',
|
||||
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
|
||||
'alt' => ['de' => 'Capability Leistungsübersicht', 'en' => 'Capability Service Overview for Technical Excellence'],
|
||||
'thumbnail' => 'keyvisual.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'en' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'checkpoints' => [['value' => 'Ihre Buchungsmodelle'], ['value' => 'Verfügbare Experten-Rollen'], ['value' => 'Warum inno-projekt?']],
|
||||
],
|
||||
[
|
||||
'category' => 'capability',
|
||||
'title' => ['de' => 'Master Data Management', 'en' => 'Master Data Management'],
|
||||
'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Master Data Management und Systemintegration für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in master data management and system integration for FMCG companies.'],
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => 'Verwandeln Sie Datenchaos in Prozessgeschwindigkeit.',
|
||||
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
|
||||
'alt' => ['de' => 'Capability Profile Master Data Management', 'en' => 'Capability Profile Master Data Management'],
|
||||
'thumbnail' => 'leistung-2.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'en' => 'inno-projekt-Capability_9012_MasterData_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'checkpoints' => [['value' => 'Entlasten Sie Ihre Experten'], ['value' => 'Beschleunigen Sie Ihren Markteintritt'], ['value' => 'Garantieren Sie Compliance']],
|
||||
],
|
||||
[
|
||||
'category' => 'capability',
|
||||
'title' => ['de' => 'Integrated Consumer Research', 'en' => 'Integrated Consumer Research'],
|
||||
'description' => ['de' => 'Erfahren Sie mehr über unsere Expertise im Bereich Integrated Consumer Research für FMCG-Unternehmen.', 'en' => 'Learn more about our expertise in integrated consumer research for FMCG companies.'],
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => 'Verwandeln Sie subjektives Erleben in messbare technische Daten.',
|
||||
'type_label' => ['de' => 'Capability', 'en' => 'Capability'],
|
||||
'alt' => ['de' => 'Capability Integrated Consumer Research', 'en' => 'Capability Integrated Consumer Research'],
|
||||
'thumbnail' => 'leistung-2.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'en' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF downloaden', 'en' => 'Download PDF'],
|
||||
'checkpoints' => [['value' => 'Minimieren Sie Fehlentwicklungen'], ['value' => 'Machen Sie Markenwerte messbar'], ['value' => 'Verstehen Sie Ihre "Emotional Map"']],
|
||||
],
|
||||
// === Success Stories ===
|
||||
[
|
||||
'category' => 'success_story',
|
||||
'title' => ['de' => 'Lab Support Data Integration', 'en' => 'Lab Support Data Integration'],
|
||||
'description' => ['de' => 'Eie wir für einen globalen FMCG-Player die Verpackungsvalidierung standardisierten und durch direkte Systemintegration die "Time-to-Result" signifikant beschleunigten.', 'en' => 'How we standardized packaging validation for a global FMCG player and significantly accelerated "Time-to-Result" through direct system integration.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Lab Support Data Integration',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Lab Support Data Integration', 'en' => 'Case Study Lab Support Data Integration'],
|
||||
'thumbnail' => 'case-study-7012.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'en' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '20%', 'label' => 'Zeitgewinn'], ['value' => '100%', 'label' => 'Systemintegration<br> ']],
|
||||
],
|
||||
[
|
||||
'category' => 'success_story',
|
||||
'title' => ['de' => 'Fragrance Pump Experience', 'en' => 'Fragrance Pump Experience'],
|
||||
'description' => ['de' => 'Wie wir für einen internationalen Premium-Konzern das subjektive Sprühgefühl von Parfüm in belastbare Ingenieursdaten übersetzten und das perfekte Markenerlebnis definierten.', 'en' => 'How we translated the subjective spray feel of perfume into robust engineering data for an international premium corporation and defined the perfect brand experience.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Fragrance Pump Experience',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Fragrance Pump Experience', 'en' => 'Case Study Fragrance Pump Experience'],
|
||||
'thumbnail' => 'case-study-7013.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'en' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '∞', 'label' => 'Objektivierung-<br>von Emotionen'], ['value' => '∞', 'label' => 'Globale<br>Differenzierung']],
|
||||
],
|
||||
[
|
||||
'category' => 'success_story',
|
||||
'title' => ['de' => 'Master Data Excellence & Prozess-Outsourcing', 'en' => 'Master Data Excellence & Process Outsourcing'],
|
||||
'description' => ['de' => 'Ein globaler FMCG-Konzern stand vor der Herausforderung, seine internen F&E-Teams von hochadministrativem Aufwand zu befreien', 'en' => 'A global FMCG corporation faced the challenge of relieving its internal R&D teams from highly administrative workload.'],
|
||||
'icon' => 'document-chart-bar',
|
||||
'sub_category' => 'Master Data Excellence',
|
||||
'type_label' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'alt' => ['de' => 'Case Study Master Data Excellence & Prozess-Outsourcing', 'en' => 'Case Study Master Data Excellence & Process Outsourcing'],
|
||||
'thumbnail' => 'case-study-7010.webp',
|
||||
'file_path' => ['de' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'en' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf'],
|
||||
'open_text' => ['de' => 'PDF öffnen', 'en' => 'Open PDF'],
|
||||
'download_text' => ['de' => 'PDF herunterladen', 'en' => 'Download PDF'],
|
||||
'highlights' => [['value' => '~60%', 'label' => 'Zeitgewinn'], ['value' => '20-25%', 'label' => 'Kosteneffizienz<br> ']],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsFaqSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
$faqsByLocale = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$path = lang_path("{$locale}/faqs.php");
|
||||
if (file_exists($path)) {
|
||||
$faqsByLocale[$locale] = require $path;
|
||||
}
|
||||
}
|
||||
|
||||
$deData = $faqsByLocale['de'] ?? [];
|
||||
$enData = $faqsByLocale['en'] ?? [];
|
||||
|
||||
$allCategories = array_unique(array_merge(array_keys($deData), array_keys($enData)));
|
||||
|
||||
foreach ($allCategories as $category) {
|
||||
$deCategory = $deData[$category] ?? [];
|
||||
$enCategory = $enData[$category] ?? [];
|
||||
|
||||
$deItems = $deCategory['items'] ?? [];
|
||||
$enItems = $enCategory['items'] ?? [];
|
||||
|
||||
$maxCount = max(count($deItems), count($enItems));
|
||||
|
||||
for ($i = 0; $i < $maxCount; $i++) {
|
||||
$de = $deItems[$i] ?? [];
|
||||
$en = $enItems[$i] ?? [];
|
||||
|
||||
CmsFaq::create([
|
||||
'category' => $category,
|
||||
'question' => array_filter([
|
||||
'de' => $de['question'] ?? null,
|
||||
'en' => $en['question'] ?? null,
|
||||
]),
|
||||
'answer' => array_filter([
|
||||
'de' => $de['answer'] ?? null,
|
||||
'en' => $en['answer'] ?? null,
|
||||
]),
|
||||
'help' => array_filter([
|
||||
'de' => $de['help'] ?? null,
|
||||
'en' => $en['help'] ?? null,
|
||||
]) ?: null,
|
||||
'is_published' => true,
|
||||
'order' => $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsIndustrySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
$industriesByLocale = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$path = lang_path("{$locale}/components.php");
|
||||
if (file_exists($path)) {
|
||||
$data = require $path;
|
||||
$industriesByLocale[$locale] = $data['industries_band']['industries'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$deIndustries = $industriesByLocale['de'] ?? [];
|
||||
$enIndustries = $industriesByLocale['en'] ?? [];
|
||||
|
||||
$maxCount = max(count($deIndustries), count($enIndustries));
|
||||
|
||||
for ($i = 0; $i < $maxCount; $i++) {
|
||||
CmsIndustry::updateOrCreate(
|
||||
['order' => $i],
|
||||
[
|
||||
'name' => array_filter([
|
||||
'de' => $deIndustries[$i] ?? null,
|
||||
'en' => $enIndustries[$i] ?? null,
|
||||
]),
|
||||
'is_published' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsLinkedinPostSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$posts = $this->getFallbackPosts();
|
||||
|
||||
foreach ($posts as $index => $post) {
|
||||
CmsLinkedinPost::updateOrCreate(
|
||||
['linkedin_id' => $post['id'] ?? null],
|
||||
[
|
||||
'title' => ['de' => $post['title'], 'en' => $post['title']],
|
||||
'excerpt' => ['de' => $post['excerpt'], 'en' => $post['excerpt']],
|
||||
'content' => ['de' => $post['content'], 'en' => $post['content']],
|
||||
'author' => $post['author'] ?? 'inno-projekt',
|
||||
'date' => $post['date'] ?? null,
|
||||
'url' => $post['url'] ?? null,
|
||||
'image' => $post['image'] ?? null,
|
||||
'tags' => $post['tags'] ?? [],
|
||||
'source' => 'manual',
|
||||
'is_published' => true,
|
||||
'order' => $index,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function getFallbackPosts(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => '1',
|
||||
'title' => 'How to relieve your project management team and accelerate projects.',
|
||||
'excerpt' => 'Project managers in the FMCG sector often find themselves caught between two stools...',
|
||||
'content' => '<strong>How to relieve your project management team and accelerate projects.</strong><br><br>Project managers in the FMCG sector often find themselves caught between two stools: they are expected to meet the strategic expectations of management while at the same time solving operational problems on the front line.',
|
||||
'date' => '2026-01-13',
|
||||
'author' => 'inno-projekt',
|
||||
'tags' => ['Update', 'LinkedIn'],
|
||||
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_how-to-relieve-your-project-management-team-activity-7416741386902790144-dfJf',
|
||||
'image' => 'post-1.jpeg',
|
||||
],
|
||||
[
|
||||
'id' => '2',
|
||||
'title' => '2026 will be a clearly defined stress test for many packaging concepts.',
|
||||
'excerpt' => 'From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding...',
|
||||
'content' => '<strong>2026 will be a clearly defined stress test for many packaging concepts, primarily due to one thing:</strong><br><br>From August 12, 2026, the new EU Packaging Regulation (PPWR) will be largely binding.',
|
||||
'date' => '2026-01-09',
|
||||
'author' => 'inno-projekt',
|
||||
'tags' => ['Update', 'LinkedIn'],
|
||||
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_2026-will-be-a-clearly-defined-stress-test-activity-7414929446262018049-2JjY',
|
||||
'image' => 'post-2.jpeg',
|
||||
],
|
||||
[
|
||||
'id' => '3',
|
||||
'title' => 'For many, the new working year is beginning these days.',
|
||||
'excerpt' => 'For us, it is time to take on responsibility again...',
|
||||
'content' => '<strong>For many, the new working year is beginning these days.</strong><br><br>For us, it is time to take on responsibility again.',
|
||||
'date' => '2026-01-02',
|
||||
'author' => 'inno-projekt',
|
||||
'tags' => ['Update', 'LinkedIn'],
|
||||
'url' => 'https://www.linkedin.com/posts/inno-projekt-gmbh_for-many-the-new-working-year-is-beginning-activity-7414204668291059712-MfKU',
|
||||
'image' => 'post-3.jpeg',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CmsMediaSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$mediaItems = [
|
||||
['filename' => 'success-1.webp', 'path' => 'cms/media/originals/Q1qptIeXjLHi2l05i9ovwVka7uVKmyHnYBh3xQN6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 30960, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'success-2.webp', 'path' => 'cms/media/originals/btwUGqS3gj45hjnPelRu9uXfyUvACKsU81C7efZk.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'success-3.webp', 'path' => 'cms/media/originals/s13wFweUaQytN4tDlLXjuEr4VEIuXDFPElVS43hg.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46794, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'team.webp', 'path' => 'cms/media/originals/cUs47da887T1ZfkrHXTGbTE45LEAy6S8421E4YvD.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33650, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'save-pillar-1.webp', 'path' => 'cms/media/originals/G5eDDfenyKtbRiP1w1QT79EANtdArogAOmYc402W.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42774, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'save-pillar-2.webp', 'path' => 'cms/media/originals/hlhfRpV5GYKZCA9KSgJG8dY3ItULKn8o1SySxRUu.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52858, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'save-pillar-3.webp', 'path' => 'cms/media/originals/X0FHC9ieaxBzYqR5Af9ZagqPUPZTA26zz0bU2CKo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48472, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'story-logo.webp', 'path' => 'cms/media/originals/Q8VL2F3lBmLQ30tWq3KZf1eeP3XUXgzAKHSaJQ8z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9796, 'original_width' => 768, 'original_height' => 370],
|
||||
['filename' => 'story-taob.webp', 'path' => 'cms/media/originals/tlMQPvaP4t4nn3dM2C1njMLghPui8CjTLYXswwBP.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 6770, 'original_width' => 768, 'original_height' => 300],
|
||||
['filename' => 'mittelstand.webp', 'path' => 'cms/media/originals/me3XNKIxWVw5pNhEZldm8GEN6CUvN4wjtm7ElGfx.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'mittelstand1.webp', 'path' => 'cms/media/originals/V5MFyj8JWMo6WaBqfJJmqCA9drykvC65349RnU9H.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51880, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'nationale-champions.webp', 'path' => 'cms/media/originals/LTSgeytA3mncxZxdXD5SlULD6EBcTiZCwuaDJ89w.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 66366, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'projekte1.webp', 'path' => 'cms/media/originals/0MPvcE7cpGeDe64JCWaF3heCQetvU0cOpGYZnvXf.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51146, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'leistung-1.webp', 'path' => 'cms/media/originals/oVi2TrTyITKKGD22wjfhcjopgDzMofFqO2XX0Mh1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 42336, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistung-2.webp', 'path' => 'cms/media/originals/tVbL57cQkrKSQIkG1WWk9jOt4z9RiZUobLit5vpt.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 87448, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistung-3.webp', 'path' => 'cms/media/originals/e7mwnJSowDbCjxQimayoUP0tZAOkzvT7y1LQjqYp.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 49824, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistung-4.webp', 'path' => 'cms/media/originals/JHCKZVE1c9dor97PfD6yQhjkmd7PIJqwkilVBXAV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistung-5.webp', 'path' => 'cms/media/originals/FCElmkfJ0Qacp7vQRMeR1J5s9oxjRNdcFEVyoOJE.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 48686, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistungen-4.webp', 'path' => 'cms/media/originals/pbbDLBoYBI0I8Quzy0L9rHkmMzc3O78xmG9ZccRS.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51996, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'leistungen.webp', 'path' => 'cms/media/originals/ggwLmiykMRovYcMExdvHRRmUrkJrxMCpF5ZfOXJT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 51018, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'karriere1.webp', 'path' => 'cms/media/originals/KvaAufDevlUTVWQhQI5dsE6mVxORB063I8wmd1L1.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44516, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'keyvisual-small.webp', 'path' => 'cms/media/originals/MHnntV48r17drkJIKZna9NG5Zqye62ypAFSA90L6.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 35164, 'original_width' => 960, 'original_height' => 400],
|
||||
['filename' => 'keyvisual.webp', 'path' => 'cms/media/originals/ma1SzM8v4l4pQeVGhrJ80bYxq2bBnuRC7C1hwNSV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 9762, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'kontakt.webp', 'path' => 'cms/media/originals/3r2ugopYW4Rbiups57eNdzrjpqPvQYc19oVEoUap.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33692, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'grosskonzerne.webp', 'path' => 'cms/media/originals/NBSE2qKQ1uZChAgVNAYespoLKVBzYYtAE96AoK1o.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 47772, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'integration-process.webp', 'path' => 'cms/media/originals/nqOmw0aYAvY0QP1OCfMvrl9B3rpIsDIRPuyNvoxz.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 54274, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'karriere.webp', 'path' => 'cms/media/originals/V9Nkxj9ViC90a6kO6Qcg5UYWZpA3YkNBUEAyi2d0.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 45306, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'dirigent.webp', 'path' => 'cms/media/originals/ZdIjdXC5QMhAqhqluKLlOEB4Qi9LjqWz5BCOhTPw.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40648, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'global-player.webp', 'path' => 'cms/media/originals/TJj3114VYbME1b5Mz3KZ8OWwEvByXMNdHkAggzud.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 40444, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'case-study-7010.webp', 'path' => 'cms/media/originals/ZbRnWxUI5K15CyistdZ0wxRojDoqeYHAeomVehx3.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 70382, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'case-study-7011.webp', 'path' => 'cms/media/originals/9b0tmCAz0msWZaCl1EPFKnu0fzs1HOwHEiiQPixo.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53120, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'case-study-7012.webp', 'path' => 'cms/media/originals/DPVmai1rcxWxYwUPL9J0ON3AYvAe9tGOTeyEanLC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 55586, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'case-study-7013.webp', 'path' => 'cms/media/originals/mLtyPzKJRbDeciSBdKRwxgy9fHQQr7sySdrgHD7L.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 52752, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'capability-global-player.webp', 'path' => 'cms/media/originals/Io8eU0kzezXhAC3nUD0c0udqqEYzU6UG2wDJUzOr.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 28912, 'original_width' => 768, 'original_height' => 300],
|
||||
['filename' => 'capability-national-champions.webp', 'path' => 'cms/media/originals/5WFTRenjgq8ZMS1w5zhPOAlK7yESJozAzchixY6g.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 46992, 'original_width' => 768, 'original_height' => 300],
|
||||
['filename' => 'capability-overview_of_services.webp', 'path' => 'cms/media/originals/Vjylm0dN37CEEH53zi3ZxHWTx1TOR7KYekrnhb3z.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 34220, 'original_width' => 768, 'original_height' => 300],
|
||||
['filename' => 'case-studies.webp', 'path' => 'cms/media/originals/wf3oMRP9pRGk32vvI0p4e25F7RT9pEiX3wIs8cO8.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 44688, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'art.webp', 'path' => 'cms/media/originals/U9owB5ZZNSM8mIjexOpZbW7ypKZoRHFuC7igXOuT.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 53226, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'bridge-builder-2.webp', 'path' => 'cms/media/originals/w1cNSvzLriT6mqqwOEMhoUVI3O3UMiT34IZqVmMC.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 37212, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'bridge-builder.webp', 'path' => 'cms/media/originals/n4ZWkwGSsa73oCDFjIvVgs4kG3iEOc8BEYEXl7fW.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 33050, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'capabilities.webp', 'path' => 'cms/media/originals/5VTPNpgX3vwpzJVipg7RczfbVyU5lOXPpCTrRu66.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 41244, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'about-team.webp', 'path' => 'cms/media/originals/iBJanSjRP72c5smgMu3bKVaVIbMrXhah3nOd5B1I.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 39250, 'original_width' => 800, 'original_height' => 600],
|
||||
['filename' => 'about.webp', 'path' => 'cms/media/originals/cHKkrpxYyjeaqCUMgBuO8bVdJZQPQwLYxqKOqR4O.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 31870, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'architekt.webp', 'path' => 'cms/media/originals/AaTTxGRDVSDMzfPSkANrT2gI4PjFTltLykPnk5vQ.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 27666, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'art-of-balance.webp', 'path' => 'cms/media/originals/2x1uvwBHhHhBdJwOnqXDEig4ngb1KK1UBwjhUbdV.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 10636, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'about-1.webp', 'path' => 'cms/media/originals/pcd3MP3TQ189hurZEXhuzrCEE2uAziXRjvyMGl5r.webp', 'type' => 'image', 'mime_type' => 'image/webp', 'file_size' => 32548, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'art-of-balance.jpg', 'path' => 'cms/media/originals/0cbt8cH8mXsZ7i5juu45WYZDx1QLWKAlfdrXj158.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 30205, 'original_width' => 768, 'original_height' => 512],
|
||||
['filename' => 'daniel-el-titi.jpg', 'path' => 'cms/media/originals/FKKBL4HtByeEB0VxT9vqDGdbbesWWYup23HnuCMb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 23299, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'dogan-nergiz.jpg', 'path' => 'cms/media/originals/HHfjQ0sEFK8bD04e3v8KX6FMKHp5skAY97wAb1Gb.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 27201, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'jana-doepfner.jpg', 'path' => 'cms/media/originals/m7FqrMZonLZXyYIG2L03D7QSzA7y1JRjm9OCnfkX.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 29203, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'jessica-rath.jpg', 'path' => 'cms/media/originals/sOtVnXLtCIeBWFRvcldtHsSXb5yKLQhcwumSqunA.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 28226, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'marcus-thiemann.jpg', 'path' => 'cms/media/originals/79HmDT478Zrv2hPOKsQhRWr288QEhFVgLKY17Rou.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33777, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'markus-kirsch.jpg', 'path' => 'cms/media/originals/TgyErbPn8rOL1Lzsjo42MXoTjFZqdP7t4xmaFAnB.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 33592, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'martina-zeidler.jpg', 'path' => 'cms/media/originals/xVkW0fKZk7Fblbd3hdj00ntC5k6clNS7zHgsyY4S.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 45997, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'peter-bernhards.jpg', 'path' => 'cms/media/originals/8MMBC2jzZzfNjJrKvSbuRTOQDdNgFxquhzecVZlK.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 25726, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'sina-roehrs.jpg', 'path' => 'cms/media/originals/ljf17PgoAMAUse4TQ1FFE6IT6twBU93r2SpC4Ngm.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 44266, 'original_width' => 400, 'original_height' => 400],
|
||||
['filename' => 'post-1.jpeg', 'path' => 'cms/media/originals/5J1FlboQGfQ43gcnTPIBuntrXMkLMCtpIDRoLkro.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 76671, 'original_width' => 800, 'original_height' => 417],
|
||||
['filename' => 'post-2.jpeg', 'path' => 'cms/media/originals/bwlf6A9hTehxMIUqSlUA8274SCaN9PtpKXKhkMYL.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 68640, 'original_width' => 800, 'original_height' => 417],
|
||||
['filename' => 'post-3.jpeg', 'path' => 'cms/media/originals/1tWXALEJxq0UlNpA7ha936ubSxGXonwXTQHTLpKJ.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 67433, 'original_width' => 800, 'original_height' => 417],
|
||||
['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_de.pdf', 'path' => 'cms/media/originals/BAY8u1AiIJx9uXdNqISgLGaXdLV5QiRhV3bQ2EC8.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 285318, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9011_Integrated_Consumer_Research_en.pdf', 'path' => 'cms/media/originals/iSkUqzR600Ujd0lypZFd0eKLrCqGEtN2oFnhiqOZ.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 297608, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9012_MasterData_de.pdf', 'path' => 'cms/media/originals/rhh7d9qWvOnLalBFC7g1ANYJXUBjjB1ZNfFY0yns.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 454633, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9012_MasterData_en.pdf', 'path' => 'cms/media/originals/t9PpREeNXZU8xWVzZLXeqmdunq9JiZGUYNzmY2BX.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 451953, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf', 'path' => 'cms/media/originals/fpFymdm8FwXT0v3XjJY6wks9qGlIBHeWWW8zcypF.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 358498, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9101_GlobalPlayer_en.pdf', 'path' => 'cms/media/originals/ZKwcEebti4yjCNMOfZVimblesZgfncoN8db8lSaO.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 369039, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9102_NationalChampions_en.pdf', 'path' => 'cms/media/originals/flcDISWYm41Cc8mTPs1ERKsmEY2pK1gnLLATOynv.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 466950, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf', 'path' => 'cms/media/originals/UiJ4HznbKtc5QNjZoHfxD8e8fpp2zXP6Ehh17Q5g.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 470648, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf', 'path' => 'cms/media/originals/K1mwmLVYmQGhaxLrgeyBPPOQwBUTjKh1gCR1hbn5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 213462, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Capability_9202_Service_Overview_en.pdf', 'path' => 'cms/media/originals/ABM3X7BRMMpViLKBc9PbpPyi1lFFQBhviIRa53Fp.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 206054, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_de.pdf', 'path' => 'cms/media/originals/ebvunH8d8qhEMamnpmA3Myvck6nOnOXzCdsC4XCr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 437216, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7010_MasterDataExcellence_en.pdf', 'path' => 'cms/media/originals/whR8GmCPX6qCbMoA8T99G7LEkm2oL2Z3YEG8vYg5.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 415218, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_de.pdf', 'path' => 'cms/media/originals/zf1xUGsjiCG7SI4Ci2QiXkojwcwjmsAmqObXCX0W.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 361192, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7011_HairCareRD_ProductSupport_en.pdf', 'path' => 'cms/media/originals/6cr2yF2CekUyEcTz0TzNNcd2fsEEvThutJjHHmpy.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 376542, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_de.pdf', 'path' => 'cms/media/originals/bRu1mHHsUEcv0gLSkoZQPZcUf9Ixr2Y2tbML8K5o.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 402710, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7012_LabSupport_DataIntegration_en.pdf', 'path' => 'cms/media/originals/cB8h1DybKR5gjG4BNfkw4xQAa4K7DSi16sck8XCG.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 380668, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_de.pdf', 'path' => 'cms/media/originals/5yNbrbMGGm6jIC72SNPv6JHHTKWRy0Rf22MyPL0m.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 386255, 'original_width' => null, 'original_height' => null],
|
||||
['filename' => 'inno-projekt-Case_Study_7013_FragrancePump_Experience_en.pdf', 'path' => 'cms/media/originals/wVjrJVHWhY1SwYvYuDhLeFjL00UMPUQ4UeyaISvr.pdf', 'type' => 'pdf', 'mime_type' => 'application/pdf', 'file_size' => 397411, 'original_width' => null, 'original_height' => null],
|
||||
];
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
|
||||
foreach ($mediaItems as $item) {
|
||||
$media = CmsMedia::updateOrCreate(
|
||||
['filename' => $item['filename']],
|
||||
[
|
||||
'path' => $item['path'],
|
||||
'type' => $item['type'],
|
||||
'mime_type' => $item['mime_type'],
|
||||
'file_size' => $item['file_size'],
|
||||
'original_width' => $item['original_width'],
|
||||
'original_height' => $item['original_height'],
|
||||
'disk' => 'public',
|
||||
'is_published' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (
|
||||
$item['type'] === 'image'
|
||||
&& Storage::disk('public')->exists($item['path'])
|
||||
) {
|
||||
$service->generateThumbnail($media);
|
||||
}
|
||||
}
|
||||
|
||||
$imageEntries = [
|
||||
['group' => 'welcome', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'],
|
||||
['group' => 'art-of-balance', 'key' => 'hero.image', 'value' => 'keyvisual-small.webp'],
|
||||
['group' => 'art-of-balance', 'key' => 'integration_image', 'value' => 'integration-process.webp'],
|
||||
['group' => 'kontakt', 'key' => 'hero.image', 'value' => 'kontakt.webp'],
|
||||
['group' => 'faq_page', 'key' => 'hero.image', 'value' => 'kontakt.webp'],
|
||||
['group' => 'case-studies', 'key' => 'hero.image', 'value' => 'case-studies.webp'],
|
||||
['group' => 'capabilities', 'key' => 'hero.image', 'value' => 'capabilities.webp'],
|
||||
['group' => 'nationale-champions', 'key' => 'hero.image', 'value' => 'nationale-champions.webp'],
|
||||
['group' => 'global-player', 'key' => 'hero.image', 'value' => 'global-player.webp'],
|
||||
['group' => 'leistungen', 'key' => 'hero.image', 'value' => 'leistungen.webp'],
|
||||
['group' => 'leistungen', 'key' => 'feature_image', 'value' => 'leistung-1.webp'],
|
||||
['group' => 'karriere', 'key' => 'hero.image', 'value' => 'karriere.webp'],
|
||||
['group' => 'about', 'key' => 'hero.image', 'value' => 'about-1.webp'],
|
||||
['group' => 'team', 'key' => 'hero.image', 'value' => 'team.webp'],
|
||||
['group' => 'about', 'key' => 'preview_image', 'value' => 'about-team.webp'],
|
||||
['group' => 'digitale-transformation', 'key' => 'hero.image', 'value' => 'leistung-5.webp'],
|
||||
['group' => 'master-data', 'key' => 'hero.image', 'value' => 'leistung-2.webp'],
|
||||
['group' => 'nachhaltige-verpackungen', 'key' => 'hero.image', 'value' => 'leistung-3.webp'],
|
||||
['group' => 'prozess-optimierung', 'key' => 'hero.image', 'value' => 'leistung-4.webp'],
|
||||
['group' => 'strategische-projektumsetzung', 'key' => 'hero.image', 'value' => 'leistung-1.webp'],
|
||||
];
|
||||
|
||||
foreach ($imageEntries as $entry) {
|
||||
$content = CmsContent::updateOrCreate(
|
||||
['group' => $entry['group'], 'key' => $entry['key']],
|
||||
['type' => 'image']
|
||||
);
|
||||
$content->setTranslation('value', 'de', $entry['value']);
|
||||
$content->setTranslation('value', 'en', $entry['value']);
|
||||
$content->save();
|
||||
}
|
||||
|
||||
app(\FluxCms\Core\Services\CmsContentService::class)->clearCache();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsNewsItemSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $imageMapping = [
|
||||
'/assets/images/capability-overview_of_services.jpg' => 'capability-overview_of_services.webp',
|
||||
'/assets/images/capability-global-player.jpg' => 'capability-global-player.webp',
|
||||
'/assets/images/capability-national-champions.jpg' => 'capability-national-champions.webp',
|
||||
'/assets/images/story-taob.jpg?v1' => 'story-taob.webp',
|
||||
'/assets/images/story-taob.jpg' => 'story-taob.webp',
|
||||
'/assets/images/story-logo.jpg' => 'story-logo.webp',
|
||||
'/assets/images/leistung-4.jpg' => 'leistung-4.webp',
|
||||
'/assets/images/leistung-2.jpg' => 'leistung-2.webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $pdfMapping = [
|
||||
'pdfs/inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf' => 'inno-projekt-Capability_9202_Leistungsuebersicht_de.pdf',
|
||||
'pdfs/inno-projekt-Capability_9101_GlobalPlayer_de.pdf' => 'inno-projekt-Capability_9101_GlobalPlayer_de.pdf',
|
||||
'pdfs/inno-projekt-Capability_9102_NationaleChampions_de.pdf' => 'inno-projekt-Capability_9102_NationaleChampions_de.pdf',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
$itemsByLocale = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$path = lang_path("{$locale}/components.php");
|
||||
if (file_exists($path)) {
|
||||
$data = require $path;
|
||||
$itemsByLocale[$locale] = $data['news_band']['items'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$deItems = $itemsByLocale['de'] ?? [];
|
||||
$enItems = $itemsByLocale['en'] ?? [];
|
||||
|
||||
$maxCount = max(count($deItems), count($enItems));
|
||||
|
||||
for ($i = 0; $i < $maxCount; $i++) {
|
||||
$de = $deItems[$i] ?? [];
|
||||
$en = $enItems[$i] ?? [];
|
||||
|
||||
$rawImage = $de['image'] ?? $en['image'] ?? null;
|
||||
$rawPdf = $de['pdf_path'] ?? $en['pdf_path'] ?? null;
|
||||
|
||||
CmsNewsItem::updateOrCreate(
|
||||
['order' => $i],
|
||||
[
|
||||
'icon' => $de['icon'] ?? $en['icon'] ?? null,
|
||||
'text' => array_filter(['de' => $de['text'] ?? null, 'en' => $en['text'] ?? null]),
|
||||
'title' => array_filter(['de' => $de['title'] ?? null, 'en' => $en['title'] ?? null]),
|
||||
'excerpt' => array_filter(['de' => $de['excerpt'] ?? null, 'en' => $en['excerpt'] ?? null]),
|
||||
'content' => array_filter(['de' => $de['content'] ?? null, 'en' => $en['content'] ?? null]),
|
||||
'image' => $this->resolveImage($rawImage),
|
||||
'date' => $de['date'] ?? $en['date'] ?? null,
|
||||
'author' => $de['author'] ?? $en['author'] ?? null,
|
||||
'link' => $de['link'] ?? $en['link'] ?? null,
|
||||
'pdf_path' => $this->resolvePdf($rawPdf),
|
||||
'pdf_open_text' => array_filter(['de' => $de['pdf_open_text'] ?? null, 'en' => $en['pdf_open_text'] ?? null]),
|
||||
'pdf_download_text' => array_filter(['de' => $de['pdf_download_text'] ?? null, 'en' => $en['pdf_download_text'] ?? null]),
|
||||
'is_published' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveImage(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->imageMapping[$path] ?? pathinfo($path, PATHINFO_FILENAME).'.webp';
|
||||
}
|
||||
|
||||
private function resolvePdf(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->pdfMapping[$path] ?? basename($path);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsSearchIndex;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class CmsSearchIndexSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$deItems = $this->loadItems('de');
|
||||
$enItems = $this->loadItems('en');
|
||||
|
||||
$deById = collect($deItems)->keyBy('id');
|
||||
$enById = collect($enItems)->keyBy('id');
|
||||
|
||||
$allIds = $deById->keys()->merge($enById->keys())->unique();
|
||||
|
||||
$order = 0;
|
||||
foreach ($allIds as $itemId) {
|
||||
$de = $deById->get($itemId, []);
|
||||
$en = $enById->get($itemId, []);
|
||||
$source = ! empty($de) ? $de : $en;
|
||||
|
||||
CmsSearchIndex::updateOrCreate(
|
||||
['item_id' => $itemId],
|
||||
[
|
||||
'route' => $source['route'] ?? '',
|
||||
'route_params' => $source['route_params'] ?? [],
|
||||
'category' => [
|
||||
'de' => $de['category'] ?? ($en['category'] ?? ''),
|
||||
'en' => $en['category'] ?? ($de['category'] ?? ''),
|
||||
],
|
||||
'title_key' => $source['title_key'] ?? null,
|
||||
'title_fallback' => [
|
||||
'de' => $de['title_fallback'] ?? null,
|
||||
'en' => $en['title_fallback'] ?? null,
|
||||
],
|
||||
'description_key' => $source['description_key'] ?? null,
|
||||
'description_fallback_key' => $source['description_fallback_key'] ?? null,
|
||||
'description_fallback_text' => [
|
||||
'de' => $de['description_fallback_text'] ?? null,
|
||||
'en' => $en['description_fallback_text'] ?? null,
|
||||
],
|
||||
'keywords' => [
|
||||
'de' => $de['keywords'] ?? [],
|
||||
'en' => $en['keywords'] ?? [],
|
||||
],
|
||||
'is_published' => true,
|
||||
'order' => $order++,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info('CmsSearchIndexSeeder: '.$allIds->count().' Eintraege erstellt/aktualisiert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function loadItems(string $locale): array
|
||||
{
|
||||
$path = lang_path("{$locale}/search_index.php");
|
||||
if (! File::exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = require $path;
|
||||
|
||||
return $config['items'] ?? [];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace FluxCms\Core\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
|
|
@ -27,19 +27,19 @@ class CmsContentSeeder extends Seeder
|
|||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Willkommen auf unserer Website',
|
||||
'en' => 'Welcome to our Website'
|
||||
'en' => 'Welcome to our Website',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/',
|
||||
'en' => '/'
|
||||
'en' => '/',
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Willkommen auf unserer modernen Website, erstellt mit Flux CMS.',
|
||||
'en' => 'Welcome to our modern website, built with Flux CMS.'
|
||||
'en' => 'Welcome to our modern website, built with Flux CMS.',
|
||||
],
|
||||
'meta_keywords' => [
|
||||
'de' => 'Website, CMS, Flux CMS, Laravel',
|
||||
'en' => 'Website, CMS, Flux CMS, Laravel'
|
||||
'en' => 'Website, CMS, Flux CMS, Laravel',
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
|
|
@ -50,15 +50,15 @@ class CmsContentSeeder extends Seeder
|
|||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Über uns',
|
||||
'en' => 'About us'
|
||||
'en' => 'About us',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/ueber-uns',
|
||||
'en' => '/about'
|
||||
'en' => '/about',
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Erfahren Sie mehr über unser Unternehmen und unsere Mission.',
|
||||
'en' => 'Learn more about our company and our mission.'
|
||||
'en' => 'Learn more about our company and our mission.',
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
|
|
@ -69,15 +69,15 @@ class CmsContentSeeder extends Seeder
|
|||
'domain_key' => 'default',
|
||||
'title' => [
|
||||
'de' => 'Kontakt',
|
||||
'en' => 'Contact'
|
||||
'en' => 'Contact',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => '/kontakt',
|
||||
'en' => '/contact'
|
||||
'en' => '/contact',
|
||||
],
|
||||
'meta_description' => [
|
||||
'de' => 'Kontaktieren Sie uns für weitere Informationen.',
|
||||
'en' => 'Contact us for more information.'
|
||||
'en' => 'Contact us for more information.',
|
||||
],
|
||||
'is_published' => true,
|
||||
'published_at' => now(),
|
||||
|
|
@ -95,19 +95,19 @@ class CmsContentSeeder extends Seeder
|
|||
[
|
||||
'title' => [
|
||||
'de' => 'Willkommen bei Flux CMS',
|
||||
'en' => 'Welcome to Flux CMS'
|
||||
'en' => 'Welcome to Flux CMS',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'willkommen-bei-flux-cms',
|
||||
'en' => 'welcome-to-flux-cms'
|
||||
'en' => 'welcome-to-flux-cms',
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Flux CMS ist ein modernes, komponentenbasiertes Content Management System für Laravel.',
|
||||
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.'
|
||||
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.',
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Flux CMS revolutioniert die Art, wie Sie Inhalte verwalten. Mit seiner einzigartigen "Code-as-Schema" Philosophie definieren Sie Inhaltsstrukturen direkt in PHP-Komponenten.</p><p>Dies bietet beispiellose Flexibilität und eine hervorragende Entwicklererfahrung.</p>',
|
||||
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>'
|
||||
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>',
|
||||
],
|
||||
'category' => 'News',
|
||||
'tags' => ['CMS', 'Laravel', 'Flux'],
|
||||
|
|
@ -118,19 +118,19 @@ class CmsContentSeeder extends Seeder
|
|||
[
|
||||
'title' => [
|
||||
'de' => 'Multi-Domain Support',
|
||||
'en' => 'Multi-Domain Support'
|
||||
'en' => 'Multi-Domain Support',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'multi-domain-support',
|
||||
'en' => 'multi-domain-support'
|
||||
'en' => 'multi-domain-support',
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Verwalten Sie mehrere Websites von einer Installation aus.',
|
||||
'en' => 'Manage multiple websites from one installation.'
|
||||
'en' => 'Manage multiple websites from one installation.',
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Mit Flux CMS können Sie mehrere Domains von einer einzigen Installation aus verwalten. Jede Domain kann ihre eigenen Inhalte, Designs und Einstellungen haben.</p>',
|
||||
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>'
|
||||
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>',
|
||||
],
|
||||
'category' => 'Features',
|
||||
'tags' => ['Multi-Domain', 'Features'],
|
||||
|
|
@ -141,19 +141,19 @@ class CmsContentSeeder extends Seeder
|
|||
[
|
||||
'title' => [
|
||||
'de' => 'Komponenten-First Architektur',
|
||||
'en' => 'Component-First Architecture'
|
||||
'en' => 'Component-First Architecture',
|
||||
],
|
||||
'slug' => [
|
||||
'de' => 'komponenten-first-architektur',
|
||||
'en' => 'component-first-architecture'
|
||||
'en' => 'component-first-architecture',
|
||||
],
|
||||
'excerpt' => [
|
||||
'de' => 'Bauen Sie Seiten aus wiederverwendbaren Livewire-Komponenten.',
|
||||
'en' => 'Build pages from reusable Livewire components.'
|
||||
'en' => 'Build pages from reusable Livewire components.',
|
||||
],
|
||||
'content' => [
|
||||
'de' => '<p>Die Komponenten-First Architektur von Flux CMS ermöglicht es Ihnen, komplexe Seiten aus kleinen, wiederverwendbaren Komponenten zu erstellen.</p><p>Jede Komponente kann ihre eigenen Felder und Validierungsregeln definieren.</p>',
|
||||
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>'
|
||||
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>',
|
||||
],
|
||||
'category' => 'Architecture',
|
||||
'tags' => ['Components', 'Livewire', 'Architecture'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'selectedGroup' => null,
|
||||
'search' => '',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'editValue' => '',
|
||||
'editMediaId' => null,
|
||||
'showJsonModal' => false,
|
||||
'jsonItems' => [],
|
||||
'jsonIsStringArray' => false,
|
||||
'jsonEditingKey' => '',
|
||||
]);
|
||||
|
||||
on(['media-selected' => function ($mediaId, $url, $field) {
|
||||
if ($field !== 'content_image') {
|
||||
return;
|
||||
}
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
$this->editValue = $media->filename;
|
||||
$this->editMediaId = $mediaId;
|
||||
}
|
||||
}]);
|
||||
|
||||
$groups = computed(fn() => CmsContent::query()->selectRaw('`group`, count(*) as count')->groupBy('group')->orderBy('group')->pluck('count', 'group')->toArray());
|
||||
|
||||
$contents = computed(fn() => $this->selectedGroup ? CmsContent::forGroup($this->selectedGroup)->when($this->search, fn($q) => $q->where('key', 'like', "%{$this->search}%"))->orderBy('order')->get() : collect());
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$selectGroup = function (string $group) {
|
||||
$this->selectedGroup = $group;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$content = CmsContent::find($id);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $id;
|
||||
|
||||
if ($content->type === 'json') {
|
||||
$value = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($value)) {
|
||||
$value = [];
|
||||
}
|
||||
|
||||
$this->jsonEditingKey = $content->key;
|
||||
|
||||
if (!empty($value) && !is_array($value[0] ?? null)) {
|
||||
$this->jsonIsStringArray = true;
|
||||
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
|
||||
}
|
||||
|
||||
$this->showJsonModal = true;
|
||||
} else {
|
||||
$this->editValue = $content->getTranslation('value', $this->editLocale) ?? '';
|
||||
if (is_array($this->editValue)) {
|
||||
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if ($content->type === 'image') {
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} else {
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($content->type === 'json') {
|
||||
$value = $content->getTranslation('value', $locale);
|
||||
if (!is_array($value)) {
|
||||
$value = [];
|
||||
}
|
||||
|
||||
if (!empty($value) && !is_array($value[0] ?? null)) {
|
||||
$this->jsonIsStringArray = true;
|
||||
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
|
||||
}
|
||||
} else {
|
||||
$this->editValue = $content->getTranslation('value', $locale) ?? '';
|
||||
if (is_array($this->editValue)) {
|
||||
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addJsonItem = function () {
|
||||
if ($this->jsonIsStringArray) {
|
||||
$this->jsonItems[] = ['_value' => ''];
|
||||
} elseif (!empty($this->jsonItems)) {
|
||||
$template = array_map(fn() => '', $this->jsonItems[0]);
|
||||
$this->jsonItems[] = $template;
|
||||
}
|
||||
};
|
||||
|
||||
$removeJsonItem = function (int $index) {
|
||||
unset($this->jsonItems[$index]);
|
||||
$this->jsonItems = array_values($this->jsonItems);
|
||||
};
|
||||
|
||||
$saveJsonModal = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->jsonIsStringArray) {
|
||||
$value = array_values(array_map(fn($item) => $item['_value'] ?? '', $this->jsonItems));
|
||||
} else {
|
||||
$value = array_values(
|
||||
array_map(function ($item) {
|
||||
$cleaned = [];
|
||||
foreach ($item as $k => $v) {
|
||||
if (str_starts_with($v, '[') || str_starts_with($v, '{')) {
|
||||
$decoded = json_decode($v, true);
|
||||
$cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;
|
||||
} else {
|
||||
$cleaned[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $cleaned;
|
||||
}, $this->jsonItems),
|
||||
);
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, $value);
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->jsonItems = [];
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'JSON-Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, $this->editValue);
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->editingId = null;
|
||||
$this->editValue = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$cancelEdit = fn() => ($this->editingId = null);
|
||||
|
||||
$cancelJsonModal = function () {
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->jsonItems = [];
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Inhalte verwalten</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge color="blue">{{ array_sum($this->groups) }} Einträge</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{{-- Sidebar: Groups --}}
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach ($this->groups as $group => $count)
|
||||
<button wire:click="selectGroup('{{ $group }}')"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||
<span>{{ $group }}</span>
|
||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Main: Content Editor --}}
|
||||
<div class="lg:col-span-3">
|
||||
@if ($selectedGroup)
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="lg">{{ $selectedGroup }}</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
|
||||
icon="magnifying-glass" class="w-48" />
|
||||
<div class="flex gap-1">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">
|
||||
{{ strtoupper($code) }}
|
||||
</flux:button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->contents as $content)
|
||||
<div wire:key="content-{{ $content->id }}" class="py-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-zinc-100 text-zinc-400 px-1.5 py-0.5 text-xs dark:bg-zinc-700 dark:text-zinc-400">{{ $content->key }}</code>
|
||||
<flux:badge size="sm"
|
||||
:color="match($content->type) { 'html' => 'amber', 'image' => 'green', 'json' => 'violet', 'link' => 'rose', default => 'zinc' }">
|
||||
{{ $content->type }}</flux:badge>
|
||||
</div>
|
||||
|
||||
@if ($editingId === $content->id && $content->type === 'image')
|
||||
<div class="mt-2">
|
||||
<div class="flex items-start gap-4">
|
||||
@if ($editValue)
|
||||
<div class="h-24 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($editValue) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="$editMediaId"
|
||||
field="content_image"
|
||||
type="image"
|
||||
profile="thumbnail"
|
||||
label="Bild wählen"
|
||||
:key="'content-img-' . $editingId"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $editValue ?: 'Kein Bild ausgewählt' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit">
|
||||
Speichern</flux:button>
|
||||
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
|
||||
Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($editingId === $content->id && $content->type !== 'json')
|
||||
<div class="mt-2">
|
||||
@if (in_array($selectedGroup, ['datenschutz', 'impressum']))
|
||||
<flux:editor wire:model="editValue"
|
||||
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
@else
|
||||
<flux:editor wire:model="editValue" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
@endif
|
||||
<div class="mt-2 flex gap-2">
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit">
|
||||
Speichern</flux:button>
|
||||
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
|
||||
Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$displayValue = $content->getTranslation('value', $editLocale);
|
||||
$displayStr = is_array($displayValue)
|
||||
? json_encode($displayValue, JSON_UNESCAPED_UNICODE)
|
||||
: (string) $displayValue;
|
||||
@endphp
|
||||
@if ($content->type === 'image')
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($displayStr)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($displayStr) }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
@endif
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ $displayStr ?: 'Kein Bild' }}</span>
|
||||
</div>
|
||||
@elseif ($content->type === 'json')
|
||||
@php
|
||||
$jsonVal = $content->getTranslation('value', $editLocale);
|
||||
$itemCount = is_array($jsonVal) ? count($jsonVal) : 0;
|
||||
$firstItem =
|
||||
is_array($jsonVal) && !empty($jsonVal) ? $jsonVal[0] : null;
|
||||
$isObjects = is_array($firstItem);
|
||||
@endphp
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<flux:badge size="sm" color="violet">{{ $itemCount }}
|
||||
Einträge</flux:badge>
|
||||
@if ($isObjects && is_array($firstItem))
|
||||
<span class="text-xs text-zinc-400">Felder:
|
||||
{{ implode(', ', array_keys($firstItem)) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($content->type === 'html')
|
||||
<div
|
||||
class="prose prose-sm max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200 line-clamp-2">
|
||||
{!! \Illuminate\Support\Str::limit($displayStr, 200) !!}
|
||||
</div>
|
||||
@else
|
||||
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags($displayStr), 120) }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($editingId !== $content->id)
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="startEdit({{ $content->id }})" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Einträge gefunden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="py-12 text-center">
|
||||
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Seite auswählen</flux:heading>
|
||||
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- JSON Editor Modal --}}
|
||||
<flux:modal wire:model="showJsonModal" class="w-full max-w-5xl space-y-6 overflow-y-auto max-h-[90vh]">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
|
||||
<flux:text class="mt-1">
|
||||
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
|
||||
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach ($jsonItems as $idx => $item)
|
||||
<div wire:key="json-item-{{ $idx }}"
|
||||
class="rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-500">Eintrag {{ $idx + 1 }}</span>
|
||||
<flux:button size="xs" variant="ghost" icon="trash"
|
||||
wire:click="removeJsonItem({{ $idx }})"
|
||||
wire:confirm="Eintrag {{ $idx + 1 }} wirklich entfernen?" />
|
||||
</div>
|
||||
|
||||
@if ($jsonIsStringArray)
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}._value" placeholder="Wert" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@foreach ($item as $field => $fieldValue)
|
||||
@php
|
||||
$isIcon = in_array($field, ['icon']);
|
||||
$isRichText = in_array($field, [
|
||||
'description',
|
||||
'text',
|
||||
'content',
|
||||
'help',
|
||||
'answer',
|
||||
'quote',
|
||||
]);
|
||||
$isLongText = in_array($field, ['tagline']);
|
||||
$isNestedJson =
|
||||
is_string($fieldValue) &&
|
||||
(str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
|
||||
@endphp
|
||||
|
||||
@if ($isIcon)
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select
|
||||
wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
variant="listbox" searchable label="{{ ucfirst($field) }}"
|
||||
placeholder="Icon auswählen...">
|
||||
<flux:select.option value="">— Kein Icon —
|
||||
</flux:select.option>
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">
|
||||
{{ $iconName }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if (!empty($fieldValue))
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $fieldValue"
|
||||
class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($isRichText)
|
||||
<div class="md:col-span-2">
|
||||
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
@elseif ($isNestedJson)
|
||||
<div class="md:col-span-2">
|
||||
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }} (JSON)" rows="3"
|
||||
class="font-mono text-xs" />
|
||||
</div>
|
||||
@else
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-700 pt-4">
|
||||
<flux:button size="sm" variant="ghost" icon="plus" wire:click="addJsonItem">
|
||||
Eintrag hinzufügen
|
||||
</flux:button>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="ghost" wire:click="cancelJsonModal">Abbrechen</flux:button>
|
||||
<flux:button variant="primary" wire:click="saveJsonModal">Speichern</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsDownload;
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
use function Livewire\Volt\{computed};
|
||||
|
||||
$stats = computed(
|
||||
fn() => [
|
||||
'contents' => CmsContent::count(),
|
||||
'groups' => CmsContent::distinct()->pluck('group')->count(),
|
||||
'news' => CmsNewsItem::count(),
|
||||
'industries' => CmsIndustry::count(),
|
||||
'faqs' => CmsFaq::count(),
|
||||
'linkedin' => CmsLinkedinPost::count(),
|
||||
'downloads' => CmsDownload::count(),
|
||||
],
|
||||
);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-6">CMS Dashboard</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="{{ route('cms.content.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-blue-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="document-text" class="text-blue-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['contents'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Inhalte in {{ $this->stats['groups'] }} Gruppen</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.news.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-green-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="newspaper" class="text-green-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['news'] }}</flux:heading>
|
||||
<flux:text class="text-sm">News Items</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.faqs.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-amber-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="question-mark-circle" class="text-amber-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['faqs'] }}</flux:heading>
|
||||
<flux:text class="text-sm">FAQ Einträge</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.linkedin.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-sky-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="chat-bubble-left-right" class="text-sky-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['linkedin'] }}</flux:heading>
|
||||
<flux:text class="text-sm">LinkedIn Beiträge</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.industries.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-violet-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="building-office" class="text-violet-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['industries'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Industries</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.downloads.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-rose-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="arrow-down-tray" class="text-rose-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['downloads'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Downloads</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsDownload;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'filterCategory' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'category' => 'case_study',
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => '',
|
||||
'type_label' => '',
|
||||
'alt' => '',
|
||||
'file_path' => '',
|
||||
'fileMediaId' => null,
|
||||
'thumbnail' => '',
|
||||
'thumbMediaId' => null,
|
||||
'open_text' => '',
|
||||
'download_text' => '',
|
||||
'highlights' => [],
|
||||
'checkpoints' => [],
|
||||
]);
|
||||
|
||||
$downloads = computed(function () {
|
||||
$query = CmsDownload::ordered();
|
||||
if ($this->filterCategory) {
|
||||
$query->byCategory($this->filterCategory);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
});
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'title', 'description', 'icon', 'sub_category', 'type_label', 'alt', 'file_path', 'fileMediaId', 'thumbnail', 'thumbMediaId', 'open_text', 'download_text', 'highlights', 'checkpoints']);
|
||||
$this->category = 'case_study';
|
||||
$this->icon = 'document-text';
|
||||
$this->highlights = [];
|
||||
$this->checkpoints = [];
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$dl = CmsDownload::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->title = $dl->getTranslation('title', $l) ?? '';
|
||||
$this->description = $dl->getTranslation('description', $l) ?? '';
|
||||
$this->category = $dl->category;
|
||||
$this->icon = $dl->icon ?? 'document-text';
|
||||
$this->sub_category = $dl->sub_category ?? '';
|
||||
$this->type_label = $dl->getTranslation('type_label', $l) ?? '';
|
||||
$this->alt = $dl->getTranslation('alt', $l) ?? '';
|
||||
$this->file_path = $dl->getTranslation('file_path', $l) ?? '';
|
||||
$this->thumbnail = $dl->thumbnail ?? '';
|
||||
$this->open_text = $dl->getTranslation('open_text', $l) ?? '';
|
||||
$this->download_text = $dl->getTranslation('download_text', $l) ?? '';
|
||||
$this->highlights = is_array($dl->highlights) ? $dl->highlights : [];
|
||||
$this->checkpoints = is_array($dl->checkpoints) ? $dl->checkpoints : [];
|
||||
$this->thumbMediaId = $this->thumbnail ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->thumbnail)->first()?->id : null;
|
||||
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsDownload::find($this->editingId) : null;
|
||||
$merge = function (string $field, ?string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value ?? '';
|
||||
|
||||
return $t;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'title' => $merge('title', $this->title),
|
||||
'description' => $merge('description', $this->description),
|
||||
'category' => $this->category,
|
||||
'icon' => $this->icon,
|
||||
'sub_category' => $this->sub_category,
|
||||
'type_label' => $merge('type_label', $this->type_label),
|
||||
'alt' => $merge('alt', $this->alt),
|
||||
'file_path' => $merge('file_path', $this->file_path),
|
||||
'thumbnail' => $this->thumbnail,
|
||||
'open_text' => $merge('open_text', $this->open_text),
|
||||
'download_text' => $merge('download_text', $this->download_text),
|
||||
'highlights' => array_values(array_filter($this->highlights, fn($h) => !empty($h['value']) || !empty($h['label']))),
|
||||
'checkpoints' => array_values(array_filter($this->checkpoints, fn($c) => !empty($c['value']))),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsDownload::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsDownload::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Download wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsDownload::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Download wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$dl = CmsDownload::findOrFail($id);
|
||||
$dl->update(['is_published' => !$dl->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $dl->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$dl = CmsDownload::find($this->editingId);
|
||||
if ($dl) {
|
||||
$this->title = $dl->getTranslation('title', $locale) ?? '';
|
||||
$this->description = $dl->getTranslation('description', $locale) ?? '';
|
||||
$this->type_label = $dl->getTranslation('type_label', $locale) ?? '';
|
||||
$this->alt = $dl->getTranslation('alt', $locale) ?? '';
|
||||
$this->file_path = $dl->getTranslation('file_path', $locale) ?? '';
|
||||
$this->open_text = $dl->getTranslation('open_text', $locale) ?? '';
|
||||
$this->download_text = $dl->getTranslation('download_text', $locale) ?? '';
|
||||
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$addHighlight = function () {
|
||||
$this->highlights[] = ['value' => '', 'label' => ''];
|
||||
};
|
||||
|
||||
$removeHighlight = function (int $index) {
|
||||
unset($this->highlights[$index]);
|
||||
$this->highlights = array_values($this->highlights);
|
||||
};
|
||||
|
||||
$addCheckpoint = function () {
|
||||
$this->checkpoints[] = ['value' => ''];
|
||||
};
|
||||
|
||||
$removeCheckpoint = function (int $index) {
|
||||
unset($this->checkpoints[$index]);
|
||||
$this->checkpoints = array_values($this->checkpoints);
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$items = CmsDownload::ordered()->get();
|
||||
$idx = $items->search(fn($i) => $i->id === $id);
|
||||
if ($idx > 0) {
|
||||
$prev = $items[$idx - 1];
|
||||
$curr = $items[$idx];
|
||||
[$prev->order, $curr->order] = [$curr->order, $prev->order];
|
||||
$prev->save();
|
||||
$curr->save();
|
||||
}
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$items = CmsDownload::ordered()->get();
|
||||
$idx = $items->search(fn($i) => $i->id === $id);
|
||||
if ($idx !== false && $idx < $items->count() - 1) {
|
||||
$next = $items[$idx + 1];
|
||||
$curr = $items[$idx];
|
||||
[$next->order, $curr->order] = [$curr->order, $next->order];
|
||||
$next->save();
|
||||
$curr->save();
|
||||
}
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'dl_file') {
|
||||
$this->fileMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->file_path = $media ? $media->filename : '';
|
||||
} elseif ($field === 'dl_thumb') {
|
||||
$this->thumbMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->thumbnail = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Downloads</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Category Filter --}}
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<flux:button size="xs" :variant="$filterCategory === '' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', '')">Alle</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'case_study' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'case_study')">Case Studies</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'capability' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'capability')">Capabilities</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'success_story' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'success_story')">Success Stories</flux:button>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer Download' }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:select wire:model="category" label="Kategorie">
|
||||
<flux:select.option value="case_study">Case Study</flux:select.option>
|
||||
<flux:select.option value="capability">Capability</flux:select.option>
|
||||
<flux:select.option value="success_story">Success Story</flux:select.option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="sub_category" label="Unterkategorie" placeholder="z.B. R&D Product Support" />
|
||||
<flux:input wire:model="type_label" label="Typ-Label" placeholder="z.B. Case Study" />
|
||||
<flux:input wire:model="alt" label="Alt-Text (Bild)" />
|
||||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
|
||||
placeholder="Icon auswählen...">
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if ($icon)
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Vorschaubild + PDF --}}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Vorschaubild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($thumbnail)
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($thumbnail) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="$thumbMediaId" field="dl_thumb" type="image"
|
||||
profile="thumbnail" label="Bild wählen" :key="'dl-thumb-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $thumbnail ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">PDF-Datei ({{ strtoupper($editLocale) }})</label>
|
||||
@if ($file_path)
|
||||
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<iframe src="{{ media_url($file_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
<p class="mb-1 text-xs text-zinc-400">{{ $file_path }}</p>
|
||||
@endif
|
||||
<livewire:admin.cms.media-picker :value="$fileMediaId" field="dl_file" type="pdf" profile=""
|
||||
label="PDF wählen" :key="'dl-file-' . ($editingId ?? 'new') . '-' . $editLocale" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Button-Texte --}}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="open_text" label="PDF öffnen Text" placeholder="PDF öffnen" />
|
||||
<flux:input wire:model="download_text" label="PDF downloaden Text" placeholder="PDF downloaden" />
|
||||
</div>
|
||||
|
||||
{{-- Beschreibung --}}
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="description" label="Beschreibung" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
</div>
|
||||
|
||||
{{-- Highlights (Case Studies / Success Stories) --}}
|
||||
@if ($category === 'case_study' || $category === 'success_story')
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium">Highlights (Kennzahlen)</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($highlights as $hIdx => $highlight)
|
||||
<div wire:key="hl-{{ $hIdx }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="highlights.{{ $hIdx }}.value"
|
||||
placeholder="Wert (z.B. 100%)" class="w-32!" />
|
||||
<flux:input wire:model="highlights.{{ $hIdx }}.label" placeholder="Label"
|
||||
class="flex-1!" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="removeHighlight({{ $hIdx }})" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight"
|
||||
class="mt-2">
|
||||
Highlight hinzufügen</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Checkpoints (Capabilities) --}}
|
||||
@if ($category === 'capability')
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium">Checkpoints</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($checkpoints as $cIdx => $checkpoint)
|
||||
<div wire:key="cp-{{ $cIdx }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="checkpoints.{{ $cIdx }}.value"
|
||||
placeholder="Checkpoint-Text" class="flex-1!" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="removeCheckpoint({{ $cIdx }})" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addCheckpoint"
|
||||
class="mt-2">Checkpoint hinzufügen</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->downloads as $dl)
|
||||
<div wire:key="dl-{{ $dl->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($dl->thumbnail)
|
||||
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($dl->thumbnail) }}" alt=""
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@elseif ($dl->icon)
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $dl->icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ $dl->getTranslation('title', $editLocale) }}</span>
|
||||
<flux:badge size="sm"
|
||||
:color="$dl->category === 'case_study' ? 'blue' : ($dl->category === 'capability' ? 'green' : 'purple')">
|
||||
{{ $dl->category === 'case_study' ? 'Case Study' : ($dl->category === 'capability' ? 'Capability' : 'Success Story') }}
|
||||
</flux:badge>
|
||||
@unless ($dl->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="truncate text-sm text-zinc-500">
|
||||
{{ $dl->sub_category }}
|
||||
@if ($dl->getTranslation('file_path', $editLocale))
|
||||
· {{ $dl->getTranslation('file_path', $editLocale) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-up"
|
||||
wire:click="moveUp({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-down"
|
||||
wire:click="moveDown({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$dl->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $dl->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Downloads vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'selectedCategory' => null,
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'question' => '',
|
||||
'answer' => '',
|
||||
'help' => '',
|
||||
'category' => '',
|
||||
]);
|
||||
|
||||
$categories = computed(fn() => CmsFaq::query()->selectRaw('category, count(*) as count')->groupBy('category')->orderBy('category')->pluck('count', 'category')->toArray());
|
||||
|
||||
$faqs = computed(fn() => $this->selectedCategory ? CmsFaq::byCategory($this->selectedCategory)->ordered()->get() : collect());
|
||||
|
||||
$selectCategory = function (string $cat) {
|
||||
$this->selectedCategory = $cat;
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$create = function () {
|
||||
$this->resetForm();
|
||||
$this->category = $this->selectedCategory ?? '';
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$faq = CmsFaq::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->question = $faq->getTranslation('question', $l) ?? '';
|
||||
$this->answer = $faq->getTranslation('answer', $l) ?? '';
|
||||
$this->help = $faq->getTranslation('help', $l) ?? '';
|
||||
$this->category = $faq->category;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsFaq::find($this->editingId) : null;
|
||||
|
||||
$data = [
|
||||
'category' => $this->category,
|
||||
'question' => $this->mergeTranslation($existing, 'question', $this->question),
|
||||
'answer' => $this->mergeTranslation($existing, 'answer', $this->answer),
|
||||
'help' => $this->help ? $this->mergeTranslation($existing, 'help', $this->help) : null,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsFaq::where('category', $this->category)->max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsFaq::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'FAQ wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsFaq::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'FAQ wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$faq = CmsFaq::findOrFail($id);
|
||||
$faq->update(['is_published' => !$faq->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $faq->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$faq = CmsFaq::find($this->editingId);
|
||||
if ($faq) {
|
||||
$this->question = $faq->getTranslation('question', $locale) ?? '';
|
||||
$this->answer = $faq->getTranslation('answer', $locale) ?? '';
|
||||
$this->help = $faq->getTranslation('help', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$resetForm = function () {
|
||||
$this->editingId = null;
|
||||
$this->question = '';
|
||||
$this->answer = '';
|
||||
$this->help = '';
|
||||
};
|
||||
|
||||
$mergeTranslation = function (?CmsFaq $model, string $field, string $value): array {
|
||||
$existing = $model ? $model->getTranslations($field) : [];
|
||||
$existing[$this->editLocale] = $value;
|
||||
return $existing;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">FAQs</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">Kategorien</flux:heading>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach ($this->categories as $cat => $count)
|
||||
<button wire:click="selectCategory('{{ $cat }}')"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedCategory === $cat ? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||
<span>{{ $cat }}</span>
|
||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'FAQ bearbeiten' : 'Neue FAQ' }}
|
||||
</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="category" label="Kategorie" />
|
||||
<flux:input wire:model="question" label="Frage" />
|
||||
<flux:editor wire:model="answer" label="Antwort" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
<flux:editor wire:model="help" label="Hilfe-Text (optional)" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
@if ($selectedCategory)
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ $selectedCategory }}</flux:heading>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->faqs as $faq)
|
||||
<div wire:key="faq-{{ $faq->id }}"
|
||||
class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">{{ $faq->getTranslation('question', $editLocale) }}</p>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags($faq->getTranslation('answer', $editLocale) ?? ''), 100) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $faq->id }})" />
|
||||
<flux:button size="sm" variant="ghost"
|
||||
:icon="$faq->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $faq->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $faq->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine FAQs in dieser Kategorie.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="py-12 text-center">
|
||||
<flux:icon name="question-mark-circle" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Kategorie auswählen</flux:heading>
|
||||
<flux:text>Wähle links eine Kategorie, um FAQs zu bearbeiten.</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'name' => '',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$industries = computed(fn () => CmsIndustry::ordered()->get());
|
||||
|
||||
$create = function () {
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
$this->order = CmsIndustry::max('order') + 1;
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$item = CmsIndustry::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->name = $item->getTranslation('name', $this->editLocale) ?? '';
|
||||
$this->order = $item->order;
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsIndustry::find($this->editingId) : null;
|
||||
$translations = $existing ? $existing->getTranslations('name') : [];
|
||||
$translations[$this->editLocale] = $this->name;
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['name' => $translations, 'order' => (int) $this->order]);
|
||||
} else {
|
||||
CmsIndustry::create([
|
||||
'name' => $translations,
|
||||
'order' => (int) $this->order,
|
||||
'is_published' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Industrie wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsIndustry::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Industrie wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsIndustry::findOrFail($id);
|
||||
$item->update(['is_published' => !$item->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$item = CmsIndustry::find($this->editingId);
|
||||
$this->name = $item?->getTranslation('name', $locale) ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$items = CmsIndustry::ordered()->get();
|
||||
$index = $items->search(fn($i) => $i->id === $id);
|
||||
|
||||
if ($index === false || $index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prev = $items[$index - 1];
|
||||
$current = $items[$index];
|
||||
|
||||
$tmpOrder = $prev->order;
|
||||
$prev->update(['order' => $current->order]);
|
||||
$current->update(['order' => $tmpOrder]);
|
||||
|
||||
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$items = CmsIndustry::ordered()->get();
|
||||
$index = $items->search(fn($i) => $i->id === $id);
|
||||
|
||||
if ($index === false || $index >= $items->count() - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = $items[$index + 1];
|
||||
$current = $items[$index];
|
||||
|
||||
$tmpOrder = $next->order;
|
||||
$next->update(['order' => $current->order]);
|
||||
$current->update(['order' => $tmpOrder]);
|
||||
|
||||
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Industries Band</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'" wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neue Industry' }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="md:col-span-3">
|
||||
<flux:input wire:model="name" label="Name" />
|
||||
</div>
|
||||
<flux:input wire:model="order" label="Reihenfolge" type="number" min="0" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->industries as $item)
|
||||
<div wire:key="industry-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-zinc-400">{{ $item->order }}</span>
|
||||
<span class="font-medium">{{ $item->getTranslation('name', $editLocale) }}</span>
|
||||
@unless ($item->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-up"
|
||||
wire:click="moveUp({{ $item->id }})"
|
||||
:disabled="$loop->first" />
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-down"
|
||||
wire:click="moveDown({{ $item->id }})"
|
||||
:disabled="$loop->last" />
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="edit({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'" wire:click="togglePublished({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash" wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Industries vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'content' => '',
|
||||
'author' => '',
|
||||
'date' => null,
|
||||
'url' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'tags' => '',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$posts = computed(fn() => CmsLinkedinPost::ordered()->get());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'title', 'excerpt', 'content', 'author', 'date', 'url', 'image', 'imageMediaId', 'tags', 'source']);
|
||||
$this->source = 'manual';
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$post = CmsLinkedinPost::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->title = $post->getTranslation('title', $l) ?? '';
|
||||
$this->excerpt = $post->getTranslation('excerpt', $l) ?? '';
|
||||
$this->content = $post->getTranslation('content', $l) ?? '';
|
||||
$this->author = $post->author ?? '';
|
||||
$this->date = $post->date?->format('Y-m-d');
|
||||
$this->url = $post->url ?? '';
|
||||
$this->image = $post->image ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
$this->tags = is_array($post->tags) ? implode(', ', $post->tags) : '';
|
||||
$this->source = $post->source;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsLinkedinPost::find($this->editingId) : null;
|
||||
|
||||
$mergeT = function (string $field, string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value;
|
||||
return $t;
|
||||
};
|
||||
|
||||
$tagsArray = array_map('trim', explode(',', $this->tags));
|
||||
$tagsArray = array_filter($tagsArray);
|
||||
|
||||
$data = [
|
||||
'title' => $mergeT('title', $this->title),
|
||||
'excerpt' => $mergeT('excerpt', $this->excerpt),
|
||||
'content' => $mergeT('content', $this->content),
|
||||
'author' => $this->author,
|
||||
'date' => $this->date ?: null,
|
||||
'url' => $this->url,
|
||||
'image' => $this->image,
|
||||
'tags' => array_values($tagsArray),
|
||||
'source' => $this->source,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsLinkedinPost::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsLinkedinPost::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'LinkedIn-Post wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsLinkedinPost::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'LinkedIn-Post wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$post = CmsLinkedinPost::findOrFail($id);
|
||||
$post->update(['is_published' => !$post->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $post->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$post = CmsLinkedinPost::find($this->editingId);
|
||||
if ($post) {
|
||||
$this->title = $post->getTranslation('title', $locale) ?? '';
|
||||
$this->excerpt = $post->getTranslation('excerpt', $locale) ?? '';
|
||||
$this->content = $post->getTranslation('content', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'linkedin_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
}
|
||||
}]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">LinkedIn Beiträge</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer LinkedIn Beitrag' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:input wire:model="author" label="Autor" />
|
||||
<flux:input wire:model="date" label="Datum" type="date" />
|
||||
<flux:input wire:model="url" label="LinkedIn URL" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Bild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="$imageMediaId"
|
||||
field="linkedin_image"
|
||||
type="image"
|
||||
profile="news"
|
||||
label="Bild wählen"
|
||||
:key="'linkedin-img-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<flux:input wire:model="tags" label="Tags (kommagetrennt)" />
|
||||
<flux:select wire:model="source" label="Quelle">
|
||||
<flux:select.option value="manual">Manuell</flux:select.option>
|
||||
<flux:select.option value="api">API</flux:select.option>
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="content" label="Inhalt"
|
||||
toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->posts as $post)
|
||||
<div wire:key="linkedin-{{ $post->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($post->image)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($post->image) }}" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ $post->getTranslation('title', $editLocale) }}</span>
|
||||
<flux:badge size="sm" :color="$post->source === 'api' ? 'blue' : 'zinc'">
|
||||
{{ $post->source }}</flux:badge>
|
||||
@unless ($post->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ $post->author }} ·
|
||||
{{ $post->date?->format('d.m.Y') }}</p>
|
||||
</div></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $post->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$post->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $post->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $post->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine LinkedIn Beiträge vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'filterType' => 'all',
|
||||
'filterCollection' => '',
|
||||
'viewMode' => 'grid',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'altText' => '',
|
||||
'mediaTitle' => '',
|
||||
'collection' => '',
|
||||
'showDetail' => false,
|
||||
'selectedProfiles' => [],
|
||||
]);
|
||||
|
||||
$media = computed(
|
||||
fn() => CmsMedia::query()
|
||||
->when(
|
||||
$this->filterType !== 'all',
|
||||
fn($q) => match ($this->filterType) {
|
||||
'image' => $q->images(),
|
||||
'pdf' => $q->pdfs(),
|
||||
'document' => $q->documents(),
|
||||
default => $q,
|
||||
},
|
||||
)
|
||||
->when($this->filterCollection, fn($q) => $q->inCollection($this->filterCollection))
|
||||
->when($this->search, fn($q) => $q->where('filename', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(48),
|
||||
);
|
||||
|
||||
$collections = computed(fn() => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray());
|
||||
|
||||
$profiles = computed(fn() => config('flux-cms.media.profiles', []));
|
||||
|
||||
$stats = computed(
|
||||
fn() => [
|
||||
'total' => CmsMedia::count(),
|
||||
'images' => CmsMedia::images()->count(),
|
||||
'pdfs' => CmsMedia::pdfs()->count(),
|
||||
],
|
||||
);
|
||||
|
||||
on([
|
||||
'media-library-uploaded' => function ($mediaId) {
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.');
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = $id;
|
||||
$this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? '';
|
||||
$this->collection = $media->collection ?? '';
|
||||
$this->showDetail = true;
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if ($media) {
|
||||
$this->altText = $media->getTranslation('alt_text', $locale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media->setTranslation('alt_text', $this->editLocale, $this->altText);
|
||||
$media->setTranslation('title', $this->editLocale, $this->mediaTitle);
|
||||
$media->collection = $this->collection;
|
||||
$media->save();
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
|
||||
};
|
||||
|
||||
$generateConversion = function (string $profile) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media || !$media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$result = $service->convert($media, $profile);
|
||||
|
||||
if ($result) {
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert.");
|
||||
} else {
|
||||
Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
$generateAllConversions = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media || !$media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$results = $service->generateAllConversions($media);
|
||||
|
||||
$count = count(array_filter($results));
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert.");
|
||||
};
|
||||
|
||||
$deleteMedia = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = $media->filename;
|
||||
$service = app(MediaConversionService::class);
|
||||
$service->deleteAll($media);
|
||||
$media->delete();
|
||||
|
||||
$this->editingId = null;
|
||||
$this->showDetail = false;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt.");
|
||||
};
|
||||
|
||||
$closeDetail = function () {
|
||||
$this->showDetail = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Medienbibliothek</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:badge color="blue">{{ $this->stats['images'] }} Bilder</flux:badge>
|
||||
<flux:badge color="amber">{{ $this->stats['pdfs'] }} PDFs</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upload Area --}}
|
||||
<flux:card class="mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-library-uploader key="media-lib-uploader" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Dateiname suchen..." icon="magnifying-glass"
|
||||
size="sm" class="w-56" />
|
||||
|
||||
<flux:select wire:model.live="filterType" size="sm" class="w-36">
|
||||
<flux:select.option value="all">Alle Typen</flux:select.option>
|
||||
<flux:select.option value="image">Bilder</flux:select.option>
|
||||
<flux:select.option value="pdf">PDFs</flux:select.option>
|
||||
<flux:select.option value="document">Dokumente</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
@if (!empty($this->collections))
|
||||
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
|
||||
<flux:select.option value="">Alle Ordner</flux:select.option>
|
||||
@foreach ($this->collections as $col)
|
||||
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@endif
|
||||
|
||||
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
|
||||
<button wire:click="$set('viewMode', 'grid')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button wire:click="$set('viewMode', 'list')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-list-bullet class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
|
||||
{{-- Media Grid / List --}}
|
||||
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
|
||||
@if ($viewMode === 'grid')
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
|
||||
@forelse ($this->media as $item)
|
||||
<div wire:key="media-g-{{ $item->id }}"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full scale-100 bg-white"
|
||||
loading="lazy"></iframe>
|
||||
<div class="absolute inset-0"></div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
||||
<x-heroicon-o-document class="h-10 w-10" />
|
||||
<span
|
||||
class="text-xs">{{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 p-2">
|
||||
@if ($item->isImage())
|
||||
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
@elseif ($item->isPdf())
|
||||
<x-heroicon-s-document-text class="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||
@else
|
||||
<x-heroicon-s-document class="h-3.5 w-3.5 shrink-0 text-zinc-400" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $item->filename }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($item->collection)
|
||||
<div class="absolute right-1 top-1">
|
||||
<flux:badge size="sm" color="blue" class="text-[10px]!">
|
||||
{{ $item->collection }}</flux:badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
{{-- List View --}}
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="w-12 px-3 py-2"></th>
|
||||
<th class="px-3 py-2">Dateiname</th>
|
||||
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Abmessungen</th>
|
||||
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
|
||||
<th class="px-3 py-2 text-right">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($this->media as $item)
|
||||
<tr wire:key="media-l-{{ $item->id }}"
|
||||
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<td class="px-3 py-1.5">
|
||||
<div
|
||||
class="h-8 w-8 overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe
|
||||
src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<x-heroicon-s-document class="h-4 w-4" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span
|
||||
class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 sm:table-cell">
|
||||
<flux:badge size="sm"
|
||||
:color="$item->isImage() ? 'blue' : ($item->isPdf() ? 'amber' : 'zinc')">
|
||||
{{ $item->type }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
|
||||
{{ $item->getHumanFileSize() }}</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
|
||||
{{ $item->getDimensionsLabel() ?: '—' }}</td>
|
||||
<td class="hidden px-3 py-1.5 lg:table-cell">
|
||||
@if ($item->collection)
|
||||
<flux:badge size="sm" color="blue">{{ $item->collection }}
|
||||
</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-300">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right text-zinc-400">
|
||||
{{ $item->created_at->format('d.m.Y') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->media->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $this->media->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Detail Sidebar --}}
|
||||
@if ($showDetail && $editingId)
|
||||
@php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp
|
||||
@if ($editMedia)
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="sm">Details</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
|
||||
</div>
|
||||
|
||||
{{-- Large Preview --}}
|
||||
<div
|
||||
class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($editMedia->isImage())
|
||||
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
||||
class="w-full object-contain" style="max-height: 300px;" />
|
||||
@elseif ($editMedia->isPdf())
|
||||
<iframe src="{{ $editMedia->getUrl() }}#toolbar=0&navpanes=0"
|
||||
class="h-64 w-full bg-white" loading="lazy"></iframe>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- File Info --}}
|
||||
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
|
||||
<p><strong>Typ:</strong> {{ $editMedia->mime_type }}</p>
|
||||
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
|
||||
@if ($editMedia->getDimensionsLabel())
|
||||
<p><strong>Abmessungen:</strong> {{ $editMedia->getDimensionsLabel() }} px</p>
|
||||
@endif
|
||||
<p><strong>Hochgeladen:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
|
||||
<p class="break-all"><strong>URL:</strong>
|
||||
<a href="{{ $editMedia->getUrl() }}" target="_blank"
|
||||
class="text-blue-500 hover:underline">
|
||||
{{ $editMedia->getUrl() }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Locale Switcher --}}
|
||||
<div class="mb-3 flex gap-1">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">
|
||||
{{ strtoupper($code) }}
|
||||
</flux:button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Edit Fields --}}
|
||||
<div class="space-y-3">
|
||||
<flux:input wire:model="mediaTitle" label="Titel" size="sm"
|
||||
placeholder="Anzeigename..." />
|
||||
<flux:input wire:model="altText" label="Alt-Text" size="sm"
|
||||
placeholder="Bildbeschreibung für SEO..." />
|
||||
<flux:input wire:model="collection" label="Ordner / Sammlung" size="sm"
|
||||
placeholder="z.B. hero, team, news..." />
|
||||
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
|
||||
Speichern
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Conversions --}}
|
||||
@if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml')
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<flux:heading size="sm">Bildgrößen</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" wire:click="generateAllConversions"
|
||||
wire:loading.attr="disabled">
|
||||
Alle generieren
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($this->profiles as $profileName => $profileConfig)
|
||||
@php
|
||||
$hasConversion = $editMedia->hasConversion($profileName);
|
||||
$conversionUrl = $hasConversion
|
||||
? $editMedia->getConversionUrl($profileName)
|
||||
: null;
|
||||
@endphp
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium">{{ $profileName }}</span>
|
||||
<span class="text-xs text-zinc-400">
|
||||
{{ $profileConfig['width'] }}×{{ $profileConfig['height'] }}
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($hasConversion)
|
||||
<flux:badge size="sm" color="green">OK</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">—</flux:badge>
|
||||
@endif
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-path"
|
||||
wire:click="generateConversion('{{ $profileName }}')"
|
||||
wire:loading.attr="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Delete --}}
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
|
||||
wire:click="deleteMedia({{ $editMedia->id }})"
|
||||
wire:confirm="'{{ $editMedia->filename }}' wirklich löschen? Alle Conversions werden ebenfalls entfernt.">
|
||||
Datei löschen
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<div>
|
||||
<flux:file-upload wire:model="uploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Dateien hochladen"
|
||||
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($uploads) && count($uploads) > 0)
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
@foreach ($uploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove
|
||||
wire:click="removeUpload({{ $index }})"
|
||||
aria-label="Entfernen: {{ $upload->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="uploads" />
|
||||
</div>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<div>
|
||||
{{-- Current Selection Preview --}}
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
@if ($this->selected)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
||||
@if ($this->selected->isImage())
|
||||
<img src="{{ $this->selected->hasConversion('thumb') ? $this->selected->getConversionUrl('thumb') : $this->selected->getUrl() }}"
|
||||
alt="{{ $this->selected->filename }}"
|
||||
class="h-16 w-16 rounded-md object-cover" />
|
||||
@elseif ($this->selected->isPdf())
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<x-heroicon-o-document-text class="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $this->selected->filename }}
|
||||
</p>
|
||||
<p class="text-xs text-zinc-400">
|
||||
{{ $this->selected->getHumanFileSize() }}
|
||||
@if ($this->selected->getDimensionsLabel())
|
||||
— {{ $this->selected->getDimensionsLabel() }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($this->selected->isImage() && $this->selected->hasConversion($profile))
|
||||
@php
|
||||
$pConfig = config("flux-cms.media.profiles.{$profile}", []);
|
||||
@endphp
|
||||
<flux:badge size="sm" color="green" class="mt-1">
|
||||
{{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="clearSelection" />
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border-2 border-dashed border-zinc-300 px-4 py-3 text-center text-sm text-zinc-400 dark:border-zinc-600">
|
||||
Kein Medium ausgewählt
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="sm" variant="ghost" icon="photo" wire:click="openPicker">
|
||||
{{ $label }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Picker Modal --}}
|
||||
<flux:modal wire:model="showModal" class="w-full max-w-4xl space-y-4 overflow-y-auto max-h-[85vh]">
|
||||
<flux:heading size="lg">{{ $label }}</flux:heading>
|
||||
|
||||
{{-- Quick Upload + Search --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass"
|
||||
size="sm" />
|
||||
|
||||
<flux:file-upload wire:model="quickUploads" multiple
|
||||
accept="{{ $type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp,.jpg,.jpeg,.png' : ($type === 'pdf' ? '.pdf,application/pdf' : '*') }}">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Neue Datei hochladen"
|
||||
text="Direkt hier hochladen und zuweisen"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($quickUploads) && count($quickUploads) > 0)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($quickUploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove wire:click="removeQuickUpload({{ $index }})" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="quickUploads" />
|
||||
</div>
|
||||
|
||||
@php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp
|
||||
@if (!empty($profileConfig))
|
||||
<flux:text class="text-xs">
|
||||
Profil <strong>{{ $profile }}</strong>: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px,
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }},
|
||||
Qualität {{ $profileConfig['quality'] ?? 85 }}%
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
{{-- Media Grid --}}
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
@forelse ($this->mediaItems as $item)
|
||||
<div wire:key="pick-{{ $item->id }}"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
||||
wire:click="selectMedia({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="flex h-full w-full items-center justify-center text-red-500">
|
||||
<x-heroicon-o-document-text class="h-8 w-8" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<p class="truncate text-[11px] text-zinc-600 dark:text-zinc-400">{{ $item->filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<flux:text>Keine Medien gefunden.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($this->mediaItems->hasPages())
|
||||
<div class="mt-2">
|
||||
{{ $this->mediaItems->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div class="inline-flex items-center gap-2">
|
||||
<input type="file" wire:model="file" accept="{{ $accept }}"
|
||||
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
|
||||
|
||||
<div wire:loading wire:target="file">
|
||||
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'icon' => '',
|
||||
'text' => '',
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'content' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'pdfMediaId' => null,
|
||||
'date' => null,
|
||||
'author' => '',
|
||||
'link' => '',
|
||||
'pdf_path' => '',
|
||||
'pdf_open_text' => '',
|
||||
'pdf_download_text' => '',
|
||||
]);
|
||||
|
||||
$items = computed(fn() => CmsNewsItem::ordered()->get());
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'icon', 'text', 'title', 'excerpt', 'content', 'image', 'imageMediaId', 'pdfMediaId', 'date', 'author', 'link', 'pdf_path', 'pdf_open_text', 'pdf_download_text']);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$item = CmsNewsItem::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->icon = $item->icon ?? '';
|
||||
$this->text = $item->getTranslation('text', $l) ?? '';
|
||||
$this->title = $item->getTranslation('title', $l) ?? '';
|
||||
$this->excerpt = $item->getTranslation('excerpt', $l) ?? '';
|
||||
$this->content = $item->getTranslation('content', $l) ?? '';
|
||||
$this->image = $item->image ?? '';
|
||||
$this->date = $item->date?->format('Y-m-d');
|
||||
$this->author = $item->author ?? '';
|
||||
$this->link = $item->link ?? '';
|
||||
$this->pdf_path = $item->pdf_path ?? '';
|
||||
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $l) ?? '';
|
||||
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $l) ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
$this->pdfMediaId = $this->pdf_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->pdf_path)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsNewsItem::find($this->editingId) : null;
|
||||
$merge = function (string $field, string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value;
|
||||
return $t;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'icon' => $this->icon,
|
||||
'text' => $merge('text', $this->text),
|
||||
'title' => $merge('title', $this->title),
|
||||
'excerpt' => $merge('excerpt', $this->excerpt),
|
||||
'content' => $merge('content', $this->content),
|
||||
'image' => $this->image,
|
||||
'date' => $this->date ?: null,
|
||||
'author' => $this->author,
|
||||
'link' => $this->link,
|
||||
'pdf_path' => $this->pdf_path,
|
||||
'pdf_open_text' => $merge('pdf_open_text', $this->pdf_open_text),
|
||||
'pdf_download_text' => $merge('pdf_download_text', $this->pdf_download_text),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsNewsItem::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsNewsItem::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'News-Eintrag wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsNewsItem::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'News-Eintrag wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsNewsItem::findOrFail($id);
|
||||
$item->update(['is_published' => !$item->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$item = CmsNewsItem::find($this->editingId);
|
||||
if ($item) {
|
||||
$this->text = $item->getTranslation('text', $locale) ?? '';
|
||||
$this->title = $item->getTranslation('title', $locale) ?? '';
|
||||
$this->excerpt = $item->getTranslation('excerpt', $locale) ?? '';
|
||||
$this->content = $item->getTranslation('content', $locale) ?? '';
|
||||
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $locale) ?? '';
|
||||
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'news_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
} elseif ($field === 'news_pdf') {
|
||||
$this->pdfMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->pdf_path = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">News Band</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer News-Eintrag' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:input wire:model="text" label="Band-Text (kurz)" />
|
||||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
|
||||
placeholder="Icon auswählen...">
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if ($icon)
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<flux:input wire:model="author" label="Autor" />
|
||||
<flux:input wire:model="date" label="Datum" type="date" />
|
||||
<flux:input wire:model="link" label="Link (optional)" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Bild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="$imageMediaId" field="news_image" type="image"
|
||||
profile="news" label="Bild wählen" :key="'news-img-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">PDF-Dokument</label>
|
||||
@if ($pdf_path)
|
||||
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<iframe src="{{ media_url($pdf_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
<p class="mb-1 text-xs text-zinc-400">{{ $pdf_path }}</p>
|
||||
@endif
|
||||
<livewire:admin.cms.media-picker :value="$pdfMediaId" field="news_pdf" type="pdf" profile=""
|
||||
label="PDF wählen" :key="'news-pdf-' . ($editingId ?? 'new')" />
|
||||
</div>
|
||||
<flux:input wire:model="pdf_open_text" label="PDF öffnen Text" />
|
||||
<flux:input wire:model="pdf_download_text" label="PDF Download Text" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="content" label="Inhalt"
|
||||
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->items as $item)
|
||||
<div wire:key="news-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($item->image)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($item->image) }}" alt=""
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($item->icon)
|
||||
<x-dynamic-component :component="'heroicon-o-' . $item->icon" class="h-5 w-5 shrink-0 text-primary" />
|
||||
@endif
|
||||
<span class="font-medium">{{ $item->getTranslation('title', $editLocale) }}</span>
|
||||
@unless ($item->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $item->getTranslation('text', $editLocale) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine News-Einträge vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsSearchIndex;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'itemId' => '',
|
||||
'route' => '',
|
||||
'routeParams' => '',
|
||||
'category' => '',
|
||||
'titleKey' => '',
|
||||
'titleFallback' => '',
|
||||
'descriptionKey' => '',
|
||||
'descriptionFallbackKey' => '',
|
||||
'descriptionFallbackText' => '',
|
||||
'keywords' => [],
|
||||
'newKeyword' => '',
|
||||
'isPublished' => true,
|
||||
'reindexing' => false,
|
||||
]);
|
||||
|
||||
$items = computed(
|
||||
fn () => CmsSearchIndex::query()
|
||||
->when($this->search, fn ($q) => $q->where('item_id', 'like', "%{$this->search}%")
|
||||
->orWhere('route', 'like', "%{$this->search}%")
|
||||
->orWhere('category', 'like', "%{$this->search}%"))
|
||||
->ordered()
|
||||
->get()
|
||||
);
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $id;
|
||||
$this->itemId = $item->item_id;
|
||||
$this->route = $item->route;
|
||||
$this->routeParams = implode(', ', $item->route_params ?? []);
|
||||
$this->category = $item->getTranslation('category', $this->editLocale, false) ?? '';
|
||||
$this->titleKey = $item->title_key ?? '';
|
||||
$this->titleFallback = $item->getTranslation('title_fallback', $this->editLocale, false) ?? '';
|
||||
$this->descriptionKey = $item->description_key ?? '';
|
||||
$this->descriptionFallbackKey = $item->description_fallback_key ?? '';
|
||||
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $this->editLocale, false) ?? '';
|
||||
$this->keywords = $item->getTranslation('keywords', $this->editLocale, false) ?? [];
|
||||
if (! is_array($this->keywords)) {
|
||||
$this->keywords = [];
|
||||
}
|
||||
$this->isPublished = $item->is_published;
|
||||
$this->newKeyword = '';
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
if ($this->editingId) {
|
||||
$item = CmsSearchIndex::find($this->editingId);
|
||||
if ($item) {
|
||||
$this->editLocale = $locale;
|
||||
$this->category = $item->getTranslation('category', $locale, false) ?? '';
|
||||
$this->titleFallback = $item->getTranslation('title_fallback', $locale, false) ?? '';
|
||||
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $locale, false) ?? '';
|
||||
$this->keywords = $item->getTranslation('keywords', $locale, false) ?? [];
|
||||
if (! is_array($this->keywords)) {
|
||||
$this->keywords = [];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->editLocale = $locale;
|
||||
}
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$item = CmsSearchIndex::find($this->editingId);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
$item->item_id = $this->itemId;
|
||||
$item->route = $this->route;
|
||||
$item->route_params = array_values(array_filter(array_map('trim', explode(',', $this->routeParams))));
|
||||
$item->setTranslation('category', $this->editLocale, $this->category);
|
||||
|
||||
$item->title_key = $this->titleKey ?: null;
|
||||
$item->setTranslation('title_fallback', $this->editLocale, $this->titleFallback ?: null);
|
||||
|
||||
$item->description_key = $this->descriptionKey ?: null;
|
||||
$item->description_fallback_key = $this->descriptionFallbackKey ?: null;
|
||||
$item->setTranslation('description_fallback_text', $this->editLocale, $this->descriptionFallbackText ?: null);
|
||||
|
||||
$cleanKeywords = array_values(array_filter($this->keywords, fn ($k) => is_string($k) && trim($k) !== ''));
|
||||
$item->setTranslation('keywords', $this->editLocale, $cleanKeywords);
|
||||
$item->is_published = $this->isPublished;
|
||||
$item->save();
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: "Suchindex-Eintrag '{$item->item_id}' wurde gespeichert.");
|
||||
};
|
||||
|
||||
$addKeyword = function () {
|
||||
$keyword = trim($this->newKeyword);
|
||||
if ($keyword !== '' && ! in_array($keyword, $this->keywords)) {
|
||||
$this->keywords[] = $keyword;
|
||||
}
|
||||
$this->newKeyword = '';
|
||||
};
|
||||
|
||||
$removeKeyword = function (int $index) {
|
||||
unset($this->keywords[$index]);
|
||||
$this->keywords = array_values($this->keywords);
|
||||
};
|
||||
|
||||
$create = function () {
|
||||
$maxOrder = CmsSearchIndex::max('order') ?? -1;
|
||||
$item = CmsSearchIndex::create([
|
||||
'item_id' => 'new-item-' . time(),
|
||||
'route' => 'home',
|
||||
'route_params' => [],
|
||||
'category' => ['de' => 'Neu', 'en' => 'New'],
|
||||
'keywords' => ['de' => [], 'en' => []],
|
||||
'is_published' => false,
|
||||
'order' => $maxOrder + 1,
|
||||
]);
|
||||
|
||||
$this->editingId = $item->id;
|
||||
$this->itemId = $item->item_id;
|
||||
$this->route = $item->route;
|
||||
$this->routeParams = '';
|
||||
$this->category = 'Neu';
|
||||
$this->titleKey = '';
|
||||
$this->titleFallback = '';
|
||||
$this->descriptionKey = '';
|
||||
$this->descriptionFallbackKey = '';
|
||||
$this->descriptionFallbackText = '';
|
||||
$this->keywords = [];
|
||||
$this->isPublished = false;
|
||||
$this->newKeyword = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Erstellt', text: 'Neuer Suchindex-Eintrag wurde erstellt.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if ($item) {
|
||||
$name = $item->item_id;
|
||||
$item->delete();
|
||||
if ($this->editingId === $id) {
|
||||
$this->editingId = null;
|
||||
}
|
||||
Flux::toast(variant: 'success', heading: 'Geloescht', text: "Eintrag '{$name}' wurde entfernt.");
|
||||
}
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if ($item) {
|
||||
$item->is_published = ! $item->is_published;
|
||||
$item->save();
|
||||
Flux::toast(variant: 'success', heading: 'Status geaendert', text: $item->is_published ? 'Aktiviert' : 'Deaktiviert');
|
||||
}
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
$prev = CmsSearchIndex::where('order', '<', $item->order)->orderByDesc('order')->first();
|
||||
if ($prev) {
|
||||
$tmpOrder = $item->order;
|
||||
$item->order = $prev->order;
|
||||
$prev->order = $tmpOrder;
|
||||
$item->save();
|
||||
$prev->save();
|
||||
}
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
$next = CmsSearchIndex::where('order', '>', $item->order)->orderBy('order')->first();
|
||||
if ($next) {
|
||||
$tmpOrder = $item->order;
|
||||
$item->order = $next->order;
|
||||
$next->order = $tmpOrder;
|
||||
$item->save();
|
||||
$next->save();
|
||||
}
|
||||
};
|
||||
|
||||
$reindex = function () {
|
||||
$this->reindexing = true;
|
||||
|
||||
try {
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('search:extract-keywords', [
|
||||
'--apply' => true,
|
||||
'--locale' => ['de', 'en'],
|
||||
]);
|
||||
|
||||
$deItems = [];
|
||||
$enItems = [];
|
||||
$dePath = lang_path('de/search_index.php');
|
||||
$enPath = lang_path('en/search_index.php');
|
||||
|
||||
if (file_exists($dePath)) {
|
||||
$deConfig = require $dePath;
|
||||
$deItems = collect($deConfig['items'] ?? [])->keyBy('id');
|
||||
}
|
||||
if (file_exists($enPath)) {
|
||||
$enConfig = require $enPath;
|
||||
$enItems = collect($enConfig['items'] ?? [])->keyBy('id');
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach (CmsSearchIndex::all() as $entry) {
|
||||
$de = $deItems->get($entry->item_id);
|
||||
$en = $enItems->get($entry->item_id);
|
||||
|
||||
if ($de && ! empty($de['keywords'])) {
|
||||
$existing = $entry->getTranslation('keywords', 'de', false) ?? [];
|
||||
$merged = array_values(array_unique(array_merge(
|
||||
is_array($existing) ? $existing : [],
|
||||
$de['keywords']
|
||||
)));
|
||||
$entry->setTranslation('keywords', 'de', $merged);
|
||||
}
|
||||
|
||||
if ($en && ! empty($en['keywords'])) {
|
||||
$existing = $entry->getTranslation('keywords', 'en', false) ?? [];
|
||||
$merged = array_values(array_unique(array_merge(
|
||||
is_array($existing) ? $existing : [],
|
||||
$en['keywords']
|
||||
)));
|
||||
$entry->setTranslation('keywords', 'en', $merged);
|
||||
}
|
||||
|
||||
if ($entry->isDirty()) {
|
||||
$entry->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Reindexierung abgeschlossen', text: "{$updated} Eintraege aktualisiert.");
|
||||
} catch (\Exception $e) {
|
||||
Flux::toast(variant: 'danger', heading: 'Fehler', text: $e->getMessage());
|
||||
}
|
||||
|
||||
$this->reindexing = false;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">Suchindex</flux:heading>
|
||||
<flux:text class="mt-1">Verwalte die Seiten-Suche: Keywords, Kategorien und Beschreibungen.</flux:text>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button wire:click="reindex" variant="ghost" icon="arrow-path" wire:loading.attr="disabled"
|
||||
wire:target="reindex">
|
||||
<span wire:loading.remove wire:target="reindex">Reindexieren</span>
|
||||
<span wire:loading wire:target="reindex">Wird reindexiert...</span>
|
||||
</flux:button>
|
||||
<flux:button wire:click="create" variant="primary" icon="plus">Neuer Eintrag</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suche nach ID, Route oder Kategorie..."
|
||||
icon="magnifying-glass" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
{{-- Liste --}}
|
||||
<div class="w-80 shrink-0 space-y-1 overflow-y-auto" style="max-height: 80vh;">
|
||||
@foreach ($this->items as $item)
|
||||
<div wire:key="si-{{ $item->id }}"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition
|
||||
{{ $editingId === $item->id ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950' : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600' }}
|
||||
{{ ! $item->is_published ? 'opacity-50' : '' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{{ $item->item_id }}</p>
|
||||
<p class="truncate text-xs text-zinc-400">
|
||||
{{ $item->getTranslation('category', 'de', false) }}
|
||||
· {{ $item->route }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<flux:button size="xs" variant="ghost" icon="chevron-up"
|
||||
wire:click.stop="moveUp({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
|
||||
<flux:button size="xs" variant="ghost" icon="chevron-down"
|
||||
wire:click.stop="moveDown({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Editor --}}
|
||||
<div class="flex-1">
|
||||
@if ($editingId)
|
||||
@php $currentItem = CmsSearchIndex::find($editingId); @endphp
|
||||
@if ($currentItem)
|
||||
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="lg">{{ $currentItem->item_id }}</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-1">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs"
|
||||
variant="{{ $editLocale === $code ? 'primary' : 'ghost' }}"
|
||||
wire:click="switchLocale('{{ $code }}')">
|
||||
{{ strtoupper($code) }}
|
||||
</flux:button>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="sm" variant="{{ $isPublished ? 'primary' : 'ghost' }}"
|
||||
wire:click="togglePublished({{ $editingId }})">
|
||||
{{ $isPublished ? 'Aktiv' : 'Inaktiv' }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="danger" icon="trash"
|
||||
wire:click="delete({{ $editingId }})"
|
||||
wire:confirm="Suchindex-Eintrag wirklich loeschen?" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="itemId" label="Item-ID" placeholder="z.B. home, leistungen" />
|
||||
<flux:input wire:model="route" label="Route (Named)" placeholder="z.B. home, leistungen" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<flux:input wire:model="routeParams" label="Route-Parameter (kommagetrennt)"
|
||||
placeholder="z.B. strategische-fmcg-projektrealisierung" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="category"
|
||||
label="Kategorie ({{ strtoupper($editLocale) }})"
|
||||
placeholder="z.B. Startseite, Leistungen" />
|
||||
<flux:input wire:model="titleKey" label="Title-Key (CMS)"
|
||||
placeholder="z.B. welcome.title" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="titleFallback"
|
||||
label="Title-Fallback ({{ strtoupper($editLocale) }})"
|
||||
placeholder="Fallback wenn kein Key" />
|
||||
<flux:input wire:model="descriptionKey" label="Description-Key (CMS)"
|
||||
placeholder="z.B. welcome.hero.description" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="descriptionFallbackKey" label="Description-Fallback-Key"
|
||||
placeholder="Optionaler Fallback-Key" />
|
||||
<flux:input wire:model="descriptionFallbackText"
|
||||
label="Description-Fallback ({{ strtoupper($editLocale) }})"
|
||||
placeholder="Statischer Fallback-Text" />
|
||||
</div>
|
||||
|
||||
{{-- Keywords --}}
|
||||
<div class="mt-6">
|
||||
<label class="mb-2 block text-sm font-medium">
|
||||
Keywords ({{ strtoupper($editLocale) }})
|
||||
<span class="text-zinc-400">- {{ count($keywords) }} Eintraege</span>
|
||||
</label>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
@foreach ($keywords as $kIdx => $keyword)
|
||||
<span wire:key="kw-{{ $kIdx }}-{{ md5($keyword) }}"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{{ str_contains($keyword, '.') ? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' }}">
|
||||
@if (str_contains($keyword, '.'))
|
||||
<x-heroicon-s-key class="h-3 w-3 opacity-50" />
|
||||
@endif
|
||||
{{ $keyword }}
|
||||
<button wire:click="removeKeyword({{ $kIdx }})"
|
||||
class="ml-0.5 text-zinc-400 hover:text-red-500">
|
||||
<x-heroicon-s-x-mark class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:input wire:model="newKeyword" placeholder="Neues Keyword oder CMS-Key..."
|
||||
wire:keydown.enter.prevent="addKeyword" class="flex-1!" />
|
||||
<flux:button wire:click="addKeyword" icon="plus" size="sm">Hinzufuegen</flux:button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-400">
|
||||
<x-heroicon-s-key class="inline h-3 w-3 text-blue-500" /> = CMS-Key (wird aufgeloest),
|
||||
normale Keywords werden direkt verwendet.
|
||||
Enter druecken oder Button klicken zum Hinzufuegen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Vorschau --}}
|
||||
@php
|
||||
$preview = $currentItem->toFrontendArray($editLocale);
|
||||
@endphp
|
||||
<div class="mt-6 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">Vorschau
|
||||
({{ strtoupper($editLocale) }})</p>
|
||||
<p class="text-xs text-zinc-400">{{ $preview['category'] }}</p>
|
||||
<p class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
||||
{{ $preview['title'] ?: '(kein Titel)' }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-zinc-500">
|
||||
{{ $preview['description'] ?: '(keine Beschreibung)' }}</p>
|
||||
@if (! empty($preview['url']))
|
||||
<p class="mt-1 truncate text-xs text-blue-500">{{ $preview['url'] }}</p>
|
||||
@endif
|
||||
@if (! empty($preview['keywords']))
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@foreach (array_slice($preview['keywords'], 0, 10) as $kw)
|
||||
<span
|
||||
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">{{ $kw }}</span>
|
||||
@endforeach
|
||||
@if (count($preview['keywords']) > 10)
|
||||
<span
|
||||
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">+{{ count($preview['keywords']) - 10 }}
|
||||
weitere</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<flux:button wire:click="save" variant="primary" icon="check">Speichern</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex h-64 items-center justify-center rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700">
|
||||
<div class="text-center">
|
||||
<x-heroicon-o-magnifying-glass class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
|
||||
<p class="mt-2 text-sm text-zinc-400">Eintrag aus der Liste auswaehlen oder neuen erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingIndex' => null,
|
||||
'name' => '',
|
||||
'role' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'quote' => '',
|
||||
'preview' => '',
|
||||
'short' => '',
|
||||
'linkedin' => '',
|
||||
]);
|
||||
|
||||
$getProfiles = function (): array {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$val = $content->getTranslation('value', $this->editLocale);
|
||||
|
||||
return is_array($val) ? $val : [];
|
||||
};
|
||||
|
||||
$profiles = computed(fn() => $this->getProfiles());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingIndex', 'name', 'role', 'image', 'quote', 'preview', 'short', 'linkedin']);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $index) {
|
||||
$profiles = $this->getProfiles();
|
||||
if (!isset($profiles[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$p = $profiles[$index];
|
||||
$this->editingIndex = $index;
|
||||
$this->showForm = true;
|
||||
$this->name = $p['name'] ?? '';
|
||||
$this->role = $p['role'] ?? '';
|
||||
$this->image = $p['image'] ?? '';
|
||||
$this->quote = $p['quote'] ?? '';
|
||||
$this->preview = $p['preview'] ?? '';
|
||||
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
|
||||
$this->linkedin = $p['linkedin'] ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profiles = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($profiles)) {
|
||||
$profiles = [];
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'name' => $this->name,
|
||||
'role' => $this->role,
|
||||
'image' => $this->image,
|
||||
'quote' => $this->quote,
|
||||
'preview' => $this->preview,
|
||||
'short' => $this->short,
|
||||
'linkedin' => $this->linkedin,
|
||||
];
|
||||
|
||||
if ($this->editingIndex !== null && isset($profiles[$this->editingIndex])) {
|
||||
$profiles[$this->editingIndex] = $entry;
|
||||
} else {
|
||||
$profiles[] = $entry;
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, array_values($profiles));
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache('team');
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingIndex = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Teammitglied wurde gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $index) {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profiles = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($profiles) || !isset($profiles[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($profiles[$index]);
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, array_values($profiles));
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache('team');
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Teammitglied wurde entfernt.');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingIndex !== null) {
|
||||
$profiles = $this->getProfiles();
|
||||
if (isset($profiles[$this->editingIndex])) {
|
||||
$p = $profiles[$this->editingIndex];
|
||||
$this->name = $p['name'] ?? '';
|
||||
$this->role = $p['role'] ?? '';
|
||||
$this->image = $p['image'] ?? '';
|
||||
$this->quote = $p['quote'] ?? '';
|
||||
$this->preview = $p['preview'] ?? '';
|
||||
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
|
||||
$this->linkedin = $p['linkedin'] ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingIndex = null;
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'team_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Team-Verwaltung</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button variant="primary" icon="plus" wire:click="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">
|
||||
{{ $editingIndex !== null ? 'Teammitglied bearbeiten' : 'Neues Teammitglied' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="name" label="Name" />
|
||||
<flux:input wire:model="role" label="Position / Rolle" />
|
||||
<flux:input wire:model="short" label="Kürzel" placeholder="z.B. PB" />
|
||||
<flux:input wire:model="linkedin" label="LinkedIn-URL" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Profilbild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 overflow-hidden rounded-full border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="$imageMediaId" field="team_image" type="image"
|
||||
profile="avatar" label="Bild wählen" :key="'team-img-' . ($editingIndex ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<flux:input wire:model="preview" label="Kurzvorstellung (1 Satz)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="quote" label="Profil-Text (ausführlich)" toolbar="bold italic | link"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->profiles as $index => $profile)
|
||||
<div wire:key="team-{{ $index }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-4 min-w-0 flex-1">
|
||||
@if (!empty($profile['image']))
|
||||
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($profile['image']) }}" alt="{{ $profile['name'] ?? '' }}"
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
|
||||
{{ $profile['short'] ?? mb_substr($profile['name'] ?? '?', 0, 2) }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">{{ $profile['name'] ?? '—' }}</div>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $profile['role'] ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $index }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $index }})"
|
||||
wire:confirm="'{{ $profile['name'] ?? 'Dieses Mitglied' }}' wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Teammitglieder vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||
|
||||
<a href="{{ route('cms.dashboard') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse" wire:navigate>
|
||||
<x-app-logo />
|
||||
</a>
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.group heading="CMS" class="grid">
|
||||
<flux:navlist.item icon="home" :href="route('cms.dashboard')"
|
||||
:current="request()->routeIs('cms.dashboard')" wire:navigate>
|
||||
Dashboard
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
|
||||
:current="request()->routeIs('cms.content.*')" wire:navigate>
|
||||
Inhalte
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="newspaper" :href="route('cms.news.index')"
|
||||
:current="request()->routeIs('cms.news.*')" wire:navigate>
|
||||
News Band
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('cms.industries.index')"
|
||||
:current="request()->routeIs('cms.industries.*')" wire:navigate>
|
||||
Industries
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="question-mark-circle" :href="route('cms.faqs.index')"
|
||||
:current="request()->routeIs('cms.faqs.*')" wire:navigate>
|
||||
FAQs
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="chat-bubble-left-right" :href="route('cms.linkedin.index')"
|
||||
:current="request()->routeIs('cms.linkedin.*')" wire:navigate>
|
||||
LinkedIn
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="arrow-down-tray" :href="route('cms.downloads.index')"
|
||||
:current="request()->routeIs('cms.downloads.*')" wire:navigate>
|
||||
Downloads
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="user-group" :href="route('cms.team.index')"
|
||||
:current="request()->routeIs('cms.team.*')" wire:navigate>
|
||||
Team
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="photo" :href="route('cms.media.index')"
|
||||
:current="request()->routeIs('cms.media.*')" wire:navigate>
|
||||
Medienbibliothek
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="magnifying-glass" :href="route('cms.search-index')"
|
||||
:current="request()->routeIs('cms.search-index')" wire:navigate>
|
||||
Suchindex
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.item icon="arrow-left" :href="route('dashboard')" wire:navigate>
|
||||
Zurück zum Dashboard
|
||||
</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
|
||||
@auth
|
||||
<flux:dropdown class="hidden lg:block" position="bottom" align="start">
|
||||
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()" />
|
||||
|
||||
<flux:menu class="w-[220px]">
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endauth
|
||||
</flux:sidebar>
|
||||
|
||||
@auth
|
||||
<flux:header class="lg:hidden">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
<flux:spacer />
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:header>
|
||||
@endauth
|
||||
|
||||
<flux:main>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
|
||||
@persist('toast')
|
||||
<flux:toast />
|
||||
@endpersist
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div class="inline-flex items-center gap-2">
|
||||
<input type="file" wire:model="file" accept="{{ $accept }}"
|
||||
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
|
||||
|
||||
<div wire:loading wire:target="file">
|
||||
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\BlogController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\MediaController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\NavigationController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
|
||||
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
|
||||
use FluxCms\Core\Http\Controllers\PageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Http\Controllers\PageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ClearCacheCommand extends Command
|
||||
{
|
||||
protected $signature = 'flux-cms:clear-cache';
|
||||
|
||||
protected $description = 'Clear the Flux CMS component registry cache';
|
||||
|
||||
public function handle(ComponentRegistry $registry): int
|
||||
|
|
@ -15,6 +16,7 @@ class ClearCacheCommand extends Command
|
|||
$this->info('Clearing Flux CMS component cache...');
|
||||
$registry->clearCache();
|
||||
$this->info('Flux CMS component cache cleared successfully!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,17 +19,17 @@ class InstallCommand extends Command
|
|||
$this->info('🚀 Installing Flux CMS...');
|
||||
|
||||
// Check requirements
|
||||
if (!$this->checkRequirements()) {
|
||||
if (! $this->checkRequirements()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Publish configuration
|
||||
if (!$this->option('no-publish')) {
|
||||
if (! $this->option('no-publish')) {
|
||||
$this->publishAssets();
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if (!$this->option('no-migrate')) {
|
||||
if (! $this->option('no-migrate')) {
|
||||
$this->runMigrations();
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ class InstallCommand extends Command
|
|||
}
|
||||
}
|
||||
|
||||
if (!$allPassed) {
|
||||
if (! $allPassed) {
|
||||
$this->error('❌ Some requirements are not met. Please install missing dependencies.');
|
||||
$this->line('Run: composer require spatie/laravel-translatable spatie/laravel-medialibrary');
|
||||
}
|
||||
|
|
@ -85,11 +85,12 @@ class InstallCommand extends Command
|
|||
|
||||
protected function checkLivewireVersion(): bool
|
||||
{
|
||||
if (!class_exists(\Livewire\Livewire::class)) {
|
||||
if (! class_exists(\Livewire\Livewire::class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$version = \Livewire\Livewire::VERSION ?? '2.0.0';
|
||||
|
||||
return version_compare($version, '3.0.0', '>=');
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ class InstallCommand extends Command
|
|||
|
||||
protected function createStorageLink(): void
|
||||
{
|
||||
if (!File::exists(public_path('storage'))) {
|
||||
if (! File::exists(public_path('storage'))) {
|
||||
$this->info('🔗 Creating storage link...');
|
||||
$this->call('storage:link');
|
||||
$this->line('✅ Storage link created');
|
||||
|
|
|
|||
|
|
@ -8,22 +8,25 @@ use Symfony\Component\Console\Input\InputArgument;
|
|||
class MakeComponentCommand extends GeneratorCommand
|
||||
{
|
||||
protected $name = 'flux-cms:make-component';
|
||||
|
||||
protected $description = 'Create a new Flux CMS component';
|
||||
|
||||
protected $type = 'Flux CMS Component';
|
||||
|
||||
protected function getStub()
|
||||
{
|
||||
return __DIR__ . '/stubs/component.stub';
|
||||
return __DIR__.'/stubs/component.stub';
|
||||
}
|
||||
|
||||
protected function getDefaultNamespace($rootNamespace)
|
||||
{
|
||||
return $rootNamespace . '\Livewire\Web\Components';
|
||||
return $rootNamespace.'\Livewire\Web\Components';
|
||||
}
|
||||
|
||||
protected function buildClass($name)
|
||||
{
|
||||
$stub = parent::buildClass($name);
|
||||
|
||||
return str_replace('{{ componentName }}', $this->argument('name'), $stub);
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +46,7 @@ class MakeComponentCommand extends GeneratorCommand
|
|||
protected function createView()
|
||||
{
|
||||
$name = $this->argument('name');
|
||||
$viewPath = resource_path('views/livewire/web/components/' . strtolower($name) . '.blade.php');
|
||||
$viewPath = resource_path('views/livewire/web/components/'.strtolower($name).'.blade.php');
|
||||
|
||||
if (! is_dir(dirname($viewPath))) {
|
||||
mkdir(dirname($viewPath), 0777, true);
|
||||
|
|
@ -51,10 +54,11 @@ class MakeComponentCommand extends GeneratorCommand
|
|||
|
||||
if (file_exists($viewPath)) {
|
||||
$this->error('View already exists!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$stub = $this->files->get(__DIR__ . '/stubs/view.stub');
|
||||
$stub = $this->files->get(__DIR__.'/stubs/view.stub');
|
||||
$this->files->put($viewPath, $stub);
|
||||
$this->info('View created successfully.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace FluxCms\Core\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class PublishCommand extends Command
|
||||
{
|
||||
|
|
@ -27,6 +26,7 @@ class PublishCommand extends Command
|
|||
}
|
||||
|
||||
$this->info('✅ Flux CMS assets published successfully!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,21 @@ namespace FluxCms\Core\FieldTypes;
|
|||
abstract class BaseField
|
||||
{
|
||||
protected string $key;
|
||||
|
||||
protected string $label;
|
||||
|
||||
protected bool $translatable = false;
|
||||
|
||||
protected bool $required = false;
|
||||
|
||||
protected mixed $default = null;
|
||||
|
||||
protected array $rules = [];
|
||||
|
||||
protected array $attributes = [];
|
||||
|
||||
protected ?string $helpText = null;
|
||||
|
||||
protected ?string $placeholder = null;
|
||||
|
||||
public function __construct(string $key, string $label)
|
||||
|
|
@ -31,48 +39,56 @@ abstract class BaseField
|
|||
public function translatable(bool $translatable = true): static
|
||||
{
|
||||
$this->translatable = $translatable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function required(bool $required = true): static
|
||||
{
|
||||
$this->required = $required;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function default(mixed $default): static
|
||||
{
|
||||
$this->default = $default;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function rules(array|string $rules): static
|
||||
{
|
||||
$this->rules = is_array($rules) ? $rules : [$rules];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function helpText(string $helpText): static
|
||||
{
|
||||
$this->helpText = $helpText;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function placeholder(string $placeholder): static
|
||||
{
|
||||
$this->placeholder = $placeholder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function attributes(array $attributes): static
|
||||
{
|
||||
$this->attributes = array_merge($this->attributes, $attributes);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function attribute(string $key, mixed $value): static
|
||||
{
|
||||
$this->attributes[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -139,13 +155,15 @@ abstract class BaseField
|
|||
* Abstract Methods
|
||||
*/
|
||||
abstract public function getType(): string;
|
||||
|
||||
abstract public function getValidationRules(): array;
|
||||
|
||||
abstract public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Value Handling
|
||||
*/
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
public function getValue(array $content, ?string $locale = null): mixed
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
return $content[$this->key][$locale] ?? $this->default;
|
||||
|
|
@ -154,7 +172,7 @@ abstract class BaseField
|
|||
return $content[$this->key] ?? $this->default;
|
||||
}
|
||||
|
||||
public function setValue(array &$content, mixed $value, string $locale = null): void
|
||||
public function setValue(array &$content, mixed $value, ?string $locale = null): void
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
$content[$this->key][$locale] = $value;
|
||||
|
|
@ -166,7 +184,7 @@ abstract class BaseField
|
|||
/**
|
||||
* Validation
|
||||
*/
|
||||
public function validate(mixed $value, string $locale = null): array
|
||||
public function validate(mixed $value, ?string $locale = null): array
|
||||
{
|
||||
$rules = $this->getValidationRules();
|
||||
|
||||
|
|
@ -177,7 +195,7 @@ abstract class BaseField
|
|||
// Für übersetzbare Felder Locale zu Regeln hinzufügen
|
||||
$fieldKey = $this->key;
|
||||
if ($locale && $this->translatable) {
|
||||
$fieldKey .= '.' . $locale;
|
||||
$fieldKey .= '.'.$locale;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -190,7 +208,7 @@ abstract class BaseField
|
|||
|
||||
return $validator->fails() ? $validator->errors()->get($fieldKey) : [];
|
||||
} catch (\Exception $e) {
|
||||
return ['Validation error: ' . $e->getMessage()];
|
||||
return ['Validation error: '.$e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +244,7 @@ abstract class BaseField
|
|||
/**
|
||||
* Rendering
|
||||
*/
|
||||
public function render(array $content = [], string $locale = null): string
|
||||
public function render(array $content = [], ?string $locale = null): string
|
||||
{
|
||||
$value = $this->getValue($content, $locale);
|
||||
|
||||
|
|
@ -242,8 +260,8 @@ abstract class BaseField
|
|||
|
||||
$viewName = "flux-cms::fields.{$this->getType()}";
|
||||
|
||||
if (!view()->exists($viewName)) {
|
||||
$viewName = "flux-cms::fields.fallback";
|
||||
if (! view()->exists($viewName)) {
|
||||
$viewName = 'flux-cms::fields.fallback';
|
||||
}
|
||||
|
||||
return view($viewName, $viewData)->render();
|
||||
|
|
@ -252,7 +270,7 @@ abstract class BaseField
|
|||
/**
|
||||
* Wire Model für Livewire
|
||||
*/
|
||||
public function getWireModel(string $locale = null): string
|
||||
public function getWireModel(?string $locale = null): string
|
||||
{
|
||||
if ($this->translatable && $locale) {
|
||||
return "content.{$this->key}.{$locale}";
|
||||
|
|
@ -264,12 +282,12 @@ abstract class BaseField
|
|||
/**
|
||||
* Field ID für Labels
|
||||
*/
|
||||
public function getFieldId(string $locale = null): string
|
||||
public function getFieldId(?string $locale = null): string
|
||||
{
|
||||
$id = 'field_' . $this->key;
|
||||
$id = 'field_'.$this->key;
|
||||
|
||||
if ($this->translatable && $locale) {
|
||||
$id .= '_' . $locale;
|
||||
$id .= '_'.$locale;
|
||||
}
|
||||
|
||||
return $id;
|
||||
|
|
@ -280,7 +298,7 @@ abstract class BaseField
|
|||
*/
|
||||
public function getCssClasses(bool $hasError = false): string
|
||||
{
|
||||
$classes = ['flux-cms-field', 'flux-cms-field--' . $this->getType()];
|
||||
$classes = ['flux-cms-field', 'flux-cms-field--'.$this->getType()];
|
||||
|
||||
if ($this->required) {
|
||||
$classes[] = 'flux-cms-field--required';
|
||||
|
|
@ -324,4 +342,4 @@ abstract class BaseField
|
|||
{
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,37 +5,44 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class BooleanField extends BaseField
|
||||
{
|
||||
protected string $trueLabel = 'Yes';
|
||||
|
||||
protected string $falseLabel = 'No';
|
||||
|
||||
protected string $displayType = 'checkbox'; // checkbox, toggle, radio
|
||||
|
||||
public function labels(string $trueLabel, string $falseLabel): static
|
||||
{
|
||||
$this->trueLabel = $trueLabel;
|
||||
$this->falseLabel = $falseLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function displayType(string $type): static
|
||||
{
|
||||
$this->displayType = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toggle(): static
|
||||
{
|
||||
$this->displayType = 'toggle';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function radio(): static
|
||||
{
|
||||
$this->displayType = 'radio';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function checkbox(): static
|
||||
{
|
||||
$this->displayType = 'checkbox';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -79,15 +86,17 @@ class BooleanField extends BaseField
|
|||
// Convert various truthy values to boolean
|
||||
if (is_string($value)) {
|
||||
$value = strtolower($value);
|
||||
|
||||
return in_array($value, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
public function getValue(array $content, ?string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
|
||||
return $this->sanitizeValue($value);
|
||||
}
|
||||
|
||||
|
|
@ -112,4 +121,4 @@ class BooleanField extends BaseField
|
|||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,23 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class MediaField extends BaseField
|
||||
{
|
||||
protected array $acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
protected bool $multiple = false;
|
||||
|
||||
protected int $maxFiles = 1;
|
||||
|
||||
protected ?string $collection = null;
|
||||
|
||||
protected array $conversions = [];
|
||||
|
||||
protected int $maxFileSize = 10240; // 10MB in KB
|
||||
|
||||
protected bool $showPreview = true;
|
||||
|
||||
public function acceptedMimeTypes(array $mimeTypes): static
|
||||
{
|
||||
$this->acceptedMimeTypes = $mimeTypes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -22,30 +29,35 @@ class MediaField extends BaseField
|
|||
{
|
||||
$this->multiple = $multiple;
|
||||
$this->maxFiles = $maxFiles;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function collection(string $collection): static
|
||||
{
|
||||
$this->collection = $collection;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function conversions(array $conversions): static
|
||||
{
|
||||
$this->conversions = $conversions;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function maxFileSize(int $sizeInKb): static
|
||||
{
|
||||
$this->maxFileSize = $sizeInKb;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function showPreview(bool $show = true): static
|
||||
{
|
||||
$this->showPreview = $show;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +65,7 @@ class MediaField extends BaseField
|
|||
{
|
||||
$this->acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
|
||||
$this->collection = 'images';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -64,10 +77,11 @@ class MediaField extends BaseField
|
|||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv'
|
||||
'text/csv',
|
||||
];
|
||||
$this->collection = 'documents';
|
||||
$this->showPreview = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +89,7 @@ class MediaField extends BaseField
|
|||
{
|
||||
$this->acceptedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
$this->collection = 'videos';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +98,7 @@ class MediaField extends BaseField
|
|||
$this->acceptedMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg'];
|
||||
$this->collection = 'audio';
|
||||
$this->showPreview = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -157,38 +173,37 @@ class MediaField extends BaseField
|
|||
|
||||
public function isImageType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'image/')));
|
||||
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'image/')));
|
||||
}
|
||||
|
||||
public function isVideoType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'video/')));
|
||||
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'video/')));
|
||||
}
|
||||
|
||||
public function isAudioType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'audio/')));
|
||||
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'audio/')));
|
||||
}
|
||||
|
||||
public function isDocumentType(): bool
|
||||
{
|
||||
return !empty(array_filter($this->acceptedMimeTypes, fn($type) =>
|
||||
str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
|
||||
return ! empty(array_filter($this->acceptedMimeTypes, fn ($type) => str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
|
||||
));
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
public function getValue(array $content, ?string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
|
||||
// Ensure array for multiple fields
|
||||
if ($this->multiple && !is_array($value)) {
|
||||
if ($this->multiple && ! is_array($value)) {
|
||||
return $value ? [$value] : [];
|
||||
}
|
||||
|
||||
// Ensure integer for single fields
|
||||
if (!$this->multiple && is_array($value)) {
|
||||
return !empty($value) ? (int) $value[0] : null;
|
||||
if (! $this->multiple && is_array($value)) {
|
||||
return ! empty($value) ? (int) $value[0] : null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
|
|
@ -230,4 +245,4 @@ class MediaField extends BaseField
|
|||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class NumberField extends BaseField
|
||||
{
|
||||
protected ?float $min = null;
|
||||
|
||||
protected ?float $max = null;
|
||||
|
||||
protected float $step = 1;
|
||||
|
||||
protected bool $decimal = false;
|
||||
|
||||
public function min(float $min): static
|
||||
{
|
||||
$this->min = $min;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function max(float $max): static
|
||||
{
|
||||
$this->max = $max;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function step(float $step): static
|
||||
{
|
||||
$this->step = $step;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +39,7 @@ class NumberField extends BaseField
|
|||
if ($decimal && $this->step === 1) {
|
||||
$this->step = 0.01;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +48,7 @@ class NumberField extends BaseField
|
|||
$this->decimal(true);
|
||||
$this->step(0.01);
|
||||
$this->min(0);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +58,7 @@ class NumberField extends BaseField
|
|||
$this->min(0);
|
||||
$this->max(100);
|
||||
$this->step(0.1);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -141,4 +150,4 @@ class NumberField extends BaseField
|
|||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,31 +5,38 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class SelectField extends BaseField
|
||||
{
|
||||
protected array $options = [];
|
||||
|
||||
protected bool $multiple = false;
|
||||
|
||||
protected bool $searchable = false;
|
||||
|
||||
protected ?string $emptyOption = null;
|
||||
|
||||
public function options(array $options): static
|
||||
{
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function multiple(bool $multiple = true): static
|
||||
{
|
||||
$this->multiple = $multiple;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function searchable(bool $searchable = true): static
|
||||
{
|
||||
$this->searchable = $searchable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function emptyOption(string $text): static
|
||||
{
|
||||
$this->emptyOption = $text;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -72,34 +79,34 @@ class SelectField extends BaseField
|
|||
$rules[] = 'string';
|
||||
}
|
||||
|
||||
if (!empty($this->options)) {
|
||||
if (! empty($this->options)) {
|
||||
$validValues = array_keys($this->options);
|
||||
if ($this->multiple) {
|
||||
$rules[] = 'array';
|
||||
// Each value must be in the valid options
|
||||
foreach ($validValues as $value) {
|
||||
$rules[] = "array";
|
||||
$rules[] = 'array';
|
||||
}
|
||||
} else {
|
||||
$rules[] = "in:" . implode(',', $validValues);
|
||||
$rules[] = 'in:'.implode(',', $validValues);
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($rules, $this->rules);
|
||||
}
|
||||
|
||||
public function getValue(array $content, string $locale = null): mixed
|
||||
public function getValue(array $content, ?string $locale = null): mixed
|
||||
{
|
||||
$value = parent::getValue($content, $locale);
|
||||
|
||||
// Ensure array for multiple selects
|
||||
if ($this->multiple && !is_array($value)) {
|
||||
if ($this->multiple && ! is_array($value)) {
|
||||
return $value ? [$value] : [];
|
||||
}
|
||||
|
||||
// Ensure string for single selects
|
||||
if (!$this->multiple && is_array($value)) {
|
||||
return !empty($value) ? (string) $value[0] : '';
|
||||
if (! $this->multiple && is_array($value)) {
|
||||
return ! empty($value) ? (string) $value[0] : '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
|
|
@ -122,4 +129,4 @@ class SelectField extends BaseField
|
|||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,25 +5,31 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class TextField extends BaseField
|
||||
{
|
||||
protected int $maxLength = 255;
|
||||
|
||||
protected int $minLength = 0;
|
||||
|
||||
protected ?string $pattern = null;
|
||||
|
||||
protected string $inputType = 'text';
|
||||
|
||||
public function maxLength(int $maxLength): static
|
||||
{
|
||||
$this->maxLength = $maxLength;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function minLength(int $minLength): static
|
||||
{
|
||||
$this->minLength = $minLength;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function pattern(string $pattern): static
|
||||
{
|
||||
$this->pattern = $pattern;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +37,7 @@ class TextField extends BaseField
|
|||
{
|
||||
$this->inputType = 'email';
|
||||
$this->rules(['email']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -38,24 +45,28 @@ class TextField extends BaseField
|
|||
{
|
||||
$this->inputType = 'url';
|
||||
$this->rules(['url']);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function password(): static
|
||||
{
|
||||
$this->inputType = 'password';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function tel(): static
|
||||
{
|
||||
$this->inputType = 'tel';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function search(): static
|
||||
{
|
||||
$this->inputType = 'search';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +128,7 @@ class TextField extends BaseField
|
|||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +139,8 @@ class TextField extends BaseField
|
|||
$value = strtolower($value);
|
||||
} elseif ($this->inputType === 'url') {
|
||||
// Ensure URL has protocol
|
||||
if ($value && !preg_match('/^https?:\/\//', $value)) {
|
||||
$value = 'https://' . $value;
|
||||
if ($value && ! preg_match('/^https?:\/\//', $value)) {
|
||||
$value = 'https://'.$value;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,4 +174,4 @@ class TextField extends BaseField
|
|||
'regex' => 'The :attribute field format is invalid.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,45 +5,56 @@ namespace FluxCms\Core\FieldTypes;
|
|||
class WysiwygField extends BaseField
|
||||
{
|
||||
protected array $toolbar = ['bold', 'italic', 'link', 'bulletList', 'orderedList'];
|
||||
|
||||
protected int $minHeight = 200;
|
||||
|
||||
protected bool $allowImages = true;
|
||||
|
||||
protected bool $allowTables = false;
|
||||
|
||||
protected bool $allowCode = true;
|
||||
|
||||
protected string $editor = 'tiptap'; // tiptap, tinymce, quill
|
||||
|
||||
public function toolbar(array $toolbar): static
|
||||
{
|
||||
$this->toolbar = $toolbar;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function minHeight(int $minHeight): static
|
||||
{
|
||||
$this->minHeight = $minHeight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowImages(bool $allowImages = true): static
|
||||
{
|
||||
$this->allowImages = $allowImages;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowTables(bool $allowTables = true): static
|
||||
{
|
||||
$this->allowTables = $allowTables;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function allowCode(bool $allowCode = true): static
|
||||
{
|
||||
$this->allowCode = $allowCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function editor(string $editor): static
|
||||
{
|
||||
$this->editor = $editor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +64,7 @@ class WysiwygField extends BaseField
|
|||
$this->allowImages = false;
|
||||
$this->allowTables = false;
|
||||
$this->allowCode = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -64,11 +76,12 @@ class WysiwygField extends BaseField
|
|||
'bulletList', 'orderedList',
|
||||
'link', 'image', 'table',
|
||||
'code', 'codeBlock',
|
||||
'quote', 'rule'
|
||||
'quote', 'rule',
|
||||
];
|
||||
$this->allowImages = true;
|
||||
$this->allowTables = true;
|
||||
$this->allowCode = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +133,7 @@ class WysiwygField extends BaseField
|
|||
|
||||
public function sanitizeValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
|
@ -130,8 +143,8 @@ class WysiwygField extends BaseField
|
|||
// Remove dangerous tags
|
||||
$dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'];
|
||||
foreach ($dangerousTags as $tag) {
|
||||
$value = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $value);
|
||||
$value = preg_replace('/<' . $tag . '[^>]*\/?>/is', '', $value);
|
||||
$value = preg_replace('/<'.$tag.'[^>]*>.*?<\/'.$tag.'>/is', '', $value);
|
||||
$value = preg_replace('/<'.$tag.'[^>]*\/?>/is', '', $value);
|
||||
}
|
||||
|
||||
// Remove javascript: links
|
||||
|
|
@ -145,13 +158,13 @@ class WysiwygField extends BaseField
|
|||
|
||||
public function transformForDisplay(mixed $value): mixed
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Convert relative URLs to absolute
|
||||
$value = preg_replace_callback('/src="(\/[^"]*)"/', function ($matches) {
|
||||
return 'src="' . url($matches[1]) . '"';
|
||||
return 'src="'.url($matches[1]).'"';
|
||||
}, $value);
|
||||
|
||||
return $value;
|
||||
|
|
@ -176,4 +189,4 @@ class WysiwygField extends BaseField
|
|||
'attributes' => $this->attributes,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,152 +2,63 @@
|
|||
|
||||
namespace FluxCms\Core;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use FluxCms\Core\Commands\InstallCommand;
|
||||
use FluxCms\Core\Commands\PublishCommand;
|
||||
use FluxCms\Core\Commands\ClearCacheCommand;
|
||||
use FluxCms\Core\Commands\MakeComponentCommand;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class FluxCmsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Merge config
|
||||
$this->mergeConfigFrom(__DIR__ . '/../config/flux-cms.php', 'flux-cms');
|
||||
$this->mergeConfigFrom(__DIR__.'/../config/flux-cms.php', 'flux-cms');
|
||||
|
||||
// Register services
|
||||
$this->app->singleton(ComponentRegistry::class, function ($app) {
|
||||
return new ComponentRegistry();
|
||||
$this->app->singleton(CmsContentService::class, function () {
|
||||
return new CmsContentService;
|
||||
});
|
||||
|
||||
// Register aliases
|
||||
$this->app->alias(ComponentRegistry::class, 'flux-cms.registry');
|
||||
$this->app->alias(CmsContentService::class, 'flux-cms.content');
|
||||
|
||||
$this->app->singleton(MediaConversionService::class, function () {
|
||||
return new MediaConversionService;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->bootPublishing();
|
||||
$this->bootMigrations();
|
||||
$this->bootViews();
|
||||
$this->bootCommands();
|
||||
$this->bootRoutes();
|
||||
$this->bootMiddleware();
|
||||
$this->bootGates();
|
||||
$this->bootTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot publishing
|
||||
*/
|
||||
protected function bootPublishing(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
// Publish config
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
|
||||
__DIR__.'/../config/flux-cms.php' => config_path('flux-cms.php'),
|
||||
], 'flux-cms-config');
|
||||
|
||||
// Publish migrations
|
||||
$this->publishes([
|
||||
__DIR__ . '/../database/migrations' => database_path('migrations'),
|
||||
__DIR__.'/../database/migrations' => database_path('migrations'),
|
||||
], 'flux-cms-migrations');
|
||||
|
||||
// Publish views
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
|
||||
__DIR__.'/../resources/views' => resource_path('views/vendor/flux-cms'),
|
||||
], 'flux-cms-views');
|
||||
|
||||
// Publish all
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
|
||||
__DIR__ . '/../database/migrations' => database_path('migrations'),
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
|
||||
], 'flux-cms');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot migrations
|
||||
*/
|
||||
protected function bootMigrations(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot views
|
||||
*/
|
||||
protected function bootViews(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms');
|
||||
$this->loadViewsFrom(__DIR__.'/../resources/views', 'flux-cms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot commands
|
||||
*/
|
||||
protected function bootCommands(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
InstallCommand::class,
|
||||
PublishCommand::class,
|
||||
ClearCacheCommand::class,
|
||||
MakeComponentCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot routes
|
||||
*/
|
||||
protected function bootRoutes(): void
|
||||
{
|
||||
if (config('flux-cms.routes.enabled', true)) {
|
||||
$this->loadRoutesFromDirectory(__DIR__ . '/../routes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot middleware
|
||||
*/
|
||||
protected function bootMiddleware(): void
|
||||
{
|
||||
$router = $this->app['router'];
|
||||
|
||||
// Register middleware aliases
|
||||
$router->aliasMiddleware('flux-cms:cms-access', \FluxCms\Core\Http\Middleware\CmsAccess::class);
|
||||
$router->aliasMiddleware('flux-cms:domain-detection', \FluxCms\Core\Http\Middleware\DomainDetection::class);
|
||||
$router->aliasMiddleware('flux-cms:preview-mode', \FluxCms\Core\Http\Middleware\PreviewMode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load routes from directory
|
||||
*/
|
||||
protected function loadRoutesFromDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($directory . '/*.php');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$this->loadRoutesFrom($file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot authorization gates
|
||||
*/
|
||||
protected function bootGates(): void
|
||||
{
|
||||
Gate::define('flux-cms.view', function ($user) {
|
||||
|
|
@ -171,52 +82,22 @@ class FluxCmsServiceProvider extends ServiceProvider
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has CMS permission
|
||||
*/
|
||||
protected function userHasCmsPermission($user, string $permission): bool
|
||||
{
|
||||
// If no user, deny access
|
||||
if (!$user) {
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Spatie Permission package
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can("flux-cms.{$permission}") || $user->hasRole('flux-cms') || $user->hasRole('admin');
|
||||
if (method_exists($user, 'hasRole')) {
|
||||
return $user->can("flux-cms.{$permission}")
|
||||
|| $user->hasRole('flux-cms')
|
||||
|| $user->hasRole('admin');
|
||||
}
|
||||
|
||||
// Fallback: Check if user has admin role property
|
||||
if (isset($user->is_admin)) {
|
||||
return $user->is_admin;
|
||||
}
|
||||
|
||||
// Default: Allow access for authenticated users (can be overridden in config)
|
||||
return config('flux-cms.auth.default_access', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot translations
|
||||
*/
|
||||
protected function bootTranslations(): void
|
||||
{
|
||||
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'flux-cms');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/lang' => resource_path('lang/vendor/flux-cms'),
|
||||
], 'flux-cms-translations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [
|
||||
ComponentRegistry::class,
|
||||
'flux-cms.registry',
|
||||
];
|
||||
return config('flux-cms.auth.default_access', true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
Normal file
45
packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class MediaLibraryUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $uploads = [];
|
||||
|
||||
public function updatedUploads(): void
|
||||
{
|
||||
$this->validate([
|
||||
'uploads' => 'nullable|array|max:20',
|
||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
||||
]);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
|
||||
foreach ($this->uploads as $file) {
|
||||
$media = $service->storeUpload($file);
|
||||
$this->dispatch('media-library-uploaded', mediaId: $media->id);
|
||||
}
|
||||
|
||||
$this->uploads = [];
|
||||
}
|
||||
|
||||
public function removeUpload(int $index): void
|
||||
{
|
||||
if (isset($this->uploads[$index])) {
|
||||
unset($this->uploads[$index]);
|
||||
$this->uploads = array_values($this->uploads);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.cms.media-library-uploader');
|
||||
}
|
||||
}
|
||||
139
packages/flux-cms/core/src/Helpers/MediaPicker.php
Normal file
139
packages/flux-cms/core/src/Helpers/MediaPicker.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class MediaPicker extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use WithPagination;
|
||||
|
||||
public ?int $value = null;
|
||||
|
||||
public string $field = 'media_id';
|
||||
|
||||
public string $type = 'image';
|
||||
|
||||
public string $profile = 'thumbnail';
|
||||
|
||||
public string $label = 'Bild auswählen';
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $quickUploads = [];
|
||||
|
||||
public function mount(?int $value = null): void
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function openPicker(): void
|
||||
{
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function selectMedia(int $id): void
|
||||
{
|
||||
$media = CmsMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($media->isImage() && $this->profile) {
|
||||
$service = app(MediaConversionService::class);
|
||||
if (! $media->hasConversion($this->profile)) {
|
||||
$service->convert($media, $this->profile);
|
||||
$media->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$this->value = $media->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile));
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->value = null;
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: null, url: null);
|
||||
}
|
||||
|
||||
public function updatedQuickUploads(): void
|
||||
{
|
||||
$this->validate([
|
||||
'quickUploads' => 'nullable|array|max:5',
|
||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
||||
]);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$lastMedia = null;
|
||||
|
||||
foreach ($this->quickUploads as $file) {
|
||||
$lastMedia = $service->storeUpload($file);
|
||||
|
||||
if ($lastMedia->isImage() && $this->profile) {
|
||||
$service->convert($lastMedia, $this->profile);
|
||||
$lastMedia->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$this->quickUploads = [];
|
||||
|
||||
if ($lastMedia) {
|
||||
$this->value = $lastMedia->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile));
|
||||
}
|
||||
}
|
||||
|
||||
public function removeQuickUpload(int $index): void
|
||||
{
|
||||
if (isset($this->quickUploads[$index])) {
|
||||
unset($this->quickUploads[$index]);
|
||||
$this->quickUploads = array_values($this->quickUploads);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.admin.cms.media-picker', [
|
||||
'selectedMedia' => $this->resolveSelectedMedia(),
|
||||
'mediaItems' => $this->resolveMediaItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSelectedMedia(): ?CmsMedia
|
||||
{
|
||||
if (! $this->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CmsMedia::find($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, CmsMedia>
|
||||
*/
|
||||
private function resolveMediaItems(): LengthAwarePaginator
|
||||
{
|
||||
return CmsMedia::query()
|
||||
->when($this->type === 'image', fn ($q) => $q->images())
|
||||
->when($this->type === 'pdf', fn ($q) => $q->pdfs())
|
||||
->when($this->type === 'document', fn ($q) => $q->documents())
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(18);
|
||||
}
|
||||
}
|
||||
39
packages/flux-cms/core/src/Helpers/MediaUploader.php
Normal file
39
packages/flux-cms/core/src/Helpers/MediaUploader.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class MediaUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $field = '';
|
||||
|
||||
public string $accept = 'image/*';
|
||||
|
||||
public string $disk = 'public';
|
||||
|
||||
public string $directory = 'cms/uploads';
|
||||
|
||||
#[Validate('file|max:10240')]
|
||||
public $file;
|
||||
|
||||
public function updatedFile(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$path = $this->file->store($this->directory, $this->disk);
|
||||
|
||||
$this->dispatch('media-uploaded', field: $this->field, path: '/storage/'.$path);
|
||||
|
||||
$this->file = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.cms.media-uploader');
|
||||
}
|
||||
}
|
||||
85
packages/flux-cms/core/src/Helpers/cms_helpers.php
Normal file
85
packages/flux-cms/core/src/Helpers/cms_helpers.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
if (! function_exists('cms')) {
|
||||
/**
|
||||
* Resolve a CMS content value by dot-notation key.
|
||||
*
|
||||
* First segment is the group, rest is the key: "welcome.hero.heading"
|
||||
* Falls back to __() if no DB entry exists.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function cms(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
return app(CmsContentService::class)->get($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tcms')) {
|
||||
/**
|
||||
* CMS content with automatic tooltip replacement.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function tcms(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
$text = cms($key, $replace, $locale);
|
||||
|
||||
if (! is_string($text)) {
|
||||
$text = (string) $text;
|
||||
}
|
||||
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('t__')) {
|
||||
/**
|
||||
* Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description')
|
||||
* @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max'])
|
||||
* @param string|null $locale Optionale Locale (Standard: aktuelle Locale)
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function t__(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
// Holt den übersetzten Text
|
||||
$text = __($key, $replace, $locale);
|
||||
|
||||
// Wendet automatische Tooltip-Ersetzung an
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('trans_tooltip')) {
|
||||
/**
|
||||
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel
|
||||
* @param array $replace Optionale Ersetzungen
|
||||
* @param string|null $locale Optionale Locale
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
return t__($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tooltip')) {
|
||||
/**
|
||||
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel
|
||||
* @param array $replace Optionale Ersetzungen
|
||||
* @param string|null $locale Optionale Locale
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function tooltip(string $text): string
|
||||
{
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
144
packages/flux-cms/core/src/Helpers/helpers.php
Normal file
144
packages/flux-cms/core/src/Helpers/helpers.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
if (! function_exists('cms')) {
|
||||
/**
|
||||
* Resolve a CMS content value by dot-notation key.
|
||||
*
|
||||
* First segment is the group, rest is the key: "welcome.hero.heading"
|
||||
* Falls back to __() if no DB entry exists.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function cms(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
return app(CmsContentService::class)->get($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('media_url')) {
|
||||
/**
|
||||
* Resolve a media library filename to its storage URL.
|
||||
*
|
||||
* Looks up CmsMedia by filename and returns the URL.
|
||||
* Falls back to asset('assets/images/...') if not found.
|
||||
*/
|
||||
function media_url(string $filename, ?string $profile = null): string
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
$cacheKey = $filename.'|'.($profile ?? '');
|
||||
if (isset($cache[$cacheKey])) {
|
||||
return $cache[$cacheKey];
|
||||
}
|
||||
|
||||
$media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first();
|
||||
|
||||
if (! $media) {
|
||||
return $cache[$cacheKey] = asset('assets/images/'.$filename);
|
||||
}
|
||||
|
||||
if ($profile && $media->hasConversion($profile)) {
|
||||
return $cache[$cacheKey] = $media->getConversionUrl($profile);
|
||||
}
|
||||
|
||||
return $cache[$cacheKey] = $media->getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms_media_url')) {
|
||||
/**
|
||||
* Resolve a CMS content key to a media library URL.
|
||||
*
|
||||
* The CMS entry stores a CmsMedia filename.
|
||||
* Returns the original URL or a conversion URL if a profile is specified.
|
||||
*/
|
||||
function cms_media_url(string $key, ?string $profile = null): string
|
||||
{
|
||||
$filename = cms($key);
|
||||
|
||||
if (! $filename || ! is_string($filename)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$media = \FluxCms\Core\Models\CmsMedia::where('filename', $filename)->first();
|
||||
|
||||
if (! $media) {
|
||||
return asset('assets/images/'.$filename);
|
||||
}
|
||||
|
||||
if ($profile && $media->hasConversion($profile)) {
|
||||
return $media->getConversionUrl($profile);
|
||||
}
|
||||
|
||||
return $media->getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tcms')) {
|
||||
/**
|
||||
* CMS content with automatic tooltip replacement.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function tcms(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
$text = cms($key, $replace, $locale);
|
||||
|
||||
if (! is_string($text)) {
|
||||
$text = (string) $text;
|
||||
}
|
||||
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('t__')) {
|
||||
/**
|
||||
* Übersetzt einen Schlüssel und ersetzt automatisch alle bekannten Begriffe mit Tooltips
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel (z.B. 'digitale-transformation.hero.description')
|
||||
* @param array $replace Optionale Ersetzungen (z.B. ['name' => 'Max'])
|
||||
* @param string|null $locale Optionale Locale (Standard: aktuelle Locale)
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function t__(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
// Holt den übersetzten Text
|
||||
$text = __($key, $replace, $locale);
|
||||
|
||||
// Wendet automatische Tooltip-Ersetzung an
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('trans_tooltip')) {
|
||||
/**
|
||||
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel
|
||||
* @param array $replace Optionale Ersetzungen
|
||||
* @param string|null $locale Optionale Locale
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function trans_tooltip(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
return t__($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tooltip')) {
|
||||
/**
|
||||
* Alias für t__() - Übersetzt einen Schlüssel und fügt automatisch Tooltips hinzu
|
||||
*
|
||||
* @param string $key Der Übersetzungsschlüssel
|
||||
* @param array $replace Optionale Ersetzungen
|
||||
* @param string|null $locale Optionale Locale
|
||||
* @return string Der übersetzte Text mit automatischen Tooltips
|
||||
*/
|
||||
function tooltip(string $text): string
|
||||
{
|
||||
return \App\Helpers\TooltipHelper::autoTooltip($text);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
|
|
@ -36,6 +36,7 @@ class BlogController extends Controller
|
|||
public function edit(BlogPost $blogPost)
|
||||
{
|
||||
$this->authorize('flux-cms.edit');
|
||||
|
||||
return view('flux-cms::admin.blog.edit', ['post' => $blogPost]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace FluxCms\Core\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class ComponentController extends Controller
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
50
packages/flux-cms/core/src/Models/CmsContent.php
Normal file
50
packages/flux-cms/core/src/Models/CmsContent.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
97
packages/flux-cms/core/src/Models/CmsDownload.php
Normal file
97
packages/flux-cms/core/src/Models/CmsDownload.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
packages/flux-cms/core/src/Models/CmsFaq.php
Normal file
51
packages/flux-cms/core/src/Models/CmsFaq.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
packages/flux-cms/core/src/Models/CmsIndustry.php
Normal file
41
packages/flux-cms/core/src/Models/CmsIndustry.php
Normal 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');
|
||||
}
|
||||
}
|
||||
64
packages/flux-cms/core/src/Models/CmsLinkedinPost.php
Normal file
64
packages/flux-cms/core/src/Models/CmsLinkedinPost.php
Normal 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');
|
||||
}
|
||||
}
|
||||
149
packages/flux-cms/core/src/Models/CmsMedia.php
Normal file
149
packages/flux-cms/core/src/Models/CmsMedia.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
88
packages/flux-cms/core/src/Models/CmsNewsItem.php
Normal file
88
packages/flux-cms/core/src/Models/CmsNewsItem.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
172
packages/flux-cms/core/src/Models/CmsSearchIndex.php
Normal file
172
packages/flux-cms/core/src/Models/CmsSearchIndex.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue