First commit
This commit is contained in:
commit
7cf3558ba7
12933 changed files with 1180047 additions and 0 deletions
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Str;
|
||||
use ReflectionClass;
|
||||
|
||||
class FluxCmsComponentsServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Merge config if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->bootViews();
|
||||
$this->bootPublishing();
|
||||
$this->bootLivewireComponents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot views
|
||||
*/
|
||||
protected function bootViews(): void
|
||||
{
|
||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms-components');
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot publishing
|
||||
*/
|
||||
protected function bootPublishing(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
// Publish views
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-views');
|
||||
|
||||
// Publish assets
|
||||
$this->publishes([
|
||||
__DIR__ . '/../resources/assets' => public_path('vendor/flux-cms-components'),
|
||||
], 'flux-cms-components-assets');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Livewire components
|
||||
*/
|
||||
protected function bootLivewireComponents(): void
|
||||
{
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Backend', 'FluxCms\\Components\\Livewire\\Backend', 'flux-cms::');
|
||||
$this->registerLivewireComponentsFrom(__DIR__ . '/Livewire/Frontend', 'FluxCms\\Components\\Livewire\\Frontend', 'flux-cms::');
|
||||
}
|
||||
|
||||
protected function registerLivewireComponentsFrom(string $path, string $namespace, string $aliasPrefix = ''): void
|
||||
{
|
||||
$filesystem = new Filesystem();
|
||||
if (!$filesystem->isDirectory($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($filesystem->allFiles($path) as $file) {
|
||||
$class = $namespace . '\\' . str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, \Livewire\Component::class) && !(new ReflectionClass($class))->isAbstract()) {
|
||||
$alias = $aliasPrefix . Str::kebab(class_basename($class));
|
||||
Livewire::component($alias, $class);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class BlogEditor extends Component
|
||||
{
|
||||
public BlogPost $post;
|
||||
public string $tags = '';
|
||||
|
||||
public function mount(BlogPost $post)
|
||||
{
|
||||
$this->post = $post;
|
||||
$this->tags = implode(', ', $this->post->tags->pluck('name')->toArray());
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->post->save();
|
||||
|
||||
$tags = array_filter(array_map('trim', explode(',', $this->tags)));
|
||||
$this->post->syncTags($tags);
|
||||
|
||||
$this->dispatch('saved');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.backend.blog-editor');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public string $search = '';
|
||||
public string $filterStatus = 'all';
|
||||
public bool $showCreateModal = false;
|
||||
public ?BlogPost $editingPost = null;
|
||||
|
||||
// Form data
|
||||
public array $postData = [];
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function mount(string $domainKey)
|
||||
{
|
||||
$this->domainKey = $domainKey;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$posts = $this->getFilteredPosts();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.blog-manager', [
|
||||
'posts' => $posts,
|
||||
])->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered blog posts
|
||||
*/
|
||||
protected function getFilteredPosts()
|
||||
{
|
||||
$query = BlogPost::forDomain($this->domainKey);
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('title->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('title->en', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->de', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content->en', 'like', '%' . $this->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($this->filterStatus === 'published') {
|
||||
$query->published();
|
||||
} elseif ($this->filterStatus === 'draft') {
|
||||
$query->where('is_published', false);
|
||||
} elseif ($this->filterStatus === 'featured') {
|
||||
$query->featured();
|
||||
}
|
||||
|
||||
return $query->orderBy('updated_at', 'desc')->paginate(15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search updated
|
||||
*/
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter updated
|
||||
*/
|
||||
public function updatedFilterStatus()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create modal
|
||||
*/
|
||||
public function showCreatePost()
|
||||
{
|
||||
$this->editingPost = null;
|
||||
$this->postData = [
|
||||
'title' => array_fill_keys($this->availableLanguages, ''),
|
||||
'slug' => array_fill_keys($this->availableLanguages, ''),
|
||||
'excerpt' => array_fill_keys($this->availableLanguages, ''),
|
||||
'content' => array_fill_keys($this->availableLanguages, ''),
|
||||
'meta_description' => array_fill_keys($this->availableLanguages, ''),
|
||||
'meta_keywords' => array_fill_keys($this->availableLanguages, ''),
|
||||
'is_published' => false,
|
||||
'is_featured' => false,
|
||||
'published_at' => null,
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit post
|
||||
*/
|
||||
public function editPost(int $postId)
|
||||
{
|
||||
$this->editingPost = BlogPost::findOrFail($postId);
|
||||
$this->postData = [
|
||||
'title' => $this->editingPost->getTranslations('title'),
|
||||
'slug' => $this->editingPost->getTranslations('slug'),
|
||||
'excerpt' => $this->editingPost->getTranslations('excerpt'),
|
||||
'content' => $this->editingPost->getTranslations('content'),
|
||||
'meta_description' => $this->editingPost->getTranslations('meta_description'),
|
||||
'meta_keywords' => $this->editingPost->getTranslations('meta_keywords'),
|
||||
'is_published' => $this->editingPost->is_published,
|
||||
'is_featured' => $this->editingPost->is_featured,
|
||||
'published_at' => $this->editingPost->published_at?->format('Y-m-d\TH:i'),
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save post
|
||||
*/
|
||||
public function savePost()
|
||||
{
|
||||
$this->validate([
|
||||
'postData.title' => 'required|array',
|
||||
'postData.slug' => 'required|array',
|
||||
'postData.content' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'domain_key' => $this->domainKey,
|
||||
'title' => $this->postData['title'],
|
||||
'slug' => $this->postData['slug'],
|
||||
'excerpt' => $this->postData['excerpt'],
|
||||
'content' => $this->postData['content'],
|
||||
'meta_description' => $this->postData['meta_description'],
|
||||
'meta_keywords' => $this->postData['meta_keywords'],
|
||||
'is_published' => $this->postData['is_published'] ?? false,
|
||||
'is_featured' => $this->postData['is_featured'] ?? false,
|
||||
'published_at' => $this->postData['published_at'] ?
|
||||
\Carbon\Carbon::parse($this->postData['published_at']) : null,
|
||||
];
|
||||
|
||||
if ($this->editingPost) {
|
||||
$this->editingPost->update($data);
|
||||
$message = 'Blog post updated successfully.';
|
||||
} else {
|
||||
$data['author_id'] = auth()->id();
|
||||
BlogPost::create($data);
|
||||
$message = 'Blog post created successfully.';
|
||||
}
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->resetPage();
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete post
|
||||
*/
|
||||
public function deletePost(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
$post->delete();
|
||||
session()->flash('success', 'Blog post deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle publish status
|
||||
*/
|
||||
public function togglePublish(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
if ($post->is_published) {
|
||||
$post->unpublish();
|
||||
$message = 'Blog post unpublished.';
|
||||
} else {
|
||||
$post->publish();
|
||||
$message = 'Blog post published.';
|
||||
}
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating publish status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle featured status
|
||||
*/
|
||||
public function toggleFeatured(int $postId)
|
||||
{
|
||||
try {
|
||||
$post = BlogPost::find($postId);
|
||||
if ($post) {
|
||||
$post->update(['is_featured' => !$post->is_featured]);
|
||||
$message = $post->is_featured ? 'Post marked as featured.' : 'Post removed from featured.';
|
||||
session()->flash('success', $message);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating featured status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate post
|
||||
*/
|
||||
public function duplicatePost(int $postId)
|
||||
{
|
||||
try {
|
||||
$original = BlogPost::find($postId);
|
||||
if ($original) {
|
||||
$duplicate = $original->replicate();
|
||||
$duplicate->is_published = false;
|
||||
$duplicate->published_at = null;
|
||||
|
||||
// Update title to indicate it's a copy
|
||||
$titles = $duplicate->getTranslations('title');
|
||||
foreach ($titles as $locale => $title) {
|
||||
$titles[$locale] = $title . ' (Copy)';
|
||||
}
|
||||
$duplicate->title = $titles;
|
||||
|
||||
// Update slugs to avoid conflicts
|
||||
$slugs = $duplicate->getTranslations('slug');
|
||||
foreach ($slugs as $locale => $slug) {
|
||||
$slugs[$locale] = $slug . '-copy-' . time();
|
||||
}
|
||||
$duplicate->slug = $slugs;
|
||||
|
||||
$duplicate->save();
|
||||
session()->flash('success', 'Blog post duplicated successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error duplicating blog post: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from title
|
||||
*/
|
||||
public function generateSlug(string $locale)
|
||||
{
|
||||
$title = $this->postData['title'][$locale] ?? '';
|
||||
if ($title) {
|
||||
$slug = \Illuminate\Support\Str::slug($title);
|
||||
$this->postData['slug'][$locale] = '/' . $slug;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->editingPost = null;
|
||||
$this->postData = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filters
|
||||
*/
|
||||
public function getAvailableFiltersProperty(): array
|
||||
{
|
||||
return [
|
||||
'all' => 'All Posts',
|
||||
'published' => 'Published',
|
||||
'draft' => 'Drafts',
|
||||
'featured' => 'Featured',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post stats
|
||||
*/
|
||||
public function getStatsProperty(): array
|
||||
{
|
||||
return [
|
||||
'total' => BlogPost::forDomain($this->domainKey)->count(),
|
||||
'published' => BlogPost::forDomain($this->domainKey)->published()->count(),
|
||||
'drafts' => BlogPost::forDomain($this->domainKey)->where('is_published', false)->count(),
|
||||
'featured' => BlogPost::forDomain($this->domainKey)->featured()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
|
||||
class ComponentEditor extends Component
|
||||
{
|
||||
public PageComponent $component;
|
||||
public array $content = [];
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $expanded = false;
|
||||
public array $validationErrors = [];
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
||||
public function boot(ComponentRegistry $componentRegistry)
|
||||
{
|
||||
$this->componentRegistry = $componentRegistry;
|
||||
}
|
||||
|
||||
public function mount(PageComponent $component, string $locale = 'de')
|
||||
{
|
||||
$this->component = $component;
|
||||
$this->currentLocale = $locale;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->content = $component->getTranslations('content');
|
||||
$this->expanded = false;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$config = $this->componentRegistry->getComponentConfig($this->component->component_class);
|
||||
|
||||
return view('flux-cms-components::livewire.backend.component-editor', [
|
||||
'config' => $config,
|
||||
'fields' => $config['fields'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle expanded state
|
||||
*/
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = !$this->expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content when changed
|
||||
*/
|
||||
public function updatedContent()
|
||||
{
|
||||
$this->validateContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate content
|
||||
*/
|
||||
public function validateContent()
|
||||
{
|
||||
$this->validationErrors = $this->componentRegistry->validateComponentContent(
|
||||
$this->component->component_class,
|
||||
$this->content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save component
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->validateContent();
|
||||
|
||||
if (!empty($this->validationErrors)) {
|
||||
session()->flash('error', 'Please correct validation errors.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Component saved successfully.');
|
||||
$this->dispatch('component-saved', componentId: $this->component->id);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto save
|
||||
*/
|
||||
public function autoSave()
|
||||
{
|
||||
if (empty($this->validationErrors)) {
|
||||
try {
|
||||
$this->component->update([
|
||||
'content' => $this->content
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Silent fail for auto-save
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open media manager
|
||||
*/
|
||||
public function selectMedia(string $fieldKey)
|
||||
{
|
||||
$this->dispatch('open-media-manager', [
|
||||
'componentId' => $this->component->id,
|
||||
'fieldKey' => $fieldKey,
|
||||
'locale' => $this->currentLocale,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle media selection
|
||||
*/
|
||||
public function mediaSelected(int $mediaId, string $fieldKey, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = $mediaId;
|
||||
} else {
|
||||
$this->content[$fieldKey] = $mediaId;
|
||||
}
|
||||
|
||||
$this->autoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove media
|
||||
*/
|
||||
public function removeMedia(string $fieldKey, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = null;
|
||||
} else {
|
||||
$this->content[$fieldKey] = null;
|
||||
}
|
||||
|
||||
$this->autoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field value
|
||||
*/
|
||||
public function getFieldValue(string $fieldKey, ?string $locale = null): mixed
|
||||
{
|
||||
if ($locale) {
|
||||
return $this->content[$fieldKey][$locale] ?? null;
|
||||
}
|
||||
|
||||
return $this->content[$fieldKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set field value
|
||||
*/
|
||||
public function setFieldValue(string $fieldKey, mixed $value, ?string $locale = null)
|
||||
{
|
||||
if ($locale && in_array($locale, $this->availableLanguages)) {
|
||||
$this->content[$fieldKey][$locale] = $value;
|
||||
} else {
|
||||
$this->content[$fieldKey] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field has error
|
||||
*/
|
||||
public function hasFieldError(string $fieldKey, ?string $locale = null): bool
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
return isset($this->validationErrors[$errorKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field errors
|
||||
*/
|
||||
public function getFieldErrors(string $fieldKey, ?string $locale = null): array
|
||||
{
|
||||
$errorKey = $locale ? "{$fieldKey}.{$locale}" : $fieldKey;
|
||||
return $this->validationErrors[$errorKey] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component is valid
|
||||
*/
|
||||
public function getIsValidProperty(): bool
|
||||
{
|
||||
return empty($this->validationErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component config
|
||||
*/
|
||||
public function getConfigProperty(): array
|
||||
{
|
||||
return $this->componentRegistry->getComponentConfig($this->component->component_class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to original content
|
||||
*/
|
||||
public function resetContent()
|
||||
{
|
||||
$this->content = $this->component->getTranslations('content');
|
||||
$this->validationErrors = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class MediaManager extends Component
|
||||
{
|
||||
use WithFileUploads, WithPagination;
|
||||
|
||||
public bool $showModal = false;
|
||||
public ?string $targetComponentId = null;
|
||||
public ?string $targetFieldKey = null;
|
||||
public ?string $targetLocale = null;
|
||||
public array $uploadingFiles = [];
|
||||
public string $searchTerm = '';
|
||||
public string $filterType = 'all';
|
||||
public array $selectedMedia = [];
|
||||
public bool $multiSelect = false;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function render()
|
||||
{
|
||||
$media = $this->getFilteredMedia();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.media-manager', [
|
||||
'media' => $media,
|
||||
'uploadProgress' => $this->getUploadProgress(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal
|
||||
*/
|
||||
public function openModal(?string $componentId = null, ?string $fieldKey = null, ?string $locale = null, bool $multiSelect = false)
|
||||
{
|
||||
$this->showModal = true;
|
||||
$this->targetComponentId = $componentId;
|
||||
$this->targetFieldKey = $fieldKey;
|
||||
$this->targetLocale = $locale;
|
||||
$this->multiSelect = $multiSelect;
|
||||
$this->selectedMedia = [];
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
public function closeModal()
|
||||
{
|
||||
$this->showModal = false;
|
||||
$this->targetComponentId = null;
|
||||
$this->targetFieldKey = null;
|
||||
$this->targetLocale = null;
|
||||
$this->multiSelect = false;
|
||||
$this->selectedMedia = [];
|
||||
$this->uploadingFiles = [];
|
||||
$this->searchTerm = '';
|
||||
$this->filterType = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files
|
||||
*/
|
||||
public function uploadFiles()
|
||||
{
|
||||
$this->validate([
|
||||
'uploadingFiles.*' => 'file|max:' . config('flux-cms.media.max_file_size', 10240),
|
||||
]);
|
||||
|
||||
try {
|
||||
foreach ($this->uploadingFiles as $file) {
|
||||
$this->uploadSingleFile($file);
|
||||
}
|
||||
|
||||
$this->uploadingFiles = [];
|
||||
session()->flash('success', 'Files uploaded successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error uploading files: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload single file
|
||||
*/
|
||||
protected function uploadSingleFile(UploadedFile $file)
|
||||
{
|
||||
// Create a temporary model for media library
|
||||
// In real implementation, you'd use a dedicated media model
|
||||
$mediaModel = new class extends \Illuminate\Database\Eloquent\Model implements \Spatie\MediaLibrary\HasMedia {
|
||||
use \Spatie\MediaLibrary\InteractsWithMedia;
|
||||
protected $table = 'flux_cms_media'; // Would exist in real implementation
|
||||
};
|
||||
|
||||
$media = $mediaModel
|
||||
->addMedia($file)
|
||||
->usingFileName($file->getClientOriginalName())
|
||||
->toMediaCollection('uploads');
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select media
|
||||
*/
|
||||
public function selectMedia(int $mediaId)
|
||||
{
|
||||
if ($this->multiSelect) {
|
||||
if (in_array($mediaId, $this->selectedMedia)) {
|
||||
$this->selectedMedia = array_filter($this->selectedMedia, fn($id) => $id !== $mediaId);
|
||||
} else {
|
||||
$this->selectedMedia[] = $mediaId;
|
||||
}
|
||||
} else {
|
||||
$this->selectedMedia = [$mediaId];
|
||||
$this->confirmSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm selection
|
||||
*/
|
||||
public function confirmSelection()
|
||||
{
|
||||
if (empty($this->selectedMedia)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->targetComponentId && $this->targetFieldKey) {
|
||||
// Send event to component
|
||||
$this->dispatch('media-selected', [
|
||||
'componentId' => $this->targetComponentId,
|
||||
'fieldKey' => $this->targetFieldKey,
|
||||
'locale' => $this->targetLocale,
|
||||
'mediaIds' => $this->multiSelect ? $this->selectedMedia : $this->selectedMedia[0],
|
||||
]);
|
||||
} else {
|
||||
// Global event for other purposes
|
||||
$this->dispatch('media-manager-selection', [
|
||||
'mediaIds' => $this->selectedMedia,
|
||||
'multiSelect' => $this->multiSelect,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media
|
||||
*/
|
||||
public function deleteMedia(int $mediaId)
|
||||
{
|
||||
try {
|
||||
$media = Media::find($mediaId);
|
||||
|
||||
if ($media) {
|
||||
$media->delete();
|
||||
session()->flash('success', 'Media deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting media: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered media
|
||||
*/
|
||||
protected function getFilteredMedia()
|
||||
{
|
||||
$query = Media::query()->orderBy('created_at', 'desc');
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->searchTerm)) {
|
||||
$query->where('name', 'like', '%' . $this->searchTerm . '%');
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if ($this->filterType !== 'all') {
|
||||
$query->where('mime_type', 'like', $this->filterType . '%');
|
||||
}
|
||||
|
||||
return $query->paginate(20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload progress
|
||||
*/
|
||||
protected function getUploadProgress(): array
|
||||
{
|
||||
// Here you would normally track actual upload progress
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset filters
|
||||
*/
|
||||
public function resetFilters()
|
||||
{
|
||||
$this->searchTerm = '';
|
||||
$this->filterType = 'all';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search term updated
|
||||
*/
|
||||
public function updatedSearchTerm()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter type updated
|
||||
*/
|
||||
public function updatedFilterType()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if media is selected
|
||||
*/
|
||||
public function isSelected(int $mediaId): bool
|
||||
{
|
||||
return in_array($mediaId, $this->selectedMedia);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle select all
|
||||
*/
|
||||
public function toggleSelectAll()
|
||||
{
|
||||
if (count($this->selectedMedia) === 20) { // Items per page
|
||||
$this->selectedMedia = [];
|
||||
} else {
|
||||
$media = $this->getFilteredMedia();
|
||||
$this->selectedMedia = $media->pluck('id')->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filters
|
||||
*/
|
||||
public function getAvailableFiltersProperty(): array
|
||||
{
|
||||
return [
|
||||
'all' => 'All',
|
||||
'image' => 'Images',
|
||||
'video' => 'Videos',
|
||||
'audio' => 'Audio',
|
||||
'application' => 'Documents',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size
|
||||
*/
|
||||
public function formatFileSize(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use FluxCms\Core\Models\NavigationItem;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NavigationManager extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
public Collection $navigations;
|
||||
public ?Navigation $selectedNavigation = null;
|
||||
public Collection $navigationItems;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $showCreateModal = false;
|
||||
public bool $showItemModal = false;
|
||||
public ?NavigationItem $editingItem = null;
|
||||
|
||||
// Form data
|
||||
public array $navigationData = [];
|
||||
public array $itemData = [];
|
||||
|
||||
public function mount(string $domainKey)
|
||||
{
|
||||
$this->domainKey = $domainKey;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->loadNavigations();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$pages = Page::forDomain($this->domainKey)->published()->get();
|
||||
|
||||
return view('flux-cms-components::livewire.backend.navigation-manager', [
|
||||
'pages' => $pages,
|
||||
])->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigations for domain
|
||||
*/
|
||||
public function loadNavigations()
|
||||
{
|
||||
$this->navigations = Navigation::forDomain($this->domainKey)->active()->get();
|
||||
|
||||
if ($this->selectedNavigation) {
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigation items
|
||||
*/
|
||||
public function loadNavigationItems()
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
$this->navigationItems = collect();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->navigationItems = $this->selectedNavigation->getHierarchicalItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select navigation
|
||||
*/
|
||||
public function selectNavigation(int $navigationId)
|
||||
{
|
||||
$this->selectedNavigation = $this->navigations->firstWhere('id', $navigationId);
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create navigation modal
|
||||
*/
|
||||
public function showCreateNavigation()
|
||||
{
|
||||
$this->navigationData = [
|
||||
'name' => '',
|
||||
'display_name' => array_fill_keys($this->availableLanguages, ''),
|
||||
'is_active' => true,
|
||||
];
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create navigation
|
||||
*/
|
||||
public function createNavigation()
|
||||
{
|
||||
$this->validate([
|
||||
'navigationData.name' => 'required|string|max:255',
|
||||
'navigationData.display_name' => 'required|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
$navigation = Navigation::create([
|
||||
'domain_key' => $this->domainKey,
|
||||
'name' => $this->navigationData['name'],
|
||||
'display_name' => $this->navigationData['display_name'],
|
||||
'is_active' => $this->navigationData['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
$this->loadNavigations();
|
||||
$this->selectedNavigation = $navigation;
|
||||
$this->loadNavigationItems();
|
||||
$this->showCreateModal = false;
|
||||
|
||||
session()->flash('success', 'Navigation created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error creating navigation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show item modal
|
||||
*/
|
||||
public function showItemModal(?int $itemId = null, ?int $parentId = null)
|
||||
{
|
||||
if ($itemId) {
|
||||
$this->editingItem = NavigationItem::find($itemId);
|
||||
$this->itemData = [
|
||||
'label' => $this->editingItem->getTranslations('label'),
|
||||
'page_id' => $this->editingItem->page_id,
|
||||
'external_url' => $this->editingItem->external_url,
|
||||
'parent_id' => $this->editingItem->parent_id,
|
||||
'opens_in_new_tab' => $this->editingItem->opens_in_new_tab,
|
||||
'is_active' => $this->editingItem->is_active,
|
||||
];
|
||||
} else {
|
||||
$this->editingItem = null;
|
||||
$this->itemData = [
|
||||
'label' => array_fill_keys($this->availableLanguages, ''),
|
||||
'page_id' => null,
|
||||
'external_url' => '',
|
||||
'parent_id' => $parentId,
|
||||
'opens_in_new_tab' => false,
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$this->showItemModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save navigation item
|
||||
*/
|
||||
public function saveItem()
|
||||
{
|
||||
$this->validate([
|
||||
'itemData.label' => 'required|array',
|
||||
'itemData.page_id' => 'nullable|exists:flux_cms_pages,id',
|
||||
'itemData.external_url' => 'nullable|url',
|
||||
]);
|
||||
|
||||
// Validate that either page or external URL is provided
|
||||
if (empty($this->itemData['page_id']) && empty($this->itemData['external_url'])) {
|
||||
$this->addError('itemData.page_id', 'Either select a page or provide an external URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = [
|
||||
'navigation_id' => $this->selectedNavigation->id,
|
||||
'label' => $this->itemData['label'],
|
||||
'page_id' => $this->itemData['page_id'] ?: null,
|
||||
'external_url' => $this->itemData['external_url'] ?: null,
|
||||
'parent_id' => $this->itemData['parent_id'] ?: null,
|
||||
'opens_in_new_tab' => $this->itemData['opens_in_new_tab'] ?? false,
|
||||
'is_active' => $this->itemData['is_active'] ?? true,
|
||||
];
|
||||
|
||||
if ($this->editingItem) {
|
||||
$this->editingItem->update($data);
|
||||
$message = 'Navigation item updated successfully.';
|
||||
} else {
|
||||
// Set order for new item
|
||||
$maxOrder = $this->selectedNavigation->allItems()
|
||||
->where('parent_id', $data['parent_id'])
|
||||
->max('order') ?? 0;
|
||||
$data['order'] = $maxOrder + 1;
|
||||
|
||||
NavigationItem::create($data);
|
||||
$message = 'Navigation item created successfully.';
|
||||
}
|
||||
|
||||
$this->loadNavigationItems();
|
||||
$this->showItemModal = false;
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete navigation item
|
||||
*/
|
||||
public function deleteItem(int $itemId)
|
||||
{
|
||||
try {
|
||||
$item = NavigationItem::find($itemId);
|
||||
if ($item) {
|
||||
$item->delete();
|
||||
$this->loadNavigationItems();
|
||||
session()->flash('success', 'Navigation item deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle item active state
|
||||
*/
|
||||
public function toggleItem(int $itemId)
|
||||
{
|
||||
try {
|
||||
$item = NavigationItem::find($itemId);
|
||||
if ($item) {
|
||||
$item->update(['is_active' => !$item->is_active]);
|
||||
$this->loadNavigationItems();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error toggling navigation item: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item order
|
||||
*/
|
||||
public function updateOrder(array $orderedIds)
|
||||
{
|
||||
try {
|
||||
foreach ($orderedIds as $index => $id) {
|
||||
NavigationItem::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
$this->loadNavigationItems();
|
||||
session()->flash('success', 'Navigation order updated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error updating order: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete navigation
|
||||
*/
|
||||
public function deleteNavigation(int $navigationId)
|
||||
{
|
||||
try {
|
||||
$navigation = Navigation::find($navigationId);
|
||||
if ($navigation) {
|
||||
$navigation->delete();
|
||||
$this->loadNavigations();
|
||||
|
||||
if ($this->selectedNavigation && $this->selectedNavigation->id === $navigationId) {
|
||||
$this->selectedNavigation = null;
|
||||
$this->navigationItems = collect();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Navigation deleted successfully.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error deleting navigation: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available parent items
|
||||
*/
|
||||
public function getAvailableParentsProperty(): Collection
|
||||
{
|
||||
if (!$this->selectedNavigation) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->selectedNavigation->allItems()->whereNull('parent_id');
|
||||
|
||||
// Exclude current item and its children when editing
|
||||
if ($this->editingItem) {
|
||||
$excludeIds = [$this->editingItem->id];
|
||||
// Add children IDs recursively
|
||||
$this->addChildrenIds($this->editingItem, $excludeIds);
|
||||
$query->whereNotIn('id', $excludeIds);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add children IDs
|
||||
*/
|
||||
protected function addChildrenIds(NavigationItem $item, array &$excludeIds): void
|
||||
{
|
||||
foreach ($item->allChildren as $child) {
|
||||
$excludeIds[] = $child->id;
|
||||
$this->addChildrenIds($child, $excludeIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modals
|
||||
*/
|
||||
public function closeModals()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->showItemModal = false;
|
||||
$this->editingItem = null;
|
||||
$this->navigationData = [];
|
||||
$this->itemData = [];
|
||||
}
|
||||
}
|
||||
375
packages/flux-cms/components/src/Livewire/Backend/PageEditor.php
Normal file
375
packages/flux-cms/components/src/Livewire/Backend/PageEditor.php
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Backend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\On;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use FluxCms\Core\Models\PageComponent;
|
||||
use FluxCms\Core\Services\ComponentRegistry;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PageEditor extends Component
|
||||
{
|
||||
public Page $page;
|
||||
public Collection $components;
|
||||
public array $availableLanguages = [];
|
||||
public string $currentLocale = 'de';
|
||||
public bool $showComponentModal = false;
|
||||
public array $availableComponents = [];
|
||||
public string $selectedCategory = 'all';
|
||||
public bool $isLoading = false;
|
||||
|
||||
protected ComponentRegistry $componentRegistry;
|
||||
|
||||
public function boot(ComponentRegistry $componentRegistry)
|
||||
{
|
||||
$this->componentRegistry = $componentRegistry;
|
||||
}
|
||||
|
||||
public function mount(Page $page)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->availableLanguages = array_keys(config('flux-cms.locales', ['de' => 'Deutsch', 'en' => 'English']));
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->loadComponents();
|
||||
$this->loadAvailableComponents();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.backend.page-editor')
|
||||
->layout('flux-cms-components::layouts.admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load page components
|
||||
*/
|
||||
public function loadComponents()
|
||||
{
|
||||
$this->components = $this->page->allComponents()->ordered()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available components from registry
|
||||
*/
|
||||
public function loadAvailableComponents()
|
||||
{
|
||||
$this->availableComponents = $this->componentRegistry->getComponentsByCategory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch locale
|
||||
*/
|
||||
public function switchLocale(string $locale)
|
||||
{
|
||||
if (in_array($locale, $this->availableLanguages)) {
|
||||
$this->currentLocale = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add component modal
|
||||
*/
|
||||
public function showAddComponentModal()
|
||||
{
|
||||
$this->loadAvailableComponents(); // Refresh components
|
||||
$this->showComponentModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close add component modal
|
||||
*/
|
||||
public function closeAddComponentModal()
|
||||
{
|
||||
$this->showComponentModal = false;
|
||||
$this->selectedCategory = 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category filter
|
||||
*/
|
||||
public function setCategory(string $category)
|
||||
{
|
||||
$this->selectedCategory = $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new component
|
||||
*/
|
||||
public function addComponent(string $componentClass)
|
||||
{
|
||||
if (!$this->componentRegistry->isValidComponent($componentClass)) {
|
||||
$this->addError('component', 'Invalid component selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$maxOrder = $this->page->allComponents()->max('order') ?? 0;
|
||||
|
||||
$component = $this->page->allComponents()->create([
|
||||
'component_class' => $componentClass,
|
||||
'order' => $maxOrder + 1,
|
||||
'content' => $this->getDefaultContent($componentClass),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->loadComponents();
|
||||
$this->closeAddComponentModal();
|
||||
|
||||
$this->dispatch('scroll-to-component', componentId: $component->id);
|
||||
session()->flash('success', 'Component added successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error adding component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete component
|
||||
*/
|
||||
public function deleteComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$component->delete();
|
||||
$this->loadComponents();
|
||||
$this->reorderComponents();
|
||||
|
||||
session()->flash('success', 'Component deleted successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error deleting component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate component
|
||||
*/
|
||||
public function duplicateComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
$this->addError('component', 'Component not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$duplicate = $component->duplicate();
|
||||
$this->loadComponents();
|
||||
|
||||
$this->dispatch('scroll-to-component', componentId: $duplicate->id);
|
||||
session()->flash('success', 'Component duplicated successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error duplicating component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle component active state
|
||||
*/
|
||||
public function toggleComponent(int $componentId)
|
||||
{
|
||||
try {
|
||||
$component = $this->components->firstWhere('id', $componentId);
|
||||
|
||||
if (!$component) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component->update(['is_active' => !$component->is_active]);
|
||||
$this->loadComponents();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('component', 'Error toggling component: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update component order
|
||||
*/
|
||||
#[On('components-reordered')]
|
||||
public function updateOrder(array $orderedIds)
|
||||
{
|
||||
try {
|
||||
foreach ($orderedIds as $index => $id) {
|
||||
PageComponent::where('id', $id)->update(['order' => $index + 1]);
|
||||
}
|
||||
|
||||
$this->loadComponents();
|
||||
session()->flash('success', 'Component order updated.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('order', 'Error updating order: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder components to close gaps
|
||||
*/
|
||||
protected function reorderComponents()
|
||||
{
|
||||
$components = $this->page->allComponents()->orderBy('order')->get();
|
||||
|
||||
foreach ($components as $index => $component) {
|
||||
$component->update(['order' => $index + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default content for component
|
||||
*/
|
||||
protected function getDefaultContent(string $componentClass): array
|
||||
{
|
||||
$config = $this->componentRegistry->getComponentConfig($componentClass);
|
||||
$content = [];
|
||||
|
||||
if (empty($config['fields'])) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
foreach ($config['fields'] as $field) {
|
||||
if (!$field instanceof \FluxCms\Core\FieldTypes\BaseField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultValue = $field->getDefault();
|
||||
|
||||
if ($field->isTranslatable()) {
|
||||
foreach ($this->availableLanguages as $locale) {
|
||||
$content[$field->getKey()][$locale] = $defaultValue;
|
||||
}
|
||||
} else {
|
||||
$content[$field->getKey()] = $defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update page data
|
||||
*/
|
||||
public function updatePageData()
|
||||
{
|
||||
try {
|
||||
$this->validate([
|
||||
'page.title' => 'required|array',
|
||||
'page.slug' => 'required|array',
|
||||
'page.meta_description' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$this->page->save();
|
||||
session()->flash('success', 'Page data saved successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('page', 'Error saving page: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle publish status
|
||||
*/
|
||||
public function togglePublish()
|
||||
{
|
||||
try {
|
||||
if ($this->page->is_published) {
|
||||
$this->page->unpublish();
|
||||
$message = 'Page unpublished successfully.';
|
||||
} else {
|
||||
$this->page->publish();
|
||||
$message = 'Page published successfully.';
|
||||
}
|
||||
|
||||
session()->flash('success', $message);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('publish', 'Error updating publish status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version
|
||||
*/
|
||||
public function createVersion(string $description = null)
|
||||
{
|
||||
try {
|
||||
$this->page->createVersion($description, auth()->id());
|
||||
session()->flash('success', 'Version created successfully.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('version', 'Error creating version: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview page
|
||||
*/
|
||||
public function preview()
|
||||
{
|
||||
$locale = $this->currentLocale;
|
||||
$slug = $this->page->getTranslation('slug', $locale);
|
||||
|
||||
if (empty($slug)) {
|
||||
$this->addError('preview', 'No slug available for current language.');
|
||||
return;
|
||||
}
|
||||
|
||||
$url = $this->page->getUrl($locale) . '?preview=1';
|
||||
$this->dispatch('open-preview', url: $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available categories for filter
|
||||
*/
|
||||
public function getAvailableCategoriesProperty(): array
|
||||
{
|
||||
$categories = ['all' => 'All Categories'];
|
||||
|
||||
foreach ($this->availableComponents as $category => $components) {
|
||||
$categories[$category] = $category;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered components for modal
|
||||
*/
|
||||
public function getFilteredComponentsProperty(): array
|
||||
{
|
||||
if ($this->selectedCategory === 'all') {
|
||||
$components = [];
|
||||
foreach ($this->availableComponents as $category => $categoryComponents) {
|
||||
$components = array_merge($components, $categoryComponents);
|
||||
}
|
||||
return $components;
|
||||
}
|
||||
|
||||
return $this->availableComponents[$this->selectedCategory] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page status
|
||||
*/
|
||||
public function getPageStatusProperty(): string
|
||||
{
|
||||
if (!$this->page->is_published) {
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
if ($this->page->published_at && $this->page->published_at->isFuture()) {
|
||||
return 'scheduled';
|
||||
}
|
||||
|
||||
return 'published';
|
||||
}
|
||||
}
|
||||
265
packages/flux-cms/components/src/Livewire/Frontend/BlogList.php
Normal file
265
packages/flux-cms/components/src/Livewire/Frontend/BlogList.php
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use FluxCms\Core\Models\BlogPost;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogList extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $domainKey;
|
||||
public int $perPage = 12;
|
||||
public bool $showFeatured = true;
|
||||
public bool $showPagination = true;
|
||||
public string $orderBy = 'published_at';
|
||||
public string $orderDirection = 'desc';
|
||||
public array $classes = [];
|
||||
|
||||
// Filtering
|
||||
public string $search = '';
|
||||
public array $tags = [];
|
||||
public ?string $category = null;
|
||||
|
||||
protected $paginationTheme = 'simple-bootstrap';
|
||||
|
||||
public function mount(
|
||||
string $domainKey,
|
||||
int $perPage = 12,
|
||||
bool $showFeatured = true,
|
||||
bool $showPagination = true,
|
||||
string $orderBy = 'published_at',
|
||||
string $orderDirection = 'desc',
|
||||
array $classes = []
|
||||
) {
|
||||
$this->domainKey = $domainKey;
|
||||
$this->perPage = $perPage;
|
||||
$this->showFeatured = $showFeatured;
|
||||
$this->showPagination = $showPagination;
|
||||
$this->orderBy = $orderBy;
|
||||
$this->orderDirection = $orderDirection;
|
||||
$this->classes = $classes;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$posts = $this->getFilteredPosts();
|
||||
$featuredPosts = $this->showFeatured ? $this->getFeaturedPosts() : collect();
|
||||
|
||||
return view('flux-cms-components::livewire.frontend.blog-list', [
|
||||
'posts' => $posts,
|
||||
'featuredPosts' => $featuredPosts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered blog posts
|
||||
*/
|
||||
protected function getFilteredPosts()
|
||||
{
|
||||
$query = BlogPost::forDomain($this->domainKey)->published();
|
||||
|
||||
// Search filter
|
||||
if (!empty($this->search)) {
|
||||
$locale = app()->getLocale();
|
||||
$query->where(function ($q) use ($locale) {
|
||||
$q->where("title->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%' . $this->search . '%')
|
||||
->orWhere("content->{$locale}", 'like', '%' . $this->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Order by
|
||||
$query->orderBy($this->orderBy, $this->orderDirection);
|
||||
|
||||
// Pagination or limit
|
||||
if ($this->showPagination) {
|
||||
return $query->paginate($this->perPage);
|
||||
} else {
|
||||
return $query->limit($this->perPage)->get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured posts
|
||||
*/
|
||||
protected function getFeaturedPosts(): Collection
|
||||
{
|
||||
return BlogPost::forDomain($this->domainKey)
|
||||
->published()
|
||||
->featured()
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search
|
||||
*/
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search
|
||||
*/
|
||||
public function clearSearch()
|
||||
{
|
||||
$this->search = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post title
|
||||
*/
|
||||
public function getPostTitle(BlogPost $post): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post excerpt
|
||||
*/
|
||||
public function getPostExcerpt(BlogPost $post, int $length = 150): string
|
||||
{
|
||||
return $post->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post URL
|
||||
*/
|
||||
public function getPostUrl(BlogPost $post): string
|
||||
{
|
||||
return $post->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post reading time
|
||||
*/
|
||||
public function getReadingTime(BlogPost $post): int
|
||||
{
|
||||
return $post->getReadingTime(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post featured image URL
|
||||
*/
|
||||
public function getFeaturedImageUrl(BlogPost $post, string $conversion = 'card'): ?string
|
||||
{
|
||||
return $post->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post has featured image
|
||||
*/
|
||||
public function hasFeaturedImage(BlogPost $post): bool
|
||||
{
|
||||
return $post->getFeaturedImage() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format published date
|
||||
*/
|
||||
public function formatPublishedDate(BlogPost $post, string $format = 'd.m.Y'): string
|
||||
{
|
||||
return $post->published_at ? $post->published_at->format($format) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author name
|
||||
*/
|
||||
public function getAuthorName(BlogPost $post): string
|
||||
{
|
||||
return $post->author?->name ?? 'Unknown Author';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container CSS classes
|
||||
*/
|
||||
public function getContainerClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-blog-list', 'blog-list'];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post CSS classes
|
||||
*/
|
||||
public function getPostClasses(BlogPost $post): string
|
||||
{
|
||||
$classes = ['blog-post-item'];
|
||||
|
||||
if ($post->is_featured) {
|
||||
$classes[] = 'blog-post-item--featured';
|
||||
}
|
||||
|
||||
if ($this->hasFeaturedImage($post)) {
|
||||
$classes[] = 'blog-post-item--has-image';
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any posts
|
||||
*/
|
||||
public function hasPosts(): bool
|
||||
{
|
||||
return $this->getFilteredPosts()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are featured posts
|
||||
*/
|
||||
public function hasFeaturedPosts(): bool
|
||||
{
|
||||
return $this->showFeatured && $this->getFeaturedPosts()->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search placeholder
|
||||
*/
|
||||
public function getSearchPlaceholder(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $locale === 'de' ? 'Blog durchsuchen...' : 'Search blog...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get "no posts" message
|
||||
*/
|
||||
public function getNoPostsMessage(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if (!empty($this->search)) {
|
||||
return $locale === 'de'
|
||||
? 'Keine Artikel für "' . $this->search . '" gefunden.'
|
||||
: 'No posts found for "' . $this->search . '".';
|
||||
}
|
||||
|
||||
return $locale === 'de'
|
||||
? 'Noch keine Blog-Artikel vorhanden.'
|
||||
: 'No blog posts available yet.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time text
|
||||
*/
|
||||
public function getReadingTimeText(int $minutes): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($locale === 'de') {
|
||||
return $minutes === 1 ? '1 Minute Lesezeit' : "{$minutes} Minuten Lesezeit";
|
||||
}
|
||||
|
||||
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
|
||||
}
|
||||
}
|
||||
314
packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php
Normal file
314
packages/flux-cms/components/src/Livewire/Frontend/BlogPost.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\BlogPost as BlogPostModel;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BlogPost extends Component
|
||||
{
|
||||
public BlogPostModel $post;
|
||||
public string $domainKey;
|
||||
public bool $showRelated = true;
|
||||
public bool $showAuthor = true;
|
||||
public bool $showMeta = true;
|
||||
public bool $showSocial = true;
|
||||
public array $classes = [];
|
||||
|
||||
public function mount(
|
||||
BlogPostModel $post,
|
||||
string $domainKey,
|
||||
bool $showRelated = true,
|
||||
bool $showAuthor = true,
|
||||
bool $showMeta = true,
|
||||
bool $showSocial = true,
|
||||
array $classes = []
|
||||
) {
|
||||
$this->post = $post;
|
||||
$this->domainKey = $domainKey;
|
||||
$this->showRelated = $showRelated;
|
||||
$this->showAuthor = $showAuthor;
|
||||
$this->showMeta = $showMeta;
|
||||
$this->showSocial = $showSocial;
|
||||
$this->classes = $classes;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$relatedPosts = $this->showRelated ? $this->getRelatedPosts() : collect();
|
||||
|
||||
return view('flux-cms-components::livewire.frontend.blog-post', [
|
||||
'relatedPosts' => $relatedPosts,
|
||||
'seoData' => $this->getSeoData(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts
|
||||
*/
|
||||
protected function getRelatedPosts(): Collection
|
||||
{
|
||||
return BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('id', '!=', $this->post->id)
|
||||
->orderBy('published_at', 'desc')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO data for the post
|
||||
*/
|
||||
protected function getSeoData(): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'title' => $this->getTitle() . ' - Blog',
|
||||
'description' => $this->getExcerpt(160),
|
||||
'keywords' => $this->post->getTranslation('meta_keywords', $locale),
|
||||
'og_title' => $this->getTitle(),
|
||||
'og_description' => $this->getExcerpt(160),
|
||||
'og_image' => $this->getFeaturedImageUrl('card'),
|
||||
'og_url' => $this->getUrl(),
|
||||
'og_type' => 'article',
|
||||
'article_published_time' => $this->post->published_at?->toISOString(),
|
||||
'article_author' => $this->getAuthorName(),
|
||||
'canonical_url' => $this->getUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post title
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $this->post->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post content
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $this->post->getTranslation('content', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post excerpt
|
||||
*/
|
||||
public function getExcerpt(int $length = 300): string
|
||||
{
|
||||
return $this->post->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post URL
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->post->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time
|
||||
*/
|
||||
public function getReadingTime(): int
|
||||
{
|
||||
return $this->post->getReadingTime(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading time text
|
||||
*/
|
||||
public function getReadingTimeText(): string
|
||||
{
|
||||
$minutes = $this->getReadingTime();
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($locale === 'de') {
|
||||
return $minutes === 1 ? '1 Minute Lesezeit' : "{$minutes} Minuten Lesezeit";
|
||||
}
|
||||
|
||||
return $minutes === 1 ? '1 min read' : "{$minutes} min read";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured image URL
|
||||
*/
|
||||
public function getFeaturedImageUrl(string $conversion = 'hero'): ?string
|
||||
{
|
||||
return $this->post->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post has featured image
|
||||
*/
|
||||
public function hasFeaturedImage(): bool
|
||||
{
|
||||
return $this->post->getFeaturedImage() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format published date
|
||||
*/
|
||||
public function formatPublishedDate(string $format = 'd.m.Y'): string
|
||||
{
|
||||
return $this->post->published_at ? $this->post->published_at->format($format) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author name
|
||||
*/
|
||||
public function getAuthorName(): string
|
||||
{
|
||||
return $this->post->author?->name ?? 'Unknown Author';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get author avatar URL
|
||||
*/
|
||||
public function getAuthorAvatarUrl(): ?string
|
||||
{
|
||||
// This would depend on your user model implementation
|
||||
return $this->post->author?->avatar_url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post is featured
|
||||
*/
|
||||
public function isFeatured(): bool
|
||||
{
|
||||
return $this->post->is_featured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container CSS classes
|
||||
*/
|
||||
public function getContainerClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-blog-post', 'blog-post'];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
|
||||
if ($this->isFeatured()) {
|
||||
$allClasses[] = 'blog-post--featured';
|
||||
}
|
||||
|
||||
if ($this->hasFeaturedImage()) {
|
||||
$allClasses[] = 'blog-post--has-image';
|
||||
}
|
||||
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get social sharing URLs
|
||||
*/
|
||||
public function getSocialUrls(): array
|
||||
{
|
||||
$url = urlencode($this->getUrl());
|
||||
$title = urlencode($this->getTitle());
|
||||
$excerpt = urlencode($this->getExcerpt(100));
|
||||
|
||||
return [
|
||||
'twitter' => "https://twitter.com/intent/tweet?url={$url}&text={$title}",
|
||||
'facebook' => "https://www.facebook.com/sharer/sharer.php?u={$url}",
|
||||
'linkedin' => "https://www.linkedin.com/sharing/share-offsite/?url={$url}",
|
||||
'email' => "mailto:?subject={$title}&body={$excerpt}%20{$url}",
|
||||
'whatsapp' => "https://wa.me/?text={$title}%20{$url}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post title
|
||||
*/
|
||||
public function getRelatedPostTitle(BlogPostModel $relatedPost): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $relatedPost->getTranslation('title', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post URL
|
||||
*/
|
||||
public function getRelatedPostUrl(BlogPostModel $relatedPost): string
|
||||
{
|
||||
return $relatedPost->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post excerpt
|
||||
*/
|
||||
public function getRelatedPostExcerpt(BlogPostModel $relatedPost, int $length = 100): string
|
||||
{
|
||||
return $relatedPost->getExcerpt($length, app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related post featured image URL
|
||||
*/
|
||||
public function getRelatedPostImageUrl(BlogPostModel $relatedPost, string $conversion = 'thumb'): ?string
|
||||
{
|
||||
return $relatedPost->getFeaturedImageUrl($conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are related posts
|
||||
*/
|
||||
public function hasRelatedPosts(): bool
|
||||
{
|
||||
return $this->showRelated && $this->getRelatedPosts()->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema.org structured data
|
||||
*/
|
||||
public function getStructuredData(): array
|
||||
{
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BlogPosting',
|
||||
'headline' => $this->getTitle(),
|
||||
'description' => $this->getExcerpt(160),
|
||||
'datePublished' => $this->post->published_at?->toISOString(),
|
||||
'dateModified' => $this->post->updated_at->toISOString(),
|
||||
'author' => [
|
||||
'@type' => 'Person',
|
||||
'name' => $this->getAuthorName(),
|
||||
],
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => config('app.name'),
|
||||
],
|
||||
'url' => $this->getUrl(),
|
||||
'image' => $this->getFeaturedImageUrl('card'),
|
||||
'wordCount' => str_word_count(strip_tags($this->getContent())),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation (previous/next posts)
|
||||
*/
|
||||
public function getNavigation(): array
|
||||
{
|
||||
$previousPost = BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('published_at', '<', $this->post->published_at)
|
||||
->orderBy('published_at', 'desc')
|
||||
->first();
|
||||
|
||||
$nextPost = BlogPostModel::forDomain($this->domainKey)
|
||||
->published()
|
||||
->where('published_at', '>', $this->post->published_at)
|
||||
->orderBy('published_at', 'asc')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'previous' => $previousPost,
|
||||
'next' => $nextPost,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Navigation;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NavigationRenderer extends Component
|
||||
{
|
||||
public string $domainKey;
|
||||
public string $navigationName;
|
||||
public ?Navigation $navigation = null;
|
||||
public Collection $navigationItems;
|
||||
public string $currentUrl = '';
|
||||
public array $classes = [];
|
||||
public bool $showInactive = false;
|
||||
|
||||
public function mount(
|
||||
string $domainKey,
|
||||
string $navigationName,
|
||||
array $classes = [],
|
||||
bool $showInactive = false
|
||||
) {
|
||||
$this->domainKey = $domainKey;
|
||||
$this->navigationName = $navigationName;
|
||||
$this->classes = $classes;
|
||||
$this->showInactive = $showInactive;
|
||||
$this->currentUrl = request()->url();
|
||||
$this->loadNavigation();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.frontend.navigation-renderer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load navigation and items
|
||||
*/
|
||||
protected function loadNavigation()
|
||||
{
|
||||
$this->navigation = Navigation::forDomain($this->domainKey)
|
||||
->byName($this->navigationName, $this->domainKey)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($this->navigation) {
|
||||
$this->navigationItems = $this->navigation->getHierarchicalItems();
|
||||
|
||||
// Filter inactive items if needed
|
||||
if (!$this->showInactive) {
|
||||
$this->navigationItems = $this->navigationItems->where('is_active', true);
|
||||
}
|
||||
} else {
|
||||
$this->navigationItems = collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation item is active
|
||||
*/
|
||||
public function isActive($item): bool
|
||||
{
|
||||
return $item->isActive($this->currentUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for navigation item
|
||||
*/
|
||||
public function getItemUrl($item): string
|
||||
{
|
||||
return $item->getUrl(app()->getLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get label for navigation item
|
||||
*/
|
||||
public function getItemLabel($item): string
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $item->getTranslation('label', $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item has children
|
||||
*/
|
||||
public function hasChildren($item): bool
|
||||
{
|
||||
return $item->children->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get children of item
|
||||
*/
|
||||
public function getChildren($item): Collection
|
||||
{
|
||||
if (!$this->showInactive) {
|
||||
return $item->children->where('is_active', true);
|
||||
}
|
||||
return $item->children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item should open in new tab
|
||||
*/
|
||||
public function opensInNewTab($item): bool
|
||||
{
|
||||
return $item->opens_in_new_tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes for navigation container
|
||||
*/
|
||||
public function getNavigationClasses(): string
|
||||
{
|
||||
$defaultClasses = ['flux-cms-navigation', 'navigation-' . $this->navigationName];
|
||||
$allClasses = array_merge($defaultClasses, $this->classes);
|
||||
return implode(' ', $allClasses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes for navigation item
|
||||
*/
|
||||
public function getItemClasses($item, bool $isChild = false): string
|
||||
{
|
||||
$classes = ['nav-item'];
|
||||
|
||||
if ($isChild) {
|
||||
$classes[] = 'nav-item--child';
|
||||
}
|
||||
|
||||
if ($this->isActive($item)) {
|
||||
$classes[] = 'nav-item--active';
|
||||
}
|
||||
|
||||
if ($this->hasChildren($item)) {
|
||||
$classes[] = 'nav-item--has-children';
|
||||
}
|
||||
|
||||
if (!$item->is_active) {
|
||||
$classes[] = 'nav-item--inactive';
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get link attributes
|
||||
*/
|
||||
public function getLinkAttributes($item): array
|
||||
{
|
||||
$attributes = [
|
||||
'href' => $this->getItemUrl($item),
|
||||
'class' => 'nav-link',
|
||||
];
|
||||
|
||||
if ($this->opensInNewTab($item)) {
|
||||
$attributes['target'] = '_blank';
|
||||
$attributes['rel'] = 'noopener noreferrer';
|
||||
}
|
||||
|
||||
if ($this->isActive($item)) {
|
||||
$attributes['class'] .= ' nav-link--active';
|
||||
$attributes['aria-current'] = 'page';
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render link attributes as string
|
||||
*/
|
||||
public function renderLinkAttributes($item): string
|
||||
{
|
||||
$attributes = $this->getLinkAttributes($item);
|
||||
$attributeStrings = [];
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attributeStrings[] = $key . '="' . htmlspecialchars($value) . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $attributeStrings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breadcrumbs for current page
|
||||
*/
|
||||
public function getBreadcrumbs(): Collection
|
||||
{
|
||||
$breadcrumbs = collect();
|
||||
|
||||
foreach ($this->navigationItems as $item) {
|
||||
if ($item->isActive($this->currentUrl)) {
|
||||
$breadcrumbs = $item->getBreadcrumbs();
|
||||
break;
|
||||
}
|
||||
|
||||
// Check children recursively
|
||||
$childBreadcrumbs = $this->findBreadcrumbsInChildren($item);
|
||||
if ($childBreadcrumbs->isNotEmpty()) {
|
||||
$breadcrumbs = $childBreadcrumbs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $breadcrumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find breadcrumbs in children recursively
|
||||
*/
|
||||
protected function findBreadcrumbsInChildren($item): Collection
|
||||
{
|
||||
foreach ($item->children as $child) {
|
||||
if ($child->isActive($this->currentUrl)) {
|
||||
return $child->getBreadcrumbs();
|
||||
}
|
||||
|
||||
$childBreadcrumbs = $this->findBreadcrumbsInChildren($child);
|
||||
if ($childBreadcrumbs->isNotEmpty()) {
|
||||
return $childBreadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation exists and has items
|
||||
*/
|
||||
public function hasNavigation(): bool
|
||||
{
|
||||
return $this->navigation !== null && $this->navigationItems->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation display name
|
||||
*/
|
||||
public function getNavigationDisplayName(): string
|
||||
{
|
||||
if (!$this->navigation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
return $this->navigation->getTranslation('display_name', $locale);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
namespace FluxCms\Components\Livewire\Frontend;
|
||||
|
||||
use Livewire\Component;
|
||||
use FluxCms\Core\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PageRenderer extends Component
|
||||
{
|
||||
public Page $page;
|
||||
public Collection $components;
|
||||
public bool $isPreview = false;
|
||||
public array $seoData = [];
|
||||
|
||||
public function mount(Page $page, bool $isPreview = false)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->isPreview = $isPreview;
|
||||
$this->loadComponents();
|
||||
$this->prepareSeoData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('flux-cms-components::livewire.frontend.page-renderer')
|
||||
->layout('flux-cms-components::layouts.frontend', [
|
||||
'seoData' => $this->seoData,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load page components
|
||||
*/
|
||||
protected function loadComponents()
|
||||
{
|
||||
if ($this->isPreview) {
|
||||
// Show all components in preview mode
|
||||
$this->components = $this->page->allComponents()->ordered()->get();
|
||||
} else {
|
||||
// Show only active components
|
||||
$this->components = $this->page->components()->get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare SEO data
|
||||
*/
|
||||
protected function prepareSeoData()
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$this->seoData = [
|
||||
'title' => $this->page->getSeoTitle($locale),
|
||||
'description' => $this->page->getSeoDescription($locale),
|
||||
'keywords' => $this->page->getTranslation('meta_keywords', $locale),
|
||||
'canonical_url' => $this->page->getCanonicalUrl(),
|
||||
'og_title' => $this->page->getTranslation('title', $locale),
|
||||
'og_description' => $this->page->getSeoDescription($locale),
|
||||
'og_image' => $this->page->getTranslation('og_image', $locale),
|
||||
'og_url' => request()->url(),
|
||||
'og_type' => 'website',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if component can be rendered
|
||||
*/
|
||||
public function canRenderComponent(PageComponent $component): bool
|
||||
{
|
||||
// In preview mode, show all components
|
||||
if ($this->isPreview) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In normal mode, only show active components
|
||||
return $component->canRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get component content for current locale
|
||||
*/
|
||||
public function getComponentContent(PageComponent $component): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
return $component->getTranslatedContent($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render component with error handling
|
||||
*/
|
||||
public function renderComponent(PageComponent $component): string
|
||||
{
|
||||
try {
|
||||
if (!$this->canRenderComponent($component)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = $this->getComponentContent($component);
|
||||
|
||||
// Check if component class exists
|
||||
if (!class_exists($component->component_class)) {
|
||||
if ($this->isPreview) {
|
||||
return $this->renderComponentError($component, 'Component class not found');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Render component
|
||||
$componentHtml = \Livewire\Livewire::mount($component->component_class, [
|
||||
'content' => $component->getTranslations('content')
|
||||
])->html();
|
||||
|
||||
// Wrap component if enabled
|
||||
if (config('flux-cms.frontend.component_wrapper', true)) {
|
||||
return $this->wrapComponent($component, $componentHtml);
|
||||
}
|
||||
|
||||
return $componentHtml;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error rendering component', [
|
||||
'component_id' => $component->id,
|
||||
'component_class' => $component->component_class,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
if ($this->isPreview) {
|
||||
return $this->renderComponentError($component, $e->getMessage());
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap component with additional markup
|
||||
*/
|
||||
protected function wrapComponent(PageComponent $component, string $html): string
|
||||
{
|
||||
$classes = [
|
||||
'flux-cms-component',
|
||||
'flux-cms-component--' . class_basename($component->component_class),
|
||||
];
|
||||
|
||||
if (!$component->is_active) {
|
||||
$classes[] = 'flux-cms-component--inactive';
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
|
||||
if ($this->isPreview) {
|
||||
$attributes['data-component-id'] = $component->id;
|
||||
$attributes['data-component-class'] = $component->component_class;
|
||||
$classes[] = 'flux-cms-component--preview';
|
||||
}
|
||||
|
||||
$attributeString = collect($attributes)
|
||||
->map(fn($value, $key) => "{$key}=\"{$value}\"")
|
||||
->implode(' ');
|
||||
|
||||
$classString = implode(' ', $classes);
|
||||
|
||||
return "<div class=\"{$classString}\" {$attributeString}>{$html}</div>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render component error for preview mode
|
||||
*/
|
||||
protected function renderComponentError(PageComponent $component, string $error): string
|
||||
{
|
||||
$componentName = $component->getComponentName();
|
||||
|
||||
return "
|
||||
<div class=\"flux-cms-component-error p-4 border-2 border-dashed border-red-300 bg-red-50 text-red-800 rounded\">
|
||||
<h4 class=\"font-bold\">Error in {$componentName}</h4>
|
||||
<p class=\"text-sm mt-1\">{$error}</p>
|
||||
<p class=\"text-xs mt-2 opacity-75\">Component ID: {$component->id}</p>
|
||||
</div>
|
||||
";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get page breadcrumbs
|
||||
*/
|
||||
public function getBreadcrumbs(): array
|
||||
{
|
||||
// This could be extended to build breadcrumbs from navigation
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related pages
|
||||
*/
|
||||
public function getRelatedPages(): Collection
|
||||
{
|
||||
return Page::forDomain($this->page->domain_key)
|
||||
->published()
|
||||
->where('id', '!=', $this->page->id)
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue