First commit

This commit is contained in:
Kevin Adametz 2025-10-20 17:50:35 +02:00
commit 7cf3558ba7
12933 changed files with 1180047 additions and 0 deletions

View file

@ -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);
}
}
}
}

View file

@ -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');
}
}

View file

@ -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(),
];
}
}

View file

@ -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 = [];
}
}

View file

@ -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];
}
}

View file

@ -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 = [];
}
}

View 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';
}
}

View 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";
}
}

View 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,
];
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}