10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class PageVersion extends Model
|
|||
{
|
||||
public function getTable()
|
||||
{
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_versions';
|
||||
return config('flux-cms.database.table_prefix', 'flux_cms_').'page_versions';
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -80,18 +80,18 @@ class PageVersion extends Model
|
|||
$changes = [];
|
||||
|
||||
foreach ($old as $key => $value) {
|
||||
if (!isset($new[$key])) {
|
||||
if (! isset($new[$key])) {
|
||||
$changes['removed'][$key] = $value;
|
||||
} elseif ($new[$key] !== $value) {
|
||||
$changes['changed'][$key] = [
|
||||
'old' => $value,
|
||||
'new' => $new[$key]
|
||||
'new' => $new[$key],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($new as $key => $value) {
|
||||
if (!isset($old[$key])) {
|
||||
if (! isset($old[$key])) {
|
||||
$changes['added'][$key] = $value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
194
packages/flux-cms/core/src/Services/CmsContentService.php
Normal file
194
packages/flux-cms/core/src/Services/CmsContentService.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CmsContentService
|
||||
{
|
||||
protected int $cacheTtl;
|
||||
|
||||
protected string $cachePrefix;
|
||||
|
||||
/**
|
||||
* In-memory grouped content — populated once per request via preloadAll().
|
||||
*
|
||||
* @var array<string, \Illuminate\Support\Collection>
|
||||
*/
|
||||
protected array $memoryCache = [];
|
||||
|
||||
protected bool $preloaded = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->cacheTtl = config('flux-cms.cache.ttl', 3600);
|
||||
$this->cachePrefix = config('flux-cms.cache.key_prefix', 'flux_cms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CMS content value by dot-notation key.
|
||||
*
|
||||
* Format: "group.nested.key" where the first segment is the group
|
||||
* and the rest is the key within that group.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
public function get(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale', 'de');
|
||||
|
||||
[$group, $contentKey] = $this->parseKey($key);
|
||||
|
||||
if (! $group || ! $contentKey) {
|
||||
return $this->fallbackToLang($key, $replace, $locale);
|
||||
}
|
||||
|
||||
$groupData = $this->loadGroup($group);
|
||||
|
||||
$content = $groupData->firstWhere('key', $contentKey);
|
||||
|
||||
if (! $content) {
|
||||
return $this->fallbackToLang($key, $replace, $locale);
|
||||
}
|
||||
|
||||
$value = $content->getTranslation('value', $locale)
|
||||
?? $content->getTranslation('value', $fallbackLocale);
|
||||
|
||||
if ($value === null) {
|
||||
return $this->fallbackToLang($key, $replace, $locale);
|
||||
}
|
||||
|
||||
if (is_string($value) && ! empty($replace)) {
|
||||
foreach ($replace as $placeholder => $replacement) {
|
||||
$value = str_replace(":{$placeholder}", (string) $replacement, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie get(), aber ohne Fallback auf __() – liefert null, wenn kein CMS-Eintrag existiert.
|
||||
*/
|
||||
public function getIfExists(string $key, ?string $locale = null): mixed
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale', 'de');
|
||||
|
||||
[$group, $contentKey] = $this->parseKey($key);
|
||||
|
||||
if (! $group || ! $contentKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupData = $this->loadGroup($group);
|
||||
|
||||
$content = $groupData->firstWhere('key', $contentKey);
|
||||
|
||||
if (! $content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $content->getTranslation('value', $locale)
|
||||
?? $content->getTranslation('value', $fallbackLocale);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all content for a group, keyed by content key.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getGroup(string $group, ?string $locale = null): array
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
|
||||
$groupData = $this->loadGroup($group);
|
||||
|
||||
$result = [];
|
||||
foreach ($groupData as $content) {
|
||||
$value = $content->getTranslation('value', $locale)
|
||||
?? $content->getTranslation('value', config('app.fallback_locale', 'de'));
|
||||
$result[$content->key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a group — triggers a single bulk preload on first access.
|
||||
*/
|
||||
protected function loadGroup(string $group): \Illuminate\Support\Collection
|
||||
{
|
||||
if (! $this->preloaded) {
|
||||
$this->preloadAll();
|
||||
}
|
||||
|
||||
return $this->memoryCache[$group] ?? collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload ALL CMS content in a single query + single cache entry,
|
||||
* then group by content group in memory.
|
||||
*/
|
||||
protected function preloadAll(): void
|
||||
{
|
||||
$this->preloaded = true;
|
||||
|
||||
$cacheKey = "{$this->cachePrefix}.content.__all__";
|
||||
|
||||
$allContent = Cache::remember($cacheKey, $this->cacheTtl, function () {
|
||||
return CmsContent::query()->orderBy('group')->orderBy('order')->get();
|
||||
});
|
||||
|
||||
foreach ($allContent->groupBy('group') as $groupName => $items) {
|
||||
$this->memoryCache[$groupName] = $items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific group or all groups.
|
||||
*/
|
||||
public function clearCache(?string $group = null): void
|
||||
{
|
||||
Cache::forget("{$this->cachePrefix}.content.__all__");
|
||||
$this->memoryCache = [];
|
||||
$this->preloaded = false;
|
||||
|
||||
if ($group) {
|
||||
Cache::forget("{$this->cachePrefix}.content.{$group}");
|
||||
} else {
|
||||
$groups = CmsContent::query()->distinct()->pluck('group');
|
||||
foreach ($groups as $g) {
|
||||
Cache::forget("{$this->cachePrefix}.content.{$g}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string|null, 1: string|null}
|
||||
*/
|
||||
protected function parseKey(string $key): array
|
||||
{
|
||||
$dotPos = strpos($key, '.');
|
||||
if ($dotPos === false) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
$group = substr($key, 0, $dotPos);
|
||||
$contentKey = substr($key, $dotPos + 1);
|
||||
|
||||
return [$group, $contentKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
protected function fallbackToLang(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
return __($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,20 @@
|
|||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
use FluxCms\Core\FieldTypes\BaseField;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use FluxCms\Core\FieldTypes\BaseField;
|
||||
|
||||
class ComponentRegistry
|
||||
{
|
||||
protected array $componentPaths = [];
|
||||
|
||||
protected string $cacheKey = 'flux_cms.component.registry';
|
||||
|
||||
protected int $cacheTtl = 3600; // 1 Stunde
|
||||
|
||||
protected bool $cacheEnabled = true;
|
||||
|
||||
public function __construct()
|
||||
|
|
@ -31,7 +34,7 @@ class ComponentRegistry
|
|||
*/
|
||||
public function getAvailableComponents(): array
|
||||
{
|
||||
if (!$this->cacheEnabled) {
|
||||
if (! $this->cacheEnabled) {
|
||||
return $this->scanComponents();
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +49,7 @@ class ComponentRegistry
|
|||
public function getComponent(string $className): ?array
|
||||
{
|
||||
$components = $this->getAvailableComponents();
|
||||
|
||||
return $components[$className] ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +58,7 @@ class ComponentRegistry
|
|||
*/
|
||||
public function isValidComponent(string $className): bool
|
||||
{
|
||||
if (!class_exists($className)) {
|
||||
if (! class_exists($className)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -62,18 +66,18 @@ class ComponentRegistry
|
|||
$reflection = new ReflectionClass($className);
|
||||
|
||||
// Muss getCmsFields Methode haben
|
||||
if (!$reflection->hasMethod('getCmsFields')) {
|
||||
if (! $reflection->hasMethod('getCmsFields')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// getCmsFields muss static sein
|
||||
$method = $reflection->getMethod('getCmsFields');
|
||||
if (!$method->isStatic() || !$method->isPublic()) {
|
||||
if (! $method->isStatic() || ! $method->isPublic()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Muss von Livewire\Component erben
|
||||
if (!$reflection->isSubclassOf(\Livewire\Component::class)) {
|
||||
if (! $reflection->isSubclassOf(\Livewire\Component::class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -88,13 +92,13 @@ class ComponentRegistry
|
|||
*/
|
||||
public function getComponentConfig(string $className): array
|
||||
{
|
||||
if (!$this->isValidComponent($className)) {
|
||||
if (! $this->isValidComponent($className)) {
|
||||
return [
|
||||
'class' => $className,
|
||||
'name' => 'Invalid Component',
|
||||
'fields' => [],
|
||||
'category' => 'Error',
|
||||
'error' => 'Component is not valid or does not exist'
|
||||
'error' => 'Component is not valid or does not exist',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -116,13 +120,14 @@ class ComponentRegistry
|
|||
|
||||
return $config;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Error getting component config for {$className}: " . $e->getMessage());
|
||||
\Log::error("Error getting component config for {$className}: ".$e->getMessage());
|
||||
|
||||
return [
|
||||
'class' => $className,
|
||||
'name' => 'Error Component',
|
||||
'fields' => [],
|
||||
'category' => 'Error',
|
||||
'error' => $e->getMessage()
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -137,7 +142,7 @@ class ComponentRegistry
|
|||
foreach ($this->componentPaths as $namespace) {
|
||||
$path = $this->namespaceToPath($namespace);
|
||||
|
||||
if (!is_dir($path)) {
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +155,7 @@ class ComponentRegistry
|
|||
if ($categoryCompare !== 0) {
|
||||
return $categoryCompare;
|
||||
}
|
||||
|
||||
return strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +169,7 @@ class ComponentRegistry
|
|||
{
|
||||
$components = [];
|
||||
|
||||
if (!is_dir($path)) {
|
||||
if (! is_dir($path)) {
|
||||
return $components;
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +184,7 @@ class ComponentRegistry
|
|||
|
||||
if ($this->isValidComponent($className)) {
|
||||
$config = $this->getComponentConfig($className);
|
||||
if (!isset($config['error'])) {
|
||||
if (! isset($config['error'])) {
|
||||
$components[$className] = $config;
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +202,7 @@ class ComponentRegistry
|
|||
if (str_starts_with($namespace, 'App\\')) {
|
||||
$path = str_replace('App\\', 'app/', $namespace);
|
||||
$path = str_replace('\\', '/', $path);
|
||||
|
||||
return base_path($path);
|
||||
}
|
||||
|
||||
|
|
@ -204,12 +211,14 @@ class ComponentRegistry
|
|||
$path = str_replace('FluxCms\\', 'packages/flux-cms/', $namespace);
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$path = strtolower($path);
|
||||
return base_path($path . '/src');
|
||||
|
||||
return base_path($path.'/src');
|
||||
}
|
||||
|
||||
// Fallback
|
||||
$path = str_replace('\\', '/', $namespace);
|
||||
return base_path('vendor/' . strtolower($path));
|
||||
|
||||
return base_path('vendor/'.strtolower($path));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -217,11 +226,11 @@ class ComponentRegistry
|
|||
*/
|
||||
protected function fileToClassName(\SplFileInfo $file, string $namespace, string $basePath): string
|
||||
{
|
||||
$relativePath = str_replace($basePath . '/', '', $file->getPathname());
|
||||
$relativePath = str_replace($basePath.'/', '', $file->getPathname());
|
||||
$relativePath = str_replace('.php', '', $relativePath);
|
||||
$relativePath = str_replace('/', '\\', $relativePath);
|
||||
|
||||
return $namespace . '\\' . $relativePath;
|
||||
return $namespace.'\\'.$relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -232,6 +241,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsName')) {
|
||||
return $className::getCmsName();
|
||||
}
|
||||
|
||||
return $this->classNameToReadable($className);
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +250,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsCategory')) {
|
||||
return $className::getCmsCategory();
|
||||
}
|
||||
|
||||
return 'General';
|
||||
}
|
||||
|
||||
|
|
@ -248,6 +259,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsDescription')) {
|
||||
return $className::getCmsDescription();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +268,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsPreview')) {
|
||||
return $className::getCmsPreview();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +277,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsIcon')) {
|
||||
return $className::getCmsIcon();
|
||||
}
|
||||
|
||||
return 'puzzle-piece';
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +286,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsTags')) {
|
||||
return $className::getCmsTags();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +295,7 @@ class ComponentRegistry
|
|||
if (method_exists($className, 'getCmsVersion')) {
|
||||
return $className::getCmsVersion();
|
||||
}
|
||||
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +312,7 @@ class ComponentRegistry
|
|||
} else {
|
||||
\Log::warning('Invalid field type in component fields', [
|
||||
'field' => $field,
|
||||
'type' => gettype($field)
|
||||
'type' => gettype($field),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +326,7 @@ class ComponentRegistry
|
|||
protected function classNameToReadable(string $className): string
|
||||
{
|
||||
$baseName = class_basename($className);
|
||||
|
||||
return preg_replace('/([a-z])([A-Z])/', '$1 $2', $baseName);
|
||||
}
|
||||
|
||||
|
|
@ -318,14 +335,14 @@ class ComponentRegistry
|
|||
*/
|
||||
public function validateComponentContent(string $className, array $content): array
|
||||
{
|
||||
if (!$this->isValidComponent($className)) {
|
||||
return ['component' => ['Invalid component class: ' . $className]];
|
||||
if (! $this->isValidComponent($className)) {
|
||||
return ['component' => ['Invalid component class: '.$className]];
|
||||
}
|
||||
|
||||
// Custom Validation der Komponente
|
||||
if (method_exists($className, 'validateContent')) {
|
||||
$customErrors = $className::validateContent($content);
|
||||
if (!empty($customErrors)) {
|
||||
if (! empty($customErrors)) {
|
||||
return $customErrors;
|
||||
}
|
||||
}
|
||||
|
|
@ -344,7 +361,7 @@ class ComponentRegistry
|
|||
$availableLocales = config('flux-cms.locales', ['de', 'en']);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!$field instanceof BaseField) {
|
||||
if (! $field instanceof BaseField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -353,14 +370,14 @@ class ComponentRegistry
|
|||
foreach ($availableLocales as $locale) {
|
||||
$value = $content[$field->getKey()][$locale] ?? null;
|
||||
$fieldErrors = $field->validate($value, $locale);
|
||||
if (!empty($fieldErrors)) {
|
||||
if (! empty($fieldErrors)) {
|
||||
$errors["{$field->getKey()}.{$locale}"] = $fieldErrors;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$value = $content[$field->getKey()] ?? null;
|
||||
$fieldErrors = $field->validate($value);
|
||||
if (!empty($fieldErrors)) {
|
||||
if (! empty($fieldErrors)) {
|
||||
$errors[$field->getKey()] = $fieldErrors;
|
||||
}
|
||||
}
|
||||
|
|
@ -380,6 +397,7 @@ class ComponentRegistry
|
|||
public function refreshCache(): array
|
||||
{
|
||||
$this->clearCache();
|
||||
|
||||
return $this->getAvailableComponents();
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +406,7 @@ class ComponentRegistry
|
|||
*/
|
||||
public function addComponentPath(string $namespace): void
|
||||
{
|
||||
if (!in_array($namespace, $this->componentPaths)) {
|
||||
if (! in_array($namespace, $this->componentPaths)) {
|
||||
$this->componentPaths[] = $namespace;
|
||||
$this->clearCache();
|
||||
}
|
||||
|
|
@ -396,7 +414,7 @@ class ComponentRegistry
|
|||
|
||||
public function removeComponentPath(string $namespace): void
|
||||
{
|
||||
$this->componentPaths = array_filter($this->componentPaths, fn($path) => $path !== $namespace);
|
||||
$this->componentPaths = array_filter($this->componentPaths, fn ($path) => $path !== $namespace);
|
||||
$this->clearCache();
|
||||
}
|
||||
|
||||
|
|
@ -434,7 +452,7 @@ class ComponentRegistry
|
|||
strtolower($component['name']),
|
||||
strtolower($component['category']),
|
||||
strtolower($component['description'] ?? ''),
|
||||
strtolower(implode(' ', $component['tags'] ?? []))
|
||||
strtolower(implode(' ', $component['tags'] ?? [])),
|
||||
];
|
||||
|
||||
foreach ($searchFields as $field) {
|
||||
|
|
@ -467,4 +485,4 @@ class ComponentRegistry
|
|||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
packages/flux-cms/core/src/Services/HeroiconOutlineList.php
Normal file
70
packages/flux-cms/core/src/Services/HeroiconOutlineList.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Liefert die Namen der Outline-Heroicons (Präfix o- im Paket) für dynamische Auswahl.
|
||||
* Ergebnis wird über den Flux-CMS-Cache gebündelt, damit das SVG-Verzeichnis nicht bei jedem Request gescannt wird.
|
||||
*/
|
||||
final class HeroiconOutlineList
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function names(): array
|
||||
{
|
||||
if (! config('flux-cms.cache.enabled', true)) {
|
||||
return self::scanFilesystem();
|
||||
}
|
||||
|
||||
$prefix = config('flux-cms.cache.key_prefix', 'flux_cms');
|
||||
$ttl = (int) config('flux-cms.cache.ttl', 3600);
|
||||
$key = "{$prefix}.heroicon_outline_names";
|
||||
$store = config('flux-cms.cache.store');
|
||||
|
||||
$callback = fn (): array => self::scanFilesystem();
|
||||
|
||||
if ($store) {
|
||||
return Cache::store($store)->remember($key, $ttl, $callback);
|
||||
}
|
||||
|
||||
return Cache::remember($key, $ttl, $callback);
|
||||
}
|
||||
|
||||
public static function forgetCached(): void
|
||||
{
|
||||
$prefix = config('flux-cms.cache.key_prefix', 'flux_cms');
|
||||
$key = "{$prefix}.heroicon_outline_names";
|
||||
$store = config('flux-cms.cache.store');
|
||||
|
||||
if ($store) {
|
||||
Cache::store($store)->forget($key);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function scanFilesystem(): array
|
||||
{
|
||||
$path = base_path('vendor/blade-ui-kit/blade-heroicons/resources/svg');
|
||||
if (! File::isDirectory($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(File::files($path))
|
||||
->map(fn (\SplFileInfo $file): string => $file->getFilenameWithoutExtension())
|
||||
->filter(fn (string $name): bool => str_starts_with($name, 'o-'))
|
||||
->map(fn (string $name): string => substr($name, 2))
|
||||
->sort()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
224
packages/flux-cms/core/src/Services/MediaConversionService.php
Normal file
224
packages/flux-cms/core/src/Services/MediaConversionService.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Core\Services;
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class MediaConversionService
|
||||
{
|
||||
protected ImageManager $manager;
|
||||
|
||||
protected string $disk;
|
||||
|
||||
protected string $conversionsPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->manager = new ImageManager(new GdDriver);
|
||||
$this->disk = config('flux-cms.media.disk', 'public');
|
||||
$this->conversionsPath = config('flux-cms.media.conversions_path', 'cms/media/conversions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a specific conversion for a media item.
|
||||
*
|
||||
* @return string|null The path to the conversion file, or null on failure.
|
||||
*/
|
||||
public function convert(CmsMedia $media, string $profile): ?string
|
||||
{
|
||||
if (! $media->isImage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$profileConfig = config("flux-cms.media.profiles.{$profile}");
|
||||
if (! $profileConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalPath = Storage::disk($this->disk)->path($media->path);
|
||||
if (! file_exists($originalPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = $this->isSvg($media) ? 'svg' : ($profileConfig['format'] ?? 'webp');
|
||||
|
||||
if ($this->isSvg($media)) {
|
||||
return $media->path;
|
||||
}
|
||||
|
||||
$conversionFilename = pathinfo($media->filename, PATHINFO_FILENAME)
|
||||
.'-'.$profile
|
||||
.'.'.$extension;
|
||||
|
||||
$conversionPath = $this->conversionsPath.'/'.$profile.'/'.$conversionFilename;
|
||||
$absolutePath = Storage::disk($this->disk)->path($conversionPath);
|
||||
|
||||
$dir = dirname($absolutePath);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$image = $this->manager->read($originalPath);
|
||||
|
||||
$width = $profileConfig['width'];
|
||||
$height = $profileConfig['height'];
|
||||
$fit = $profileConfig['fit'] ?? 'cover';
|
||||
$quality = $profileConfig['quality'] ?? 85;
|
||||
|
||||
if ($fit === 'cover') {
|
||||
$image->cover($width, $height);
|
||||
} else {
|
||||
$image->scaleDown($width, $height);
|
||||
}
|
||||
|
||||
$encoded = match ($extension) {
|
||||
'webp' => $image->toWebp($quality),
|
||||
'png' => $image->toPng(),
|
||||
'gif' => $image->toGif(),
|
||||
default => $image->toJpeg($quality),
|
||||
};
|
||||
|
||||
$encoded->save($absolutePath);
|
||||
|
||||
$conversions = $media->conversions ?? [];
|
||||
$conversions[$profile] = $conversionPath;
|
||||
$media->update(['conversions' => $conversions]);
|
||||
|
||||
return $conversionPath;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the thumbnail conversion used in the admin grid.
|
||||
*/
|
||||
public function generateThumbnail(CmsMedia $media): ?string
|
||||
{
|
||||
return $this->convert($media, 'thumb');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all configured profile conversions.
|
||||
*
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
public function generateAllConversions(CmsMedia $media): array
|
||||
{
|
||||
$profiles = array_keys(config('flux-cms.media.profiles', []));
|
||||
$results = [];
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$results[$profile] = $this->convert($media, $profile);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all conversion files for a media item.
|
||||
*/
|
||||
public function deleteConversions(CmsMedia $media): void
|
||||
{
|
||||
$conversions = $media->conversions ?? [];
|
||||
|
||||
foreach ($conversions as $path) {
|
||||
if (Storage::disk($this->disk)->exists($path)) {
|
||||
Storage::disk($this->disk)->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
$media->update(['conversions' => []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the original file and all conversions.
|
||||
*/
|
||||
public function deleteAll(CmsMedia $media): void
|
||||
{
|
||||
$this->deleteConversions($media);
|
||||
|
||||
if (Storage::disk($this->disk)->exists($media->path)) {
|
||||
Storage::disk($this->disk)->delete($media->path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an uploaded file and create the CmsMedia record.
|
||||
*/
|
||||
public function storeUpload(
|
||||
\Illuminate\Http\UploadedFile $file,
|
||||
?string $collection = null,
|
||||
bool $generateThumbnail = true,
|
||||
): CmsMedia {
|
||||
$originalsPath = config('flux-cms.media.originals_path', 'cms/media/originals');
|
||||
$path = $file->store($originalsPath, $this->disk);
|
||||
|
||||
$type = $this->detectType($file);
|
||||
$width = null;
|
||||
$height = null;
|
||||
|
||||
if ($type === 'image' && ! $this->isSvgFile($file)) {
|
||||
try {
|
||||
$dimensions = getimagesize($file->getRealPath());
|
||||
if ($dimensions) {
|
||||
$width = $dimensions[0];
|
||||
$height = $dimensions[1];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
$media = CmsMedia::create([
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'disk' => $this->disk,
|
||||
'path' => $path,
|
||||
'type' => $type,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'original_width' => $width,
|
||||
'original_height' => $height,
|
||||
'collection' => $collection,
|
||||
'conversions' => [],
|
||||
]);
|
||||
|
||||
if ($generateThumbnail && $media->isImage() && ! $this->isSvg($media)) {
|
||||
$this->generateThumbnail($media);
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
protected function detectType(\Illuminate\Http\UploadedFile $file): string
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if ($mime === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
return 'document';
|
||||
}
|
||||
|
||||
protected function isSvg(CmsMedia $media): bool
|
||||
{
|
||||
return $media->mime_type === 'image/svg+xml'
|
||||
|| str_ends_with(strtolower($media->filename), '.svg');
|
||||
}
|
||||
|
||||
protected function isSvgFile(\Illuminate\Http\UploadedFile $file): bool
|
||||
{
|
||||
return $file->getMimeType() === 'image/svg+xml'
|
||||
|| str_ends_with(strtolower($file->getClientOriginalName()), '.svg');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('redirects unauthenticated users from cms dashboard', function () {
|
||||
$this->get(route('cms.dashboard'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('allows authenticated users to access cms dashboard', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.dashboard'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access content index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.content.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access news index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.news.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access faq index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.faqs.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access industries index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.industries.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access linkedin index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.linkedin.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('allows authenticated users to access downloads index', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.downloads.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
beforeEach(function () {
|
||||
CmsContent::create([
|
||||
'group' => 'welcome',
|
||||
'key' => 'hero.heading',
|
||||
'type' => 'text',
|
||||
'value' => ['de' => 'Willkommen', 'en' => 'Welcome'],
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
CmsContent::create([
|
||||
'group' => 'welcome',
|
||||
'key' => 'hero.description',
|
||||
'type' => 'html',
|
||||
'value' => ['de' => '<p>Beschreibung</p>', 'en' => '<p>Description</p>'],
|
||||
'order' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves content by dot-notation key', function () {
|
||||
app()->setLocale('de');
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
expect($service->get('welcome.hero.heading'))->toBe('Willkommen');
|
||||
});
|
||||
|
||||
it('resolves content in english locale', function () {
|
||||
app()->setLocale('en');
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
expect($service->get('welcome.hero.heading'))->toBe('Welcome');
|
||||
});
|
||||
|
||||
it('falls back to lang file when no db entry', function () {
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
$result = $service->get('nonexistent.key.here');
|
||||
|
||||
expect($result)->toBe('nonexistent.key.here');
|
||||
});
|
||||
|
||||
it('returns html content correctly', function () {
|
||||
app()->setLocale('de');
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
expect($service->get('welcome.hero.description'))->toBe('<p>Beschreibung</p>');
|
||||
});
|
||||
|
||||
it('replaces placeholders in content', function () {
|
||||
CmsContent::create([
|
||||
'group' => 'test',
|
||||
'key' => 'greeting',
|
||||
'type' => 'text',
|
||||
'value' => ['de' => 'Hallo :name!'],
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
app()->setLocale('de');
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
expect($service->get('test.greeting', ['name' => 'Welt']))->toBe('Hallo Welt!');
|
||||
});
|
||||
|
||||
it('gets all content for a group', function () {
|
||||
app()->setLocale('de');
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
$group = $service->getGroup('welcome');
|
||||
|
||||
expect($group)->toHaveKey('hero.heading', 'Willkommen')
|
||||
->toHaveKey('hero.description', '<p>Beschreibung</p>');
|
||||
});
|
||||
|
||||
it('clears cache for a group', function () {
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
$service->get('welcome.hero.heading');
|
||||
|
||||
$service->clearCache('welcome');
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('cms helper function works', function () {
|
||||
app()->setLocale('en');
|
||||
|
||||
expect(cms('welcome.hero.heading'))->toBe('Welcome');
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
test('CmsMedia model can be created', function () {
|
||||
$media = CmsMedia::create([
|
||||
'filename' => 'test-image.jpg',
|
||||
'disk' => 'public',
|
||||
'path' => 'cms/media/originals/test-image.jpg',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'file_size' => 102400,
|
||||
'original_width' => 1920,
|
||||
'original_height' => 1080,
|
||||
]);
|
||||
|
||||
expect($media)->toBeInstanceOf(CmsMedia::class)
|
||||
->and($media->filename)->toBe('test-image.jpg')
|
||||
->and($media->isImage())->toBeTrue()
|
||||
->and($media->isPdf())->toBeFalse()
|
||||
->and($media->getHumanFileSize())->toBe('100 KB')
|
||||
->and($media->getDimensionsLabel())->toBe('1920 × 1080');
|
||||
});
|
||||
|
||||
test('CmsMedia scopes filter correctly', function () {
|
||||
CmsMedia::create(['filename' => 'photo.jpg', 'disk' => 'public', 'path' => 'a.jpg', 'type' => 'image', 'collection' => 'hero']);
|
||||
CmsMedia::create(['filename' => 'doc.pdf', 'disk' => 'public', 'path' => 'b.pdf', 'type' => 'pdf', 'collection' => 'downloads']);
|
||||
CmsMedia::create(['filename' => 'draft.jpg', 'disk' => 'public', 'path' => 'c.jpg', 'type' => 'image', 'is_published' => false]);
|
||||
|
||||
expect(CmsMedia::images()->count())->toBe(2)
|
||||
->and(CmsMedia::pdfs()->count())->toBe(1)
|
||||
->and(CmsMedia::published()->count())->toBe(2)
|
||||
->and(CmsMedia::inCollection('hero')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('CmsMedia stores translatable alt_text and title', function () {
|
||||
$media = CmsMedia::create([
|
||||
'filename' => 'hero.jpg',
|
||||
'disk' => 'public',
|
||||
'path' => 'cms/media/originals/hero.jpg',
|
||||
'type' => 'image',
|
||||
]);
|
||||
|
||||
$media->setTranslation('alt_text', 'de', 'Heldenbild');
|
||||
$media->setTranslation('alt_text', 'en', 'Hero image');
|
||||
$media->setTranslation('title', 'de', 'Startseite Hero');
|
||||
$media->save();
|
||||
|
||||
$media->refresh();
|
||||
|
||||
expect($media->getTranslation('alt_text', 'de'))->toBe('Heldenbild')
|
||||
->and($media->getTranslation('alt_text', 'en'))->toBe('Hero image')
|
||||
->and($media->getTranslation('title', 'de'))->toBe('Startseite Hero');
|
||||
});
|
||||
|
||||
test('CmsMedia conversions tracking works', function () {
|
||||
$media = CmsMedia::create([
|
||||
'filename' => 'test.jpg',
|
||||
'disk' => 'public',
|
||||
'path' => 'cms/media/originals/test.jpg',
|
||||
'type' => 'image',
|
||||
'conversions' => [],
|
||||
]);
|
||||
|
||||
expect($media->hasConversion('hero'))->toBeFalse()
|
||||
->and($media->getExistingConversions())->toBe([]);
|
||||
|
||||
$media->update(['conversions' => ['hero' => 'cms/media/conversions/hero/test-hero.webp']]);
|
||||
|
||||
Storage::disk('public')->put('cms/media/conversions/hero/test-hero.webp', 'fake');
|
||||
|
||||
$media->refresh();
|
||||
|
||||
expect($media->hasConversion('hero'))->toBeTrue()
|
||||
->and($media->getExistingConversions())->toHaveKey('hero');
|
||||
});
|
||||
|
||||
test('MediaConversionService storeUpload creates media record', function () {
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$media = $service->storeUpload($file, 'test');
|
||||
|
||||
expect($media)->toBeInstanceOf(CmsMedia::class)
|
||||
->and($media->filename)->toBe('photo.jpg')
|
||||
->and($media->type)->toBe('image')
|
||||
->and($media->collection)->toBe('test')
|
||||
->and($media->original_width)->toBe(800)
|
||||
->and($media->original_height)->toBe(600)
|
||||
->and(Storage::disk('public')->exists($media->path))->toBeTrue();
|
||||
});
|
||||
|
||||
test('MediaConversionService generates thumbnail on upload', function () {
|
||||
$file = UploadedFile::fake()->image('big-photo.jpg', 1920, 1080);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$media = $service->storeUpload($file);
|
||||
|
||||
$media->refresh();
|
||||
|
||||
expect($media->hasConversion('thumb'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('MediaConversionService can generate specific profile conversion', function () {
|
||||
$file = UploadedFile::fake()->image('hero-bg.jpg', 2400, 1600);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$media = $service->storeUpload($file, generateThumbnail: false);
|
||||
|
||||
$result = $service->convert($media, 'hero');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
|
||||
$media->refresh();
|
||||
expect($media->hasConversion('hero'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('MediaConversionService deleteAll removes files', function () {
|
||||
$file = UploadedFile::fake()->image('delete-me.jpg', 400, 400);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$media = $service->storeUpload($file);
|
||||
|
||||
$path = $media->path;
|
||||
|
||||
expect(Storage::disk('public')->exists($path))->toBeTrue();
|
||||
|
||||
$service->deleteAll($media);
|
||||
|
||||
expect(Storage::disk('public')->exists($path))->toBeFalse();
|
||||
});
|
||||
|
||||
test('MediaConversionService skips SVG files for conversions', function () {
|
||||
$file = UploadedFile::fake()->create('logo.svg', 5, 'image/svg+xml');
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$media = $service->storeUpload($file, generateThumbnail: false);
|
||||
|
||||
expect($media->type)->toBe('image')
|
||||
->and($media->mime_type)->toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
test('media profiles are configured', function () {
|
||||
$profiles = config('flux-cms.media.profiles');
|
||||
|
||||
expect($profiles)->toHaveKey('hero')
|
||||
->and($profiles)->toHaveKey('thumb')
|
||||
->and($profiles)->toHaveKey('avatar')
|
||||
->and($profiles)->toHaveKey('news')
|
||||
->and($profiles)->toHaveKey('thumbnail')
|
||||
->and($profiles)->toHaveKey('service')
|
||||
->and($profiles)->toHaveKey('og_image')
|
||||
->and($profiles['hero']['width'])->toBe(1920)
|
||||
->and($profiles['hero']['height'])->toBe(800)
|
||||
->and($profiles['avatar']['width'])->toBe(400);
|
||||
});
|
||||
|
||||
test('media library admin page loads', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('cms.media.index'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
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;
|
||||
|
||||
it('creates a cms faq with translations', function () {
|
||||
$faq = CmsFaq::create([
|
||||
'category' => 'general',
|
||||
'question' => ['de' => 'Was ist das?', 'en' => 'What is this?'],
|
||||
'answer' => ['de' => 'Eine Antwort', 'en' => 'An answer'],
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
expect($faq->getTranslation('question', 'de'))->toBe('Was ist das?')
|
||||
->and($faq->getTranslation('question', 'en'))->toBe('What is this?');
|
||||
});
|
||||
|
||||
it('scopes faqs by category', function () {
|
||||
CmsFaq::create([
|
||||
'category' => 'general',
|
||||
'question' => ['de' => 'Frage 1'],
|
||||
'answer' => ['de' => 'Antwort 1'],
|
||||
'order' => 0,
|
||||
]);
|
||||
CmsFaq::create([
|
||||
'category' => 'technical',
|
||||
'question' => ['de' => 'Frage 2'],
|
||||
'answer' => ['de' => 'Antwort 2'],
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
expect(CmsFaq::byCategory('general')->count())->toBe(1)
|
||||
->and(CmsFaq::byCategory('technical')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('scopes published items', function () {
|
||||
CmsFaq::create([
|
||||
'category' => 'test',
|
||||
'question' => ['de' => 'Sichtbar'],
|
||||
'answer' => ['de' => 'Ja'],
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
CmsFaq::create([
|
||||
'category' => 'test',
|
||||
'question' => ['de' => 'Versteckt'],
|
||||
'answer' => ['de' => 'Nein'],
|
||||
'is_published' => false,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
expect(CmsFaq::published()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('creates a news item with all fields', function () {
|
||||
$item = CmsNewsItem::create([
|
||||
'icon' => 'document-check',
|
||||
'text' => ['de' => 'Capability: Test', 'en' => 'Capability: Test'],
|
||||
'title' => ['de' => 'Titel DE', 'en' => 'Title EN'],
|
||||
'excerpt' => ['de' => 'Kurztext', 'en' => 'Short text'],
|
||||
'content' => ['de' => '<p>Inhalt</p>', 'en' => '<p>Content</p>'],
|
||||
'image' => '/images/test.jpg',
|
||||
'date' => '2026-01-01',
|
||||
'author' => 'Test Author',
|
||||
'pdf_path' => '/pdfs/test.pdf',
|
||||
'pdf_open_text' => ['de' => 'Öffnen', 'en' => 'Open'],
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
expect($item->toFrontendArray('de'))
|
||||
->toHaveKey('icon', 'document-check')
|
||||
->toHaveKey('title', 'Titel DE')
|
||||
->toHaveKey('pdf_path', '/pdfs/test.pdf');
|
||||
});
|
||||
|
||||
it('creates and queries industries', function () {
|
||||
CmsIndustry::create(['name' => ['de' => 'FMCG', 'en' => 'FMCG'], 'order' => 0, 'is_published' => true]);
|
||||
CmsIndustry::create(['name' => ['de' => 'Beauty', 'en' => 'Beauty'], 'order' => 1, 'is_published' => true]);
|
||||
CmsIndustry::create(['name' => ['de' => 'Entwurf', 'en' => 'Draft'], 'order' => 2, 'is_published' => false]);
|
||||
|
||||
expect(CmsIndustry::published()->count())->toBe(2)
|
||||
->and(CmsIndustry::ordered()->first()->getTranslation('name', 'de'))->toBe('FMCG');
|
||||
});
|
||||
|
||||
it('creates a download with category', function () {
|
||||
$dl = CmsDownload::create([
|
||||
'title' => ['de' => 'Case Study', 'en' => 'Case Study'],
|
||||
'description' => ['de' => 'Beschreibung', 'en' => 'Description'],
|
||||
'category' => 'case_study',
|
||||
'file_path' => '/pdfs/case-study.pdf',
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
expect(CmsDownload::byCategory('case_study')->count())->toBe(1)
|
||||
->and($dl->getTranslation('title', 'de'))->toBe('Case Study');
|
||||
});
|
||||
|
||||
it('creates a linkedin post with source flag', function () {
|
||||
CmsLinkedinPost::create([
|
||||
'title' => ['de' => 'Post Titel'],
|
||||
'content' => ['de' => '<p>Inhalt</p>'],
|
||||
'author' => 'Test',
|
||||
'date' => '2026-01-01',
|
||||
'source' => 'manual',
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
CmsLinkedinPost::create([
|
||||
'title' => ['de' => 'API Post'],
|
||||
'content' => ['de' => '<p>API</p>'],
|
||||
'linkedin_id' => 'ext-123',
|
||||
'source' => 'api',
|
||||
'is_published' => true,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
expect(CmsLinkedinPost::manual()->count())->toBe(1)
|
||||
->and(CmsLinkedinPost::fromApi()->count())->toBe(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use Database\Seeders\CmsContentSeeder;
|
||||
use Database\Seeders\CmsFaqSeeder;
|
||||
use Database\Seeders\CmsIndustrySeeder;
|
||||
use Database\Seeders\CmsLinkedinPostSeeder;
|
||||
use Database\Seeders\CmsNewsItemSeeder;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
|
||||
it('seeds cms content from lang files', function () {
|
||||
$this->seed(CmsContentSeeder::class);
|
||||
|
||||
expect(CmsContent::count())->toBeGreaterThan(0);
|
||||
|
||||
$welcomeContent = CmsContent::forGroup('welcome')->get();
|
||||
expect($welcomeContent->count())->toBeGreaterThan(0);
|
||||
|
||||
$heroHeading = CmsContent::where('group', 'welcome')
|
||||
->where('key', 'hero.heading_main')
|
||||
->first();
|
||||
expect($heroHeading)->not->toBeNull()
|
||||
->and($heroHeading->getTranslation('value', 'de'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('seeds news items from lang files', function () {
|
||||
$this->seed(CmsNewsItemSeeder::class);
|
||||
|
||||
expect(CmsNewsItem::count())->toBeGreaterThan(0);
|
||||
|
||||
$first = CmsNewsItem::ordered()->first();
|
||||
expect($first->getTranslation('title', 'de'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('seeds industries from lang files', function () {
|
||||
$this->seed(CmsIndustrySeeder::class);
|
||||
|
||||
expect(CmsIndustry::count())->toBeGreaterThan(0);
|
||||
|
||||
$first = CmsIndustry::ordered()->first();
|
||||
expect($first->getTranslation('name', 'de'))->toBe('FMCG');
|
||||
});
|
||||
|
||||
it('seeds faqs from lang files', function () {
|
||||
$this->seed(CmsFaqSeeder::class);
|
||||
|
||||
expect(CmsFaq::count())->toBeGreaterThan(0);
|
||||
|
||||
$categories = CmsFaq::distinct()->pluck('category');
|
||||
expect($categories)->toContain('general');
|
||||
});
|
||||
|
||||
it('seeds linkedin posts', function () {
|
||||
$this->seed(CmsLinkedinPostSeeder::class);
|
||||
|
||||
expect(CmsLinkedinPost::count())->toBe(3);
|
||||
|
||||
$first = CmsLinkedinPost::ordered()->first();
|
||||
expect($first->source)->toBe('manual')
|
||||
->and($first->getTranslation('title', 'de'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not create duplicates when run twice', function () {
|
||||
$this->seed(CmsIndustrySeeder::class);
|
||||
$countAfterFirst = CmsIndustry::count();
|
||||
|
||||
$this->seed(CmsIndustrySeeder::class);
|
||||
$countAfterSecond = CmsIndustry::count();
|
||||
|
||||
expect($countAfterSecond)->toBe($countAfterFirst);
|
||||
});
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace FluxCms\Core\Tests;
|
||||
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||
use Orchestra\Testbench\Concerns\CreatesApplication;
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
|
||||
abstract class DuskTestCase extends BaseTestCase
|
||||
{
|
||||
|
|
@ -16,6 +16,7 @@ abstract class DuskTestCase extends BaseTestCase
|
|||
* Prepare for Dusk test execution.
|
||||
*
|
||||
* @beforeClass
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function prepare()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
namespace FluxCms\Core\Tests\Feature;
|
||||
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class PageManagementTest extends TestCase
|
||||
{
|
||||
|
|
@ -50,7 +49,7 @@ class PageManagementTest extends TestCase
|
|||
'order' => 1,
|
||||
'content' => [
|
||||
'title' => ['de' => 'Komponenten Titel'],
|
||||
'text' => ['de' => 'Komponenten Text']
|
||||
'text' => ['de' => 'Komponenten Text'],
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
|
@ -194,4 +193,4 @@ class PageManagementTest extends TestCase
|
|||
'en' => 'English',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace FluxCms\Core\Tests\Unit\Admin;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Core\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace FluxCms\Core\Tests\Unit\Admin;
|
||||
|
||||
use FluxCms\Core\Models\User;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace FluxCms\Core\Tests\Unit;
|
||||
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\FieldTypes\MediaField;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use FluxCms\Core\FieldTypes\TextField;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Livewire\Component;
|
||||
use Mockery;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
class ComponentRegistryTest extends TestCase
|
||||
{
|
||||
|
|
@ -16,7 +16,7 @@ class ComponentRegistryTest extends TestCase
|
|||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->registry = new ComponentRegistry();
|
||||
$this->registry = new ComponentRegistry;
|
||||
}
|
||||
|
||||
public function test_can_detect_valid_component()
|
||||
|
|
@ -45,7 +45,7 @@ class ComponentRegistryTest extends TestCase
|
|||
// Valid content
|
||||
$validContent = [
|
||||
'title' => ['de' => 'Test Titel', 'en' => 'Test Title'],
|
||||
'image' => 123
|
||||
'image' => 123,
|
||||
];
|
||||
|
||||
$errors = $this->registry->validateComponentContent($componentClass, $validContent);
|
||||
|
|
@ -53,7 +53,7 @@ class ComponentRegistryTest extends TestCase
|
|||
|
||||
// Invalid content (missing required field)
|
||||
$invalidContent = [
|
||||
'image' => 123
|
||||
'image' => 123,
|
||||
];
|
||||
|
||||
$errors = $this->registry->validateComponentContent($componentClass, $invalidContent);
|
||||
|
|
@ -68,8 +68,8 @@ class ComponentRegistryTest extends TestCase
|
|||
'name' => 'Test Component',
|
||||
'category' => 'Testing',
|
||||
'description' => 'A test component',
|
||||
'tags' => ['test', 'example']
|
||||
]
|
||||
'tags' => ['test', 'example'],
|
||||
],
|
||||
];
|
||||
|
||||
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
|
||||
|
|
@ -91,7 +91,7 @@ class ComponentRegistryTest extends TestCase
|
|||
AnotherTestComponent::class => [
|
||||
'name' => 'Another Component',
|
||||
'category' => 'Layout',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
|
||||
|
|
@ -162,4 +162,4 @@ class AnotherTestComponent extends Component
|
|||
{
|
||||
return '<div>Another Component</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace FluxCms\Core\Tests\Unit\Models;
|
|||
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use FluxCms\Core\Models\User;
|
||||
use Spatie\Tags\Tag;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ class BlogPostTest extends TestCase
|
|||
$user = User::factory()->create();
|
||||
$post = BlogPost::factory()->create([
|
||||
'author_id' => $user->id,
|
||||
'author_type' => User::class
|
||||
'author_type' => User::class,
|
||||
]);
|
||||
$this->assertInstanceOf(User::class, $post->author);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class PageTest extends TestCase
|
|||
$page = Page::factory()->create();
|
||||
Slug::factory()->create([
|
||||
'model_id' => $page->id,
|
||||
'model_type' => Page::class
|
||||
'model_type' => Page::class,
|
||||
]);
|
||||
$this->assertInstanceOf(Slug::class, $page->slugs->first());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue