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,57 @@
{
"name": "flux-cms/components",
"description": "Flux CMS Livewire Components Package - Backend and Frontend components",
"type": "library",
"keywords": [
"laravel",
"cms",
"livewire",
"components",
"backend",
"frontend"
],
"license": "MIT",
"authors": [
{
"name": "Flux CMS Contributors",
"email": "contributors@flux-cms.com"
}
],
"require": {
"php": "^8.2",
"laravel/framework": "^11.0|^12.0",
"livewire/livewire": "^3.0",
"livewire/flux": "^2.1.1",
"flux-cms/core": "*"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"pestphp/pest": "^3.8",
"pestphp/pest-plugin-laravel": "^3.2"
},
"autoload": {
"psr-4": {
"FluxCms\\Components\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"FluxCms\\Components\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"FluxCms\\Components\\FluxCmsComponentsServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

View file

@ -0,0 +1,27 @@
<div>
<form wire:submit.prevent="save">
{{-- Post Title --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="title" wire:model.defer="post.title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
{{-- Post Content --}}
<div class="mt-4">
<label for="content" class="block text-sm font-medium text-gray-700">Content</label>
<textarea id="content" wire:model.defer="post.content" rows="10" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"></textarea>
</div>
{{-- Tags --}}
<div class="mt-4">
<label for="tags" class="block text-sm font-medium text-gray-700">Tags (comma-separated)</label>
<input type="text" id="tags" wire:model.defer="tags" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" placeholder="laravel, php, cms">
</div>
<div class="mt-4">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-500 active:bg-blue-700">
Save Post
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,366 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Blog Manager</h1>
<p class="text-sm text-gray-500 mt-1">
Create and manage blog posts
</p>
</div>
<div class="flex items-center space-x-3">
<flux:button wire:click="createPost" variant="primary">
New Post
</flux:button>
</div>
</div>
{{-- Stats Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
@foreach($this->getStats() as $stat)
<flux:card>
<div class="text-center p-4">
<div class="text-2xl font-bold text-gray-900">{{ $stat['value'] }}</div>
<div class="text-sm text-gray-500">{{ $stat['label'] }}</div>
</div>
</flux:card>
@endforeach
</div>
{{-- Filters --}}
<flux:card>
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-64">
<flux:input wire:model.live="search"
placeholder="Search posts..."
class="w-full" />
</div>
<flux:select wire:model.live="statusFilter" placeholder="All Status">
<flux:option value="">All Status</flux:option>
<flux:option value="published">Published</flux:option>
<flux:option value="draft">Draft</flux:option>
<flux:option value="featured">Featured</flux:option>
</flux:select>
<flux:select wire:model.live="categoryFilter" placeholder="All Categories">
<flux:option value="">All Categories</flux:option>
@foreach($this->getCategories() as $category)
<flux:option value="{{ $category }}">{{ $category }}</flux:option>
@endforeach
</flux:select>
<flux:select wire:model.live="sortBy">
<flux:option value="created_at">Created Date</flux:option>
<flux:option value="published_at">Published Date</flux:option>
<flux:option value="title">Title</flux:option>
</flux:select>
<flux:select wire:model.live="sortDirection">
<flux:option value="desc">Newest First</flux:option>
<flux:option value="asc">Oldest First</flux:option>
</flux:select>
</div>
</flux:card>
{{-- Posts Table --}}
<flux:card>
<flux:table>
<flux:columns>
<flux:column>Title</flux:column>
<flux:column>Status</flux:column>
<flux:column>Category</flux:column>
<flux:column>Author</flux:column>
<flux:column>Published</flux:column>
<flux:column>Actions</flux:column>
</flux:columns>
<flux:rows>
@forelse($posts as $post)
<flux:row wire:key="post-{{ $post->id }}">
<flux:cell>
<div>
<div class="font-medium text-gray-900">
{{ $post->title }}
</div>
@if($post->excerpt)
<div class="text-sm text-gray-500 mt-1">
{{ str_limit($post->excerpt, 60) }}
</div>
@endif
</div>
</flux:cell>
<flux:cell>
<div class="flex items-center space-x-2">
@if($post->is_published)
<flux:badge color="green" size="sm">Published</flux:badge>
@else
<flux:badge color="gray" size="sm">Draft</flux:badge>
@endif
@if($post->is_featured)
<flux:badge color="blue" size="sm">Featured</flux:badge>
@endif
</div>
</flux:cell>
<flux:cell>
{{ $post->category ?? '—' }}
</flux:cell>
<flux:cell>
{{ $post->author?->name ?? '—' }}
</flux:cell>
<flux:cell>
{{ $post->published_at?->format('M j, Y') ?? '—' }}
</flux:cell>
<flux:cell>
<div class="flex items-center space-x-2">
@if($post->is_published)
<flux:button href="{{ $post->getUrl() }}"
target="_blank"
size="sm"
variant="ghost">
View
</flux:button>
@endif
<flux:button wire:click="editPost({{ $post->id }})"
size="sm"
variant="ghost">
Edit
</flux:button>
<flux:button wire:click="duplicatePost({{ $post->id }})"
size="sm"
variant="ghost">
Duplicate
</flux:button>
<flux:button wire:click="deletePost({{ $post->id }})"
size="sm"
variant="danger">
Delete
</flux:button>
</div>
</flux:cell>
</flux:row>
@empty
<flux:row>
<flux:cell colspan="6">
<div class="text-center py-8 text-gray-500">
<flux:icon.document-text class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No blog posts found.</p>
<p class="text-sm">Create your first blog post to get started.</p>
</div>
</flux:cell>
</flux:row>
@endforelse
</flux:rows>
</flux:table>
{{-- Pagination --}}
@if($posts->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $posts->links() }}
</div>
@endif
</flux:card>
{{-- Post Form Modal --}}
<flux:modal name="post-form" class="md:w-6xl max-h-screen overflow-y-auto">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">
{{ $editingPost ? 'Edit Post' : 'Create Post' }}
</flux:heading>
<div class="flex items-center space-x-3">
@if($editingPost)
<flux:button wire:click="saveAsDraft" variant="ghost">
Save as Draft
</flux:button>
<flux:button wire:click="publishPost" variant="primary">
{{ $editingPost->is_published ? 'Update' : 'Publish' }}
</flux:button>
@else
<flux:button wire:click="saveDraft" variant="ghost">
Save Draft
</flux:button>
<flux:button wire:click="saveAndPublish" variant="primary">
Publish
</flux:button>
@endif
</div>
</div>
{{-- Language Tabs --}}
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
@foreach($availableLocales as $locale)
<button wire:click="setActiveLocale('{{ $locale }}')"
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ strtoupper($locale) }}
</button>
@endforeach
</nav>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main Content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Title & Slug --}}
<div class="space-y-4">
<flux:field>
<flux:label>Title ({{ strtoupper($activeLocale) }})</flux:label>
<flux:input wire:model.live="postForm.title.{{ $activeLocale }}"
placeholder="Enter post title" />
<flux:error name="postForm.title.{{ $activeLocale }}" />
</flux:field>
<flux:field>
<flux:label>Slug ({{ strtoupper($activeLocale) }})</flux:label>
<flux:input wire:model.live="postForm.slug.{{ $activeLocale }}"
placeholder="post-url-slug" />
<flux:error name="postForm.slug.{{ $activeLocale }}" />
</flux:field>
</div>
{{-- Excerpt --}}
<flux:field>
<flux:label>Excerpt ({{ strtoupper($activeLocale) }})</flux:label>
<flux:textarea wire:model.live="postForm.excerpt.{{ $activeLocale }}"
rows="3"
placeholder="Brief description of the post" />
<flux:description>Optional excerpt for post listings and SEO</flux:description>
<flux:error name="postForm.excerpt.{{ $activeLocale }}" />
</flux:field>
{{-- Content --}}
<flux:field>
<flux:label>Content ({{ strtoupper($activeLocale) }})</flux:label>
<div wire:ignore>
<textarea wire:model.live="postForm.content.{{ $activeLocale }}"
class="wysiwyg-editor"
rows="15"
placeholder="Write your post content here..."></textarea>
</div>
<flux:error name="postForm.content.{{ $activeLocale }}" />
</flux:field>
</div>
{{-- Sidebar --}}
<div class="space-y-6">
{{-- Publishing Options --}}
<flux:card>
<flux:card.header>
<flux:heading size="sm">Publishing</flux:heading>
</flux:card.header>
<div class="space-y-4">
<flux:field>
<flux:label>Published Date</flux:label>
<flux:input wire:model.live="postForm.published_at"
type="datetime-local" />
</flux:field>
<flux:field>
<flux:checkbox wire:model.live="postForm.is_featured">
Featured Post
</flux:checkbox>
<flux:description>Featured posts appear prominently on the site</flux:description>
</flux:field>
</div>
</flux:card>
{{-- Categories & Tags --}}
<flux:card>
<flux:card.header>
<flux:heading size="sm">Organization</flux:heading>
</flux:card.header>
<div class="space-y-4">
<flux:field>
<flux:label>Category</flux:label>
<flux:input wire:model.live="postForm.category"
placeholder="e.g., Technology, News" />
</flux:field>
<flux:field>
<flux:label>Tags</flux:label>
<flux:input wire:model.live="tagsInput"
placeholder="Enter tags separated by commas" />
<flux:description>Separate multiple tags with commas</flux:description>
</flux:field>
</div>
</flux:card>
{{-- SEO Settings --}}
<flux:card>
<flux:card.header>
<flux:heading size="sm">SEO Settings</flux:heading>
</flux:card.header>
<div class="space-y-4">
<flux:field>
<flux:label>Meta Title ({{ strtoupper($activeLocale) }})</flux:label>
<flux:input wire:model.live="postForm.meta_title.{{ $activeLocale }}"
placeholder="SEO title (defaults to post title)" />
</flux:field>
<flux:field>
<flux:label>Meta Description ({{ strtoupper($activeLocale) }})</flux:label>
<flux:textarea wire:model.live="postForm.meta_description.{{ $activeLocale }}"
rows="3"
placeholder="SEO description (defaults to excerpt)" />
</flux:field>
</div>
</flux:card>
{{-- Featured Image --}}
<flux:card>
<flux:card.header>
<flux:heading size="sm">Featured Image</flux:heading>
</flux:card.header>
<div class="space-y-4">
@if($featuredImage)
<div class="relative">
<img src="{{ $featuredImage->getUrl('thumb') }}"
alt="Featured image"
class="w-full rounded">
<button wire:click="removeFeaturedImage"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
×
</button>
</div>
@else
<flux:button wire:click="selectFeaturedImage" size="sm" class="w-full">
Select Featured Image
</flux:button>
@endif
</div>
</flux:card>
</div>
</div>
<div class="flex justify-end space-x-3 pt-6 border-t border-gray-200">
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
Cancel
</flux:button>
</div>
</div>
</flux:modal>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize WYSIWYG editor for content
// You can integrate your preferred editor here (CKEditor, TinyMCE, Quill, etc.)
});
</script>
@endpush

View file

@ -0,0 +1,253 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">
{{ $componentData ? 'Edit Component' : 'Add Component' }}
</h1>
@if($componentType)
<p class="text-sm text-gray-500 mt-1">
Component Type: {{ class_basename($componentType) }}
</p>
@endif
</div>
<div class="flex items-center space-x-3">
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
Cancel
</flux:button>
<flux:button wire:click="save" variant="primary">
{{ $componentData ? 'Update' : 'Add' }} Component
</flux:button>
</div>
</div>
{{-- Component Settings --}}
@if($componentType && $fields)
<flux:card>
<flux:card.header>
<flux:heading size="lg">Component Settings</flux:heading>
</flux:card.header>
<div class="space-y-6">
{{-- Language Tabs for Translatable Fields --}}
@if($hasTranslatableFields)
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
@foreach($availableLocales as $locale)
<button wire:click="setActiveLocale('{{ $locale }}')"
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ strtoupper($locale) }}
</button>
@endforeach
</nav>
</div>
@endif
{{-- Dynamic Fields --}}
@foreach($fields as $field)
<div class="space-y-2">
@if($field->isTranslatable() && $hasTranslatableFields)
{{-- Translatable Field --}}
<flux:field>
<flux:label>{{ $field->getLabel() }}</flux:label>
@if($field->getHelpText())
<flux:description>{{ $field->getHelpText() }}</flux:description>
@endif
@php
$fieldKey = $field->getKey();
$wireModel = "content.{$fieldKey}.{$activeLocale}";
@endphp
@switch($field->getType())
@case('text')
<flux:input wire:model.live="{{ $wireModel }}"
placeholder="{{ $field->getPlaceholder() }}" />
@break
@case('textarea')
<flux:textarea wire:model.live="{{ $wireModel }}"
placeholder="{{ $field->getPlaceholder() }}"
rows="4" />
@break
@case('wysiwyg')
<div wire:ignore>
<textarea wire:model.live="{{ $wireModel }}"
class="wysiwyg-editor"
rows="10"></textarea>
</div>
@break
@case('select')
<flux:select wire:model.live="{{ $wireModel }}">
@if($field->getEmptyOption())
<flux:option value="">{{ $field->getEmptyOption() }}</flux:option>
@endif
@foreach($field->getOptions() as $value => $label)
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
@endforeach
</flux:select>
@break
@endswitch
<flux:error name="{{ $wireModel }}" />
</flux:field>
@else
{{-- Non-translatable Field --}}
<flux:field>
<flux:label>{{ $field->getLabel() }}</flux:label>
@if($field->getHelpText())
<flux:description>{{ $field->getHelpText() }}</flux:description>
@endif
@php
$fieldKey = $field->getKey();
$wireModel = "content.{$fieldKey}";
@endphp
@switch($field->getType())
@case('text')
<flux:input wire:model.live="{{ $wireModel }}"
placeholder="{{ $field->getPlaceholder() }}" />
@break
@case('number')
<flux:input wire:model.live="{{ $wireModel }}"
type="number"
step="{{ $field->getStep() }}"
@if($field->getMin() !== null) min="{{ $field->getMin() }}" @endif
@if($field->getMax() !== null) max="{{ $field->getMax() }}" @endif
placeholder="{{ $field->getPlaceholder() }}" />
@break
@case('boolean')
@if($field->getDisplayType() === 'toggle')
<flux:switch wire:model.live="{{ $wireModel }}">
{{ $field->getTrueLabel() }}
</flux:switch>
@else
<flux:checkbox wire:model.live="{{ $wireModel }}">
{{ $field->getTrueLabel() }}
</flux:checkbox>
@endif
@break
@case('select')
@if($field->isMultiple())
<flux:select wire:model.live="{{ $wireModel }}" multiple>
@foreach($field->getOptions() as $value => $label)
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
@endforeach
</flux:select>
@else
<flux:select wire:model.live="{{ $wireModel }}">
@if($field->getEmptyOption())
<flux:option value="">{{ $field->getEmptyOption() }}</flux:option>
@endif
@foreach($field->getOptions() as $value => $label)
<flux:option value="{{ $value }}">{{ $label }}</flux:option>
@endforeach
</flux:select>
@endif
@break
@case('media')
<div class="space-y-3">
<flux:button wire:click="openMediaPicker('{{ $fieldKey }}')" size="sm">
Select {{ $field->acceptsImages() ? 'Image' : 'File' }}
</flux:button>
@if(!empty($content[$fieldKey]))
<div class="grid grid-cols-3 gap-3">
@if($field->isMultiple())
@foreach($content[$fieldKey] as $index => $mediaId)
<div class="relative">
<img src="{{ $this->getMediaUrl($mediaId) }}"
alt="Selected media"
class="w-full h-24 object-cover rounded">
<button wire:click="removeMedia('{{ $fieldKey }}', {{ $index }})"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
×
</button>
</div>
@endforeach
@else
<div class="relative">
<img src="{{ $this->getMediaUrl($content[$fieldKey]) }}"
alt="Selected media"
class="w-full h-24 object-cover rounded">
<button wire:click="removeMedia('{{ $fieldKey }}')"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs">
×
</button>
</div>
@endif
</div>
@endif
</div>
@break
@endswitch
<flux:error name="{{ $wireModel }}" />
</flux:field>
@endif
</div>
@endforeach
</div>
</flux:card>
{{-- Component Visibility Settings --}}
<flux:card>
<flux:card.header>
<flux:heading size="lg">Visibility Settings</flux:heading>
</flux:card.header>
<div class="space-y-4">
<flux:field>
<flux:checkbox wire:model.live="settings.is_active">
Component is active
</flux:checkbox>
<flux:description>Inactive components will not be displayed on the frontend</flux:description>
</flux:field>
<flux:field>
<flux:label>CSS Classes</flux:label>
<flux:input wire:model.live="settings.css_classes"
placeholder="Additional CSS classes" />
<flux:description>Custom CSS classes to apply to this component</flux:description>
</flux:field>
</div>
</flux:card>
@else
<div class="text-center py-8">
<flux:icon.exclamation-triangle class="w-12 h-12 mx-auto text-gray-400 mb-4" />
<p class="text-gray-500">No component type selected or fields defined.</p>
</div>
@endif
{{-- Media Picker Modal --}}
<flux:modal name="media-picker" class="md:w-4xl">
<div class="space-y-6">
<flux:heading size="lg">Select Media</flux:heading>
<div class="grid grid-cols-4 gap-4 max-h-96 overflow-y-auto">
{{-- Media items would be loaded here --}}
<div class="text-center py-8 col-span-4 text-gray-500">
Media picker implementation needed
</div>
</div>
</div>
</flux:modal>
</div>
@push('scripts')
<script>
// Initialize WYSIWYG editors
document.addEventListener('DOMContentLoaded', function() {
// Initialize your preferred WYSIWYG editor here
// Example: CKEditor, TinyMCE, Quill, etc.
});
</script>
@endpush

View file

@ -0,0 +1,282 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Media Manager</h1>
<p class="text-sm text-gray-500 mt-1">
Manage your media files and assets
</p>
</div>
<div class="flex items-center space-x-3">
<flux:button wire:click="$dispatch('open-upload-modal')" variant="primary">
Upload Media
</flux:button>
</div>
</div>
{{-- Filters & Search --}}
<flux:card>
<div class="flex items-center space-x-4">
<div class="flex-1">
<flux:input wire:model.live="search"
placeholder="Search media files..."
class="w-full" />
</div>
<flux:select wire:model.live="filterType" placeholder="All Types">
<flux:option value="">All Types</flux:option>
<flux:option value="image">Images</flux:option>
<flux:option value="document">Documents</flux:option>
<flux:option value="video">Videos</flux:option>
<flux:option value="audio">Audio</flux:option>
</flux:select>
<flux:select wire:model.live="sortBy">
<flux:option value="created_at">Upload Date</flux:option>
<flux:option value="name">Name</flux:option>
<flux:option value="size">Size</flux:option>
</flux:select>
<flux:select wire:model.live="sortDirection">
<flux:option value="desc">Newest First</flux:option>
<flux:option value="asc">Oldest First</flux:option>
</flux:select>
</div>
</flux:card>
{{-- Media Grid --}}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
@forelse($mediaItems as $media)
<div wire:key="media-{{ $media->id }}"
class="group relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow">
{{-- Media Preview --}}
<div class="aspect-square bg-gray-100 flex items-center justify-center">
@if($media->hasGeneratedConversion('thumb'))
<img src="{{ $media->getUrl('thumb') }}"
alt="{{ $media->name }}"
class="w-full h-full object-cover">
@elseif(str_starts_with($media->mime_type, 'image/'))
<img src="{{ $media->getUrl() }}"
alt="{{ $media->name }}"
class="w-full h-full object-cover">
@else
<div class="text-center p-4">
@switch(explode('/', $media->mime_type)[0])
@case('video')
<flux:icon.film class="w-8 h-8 mx-auto text-gray-400 mb-2" />
@break
@case('audio')
<flux:icon.musical-note class="w-8 h-8 mx-auto text-gray-400 mb-2" />
@break
@default
<flux:icon.document class="w-8 h-8 mx-auto text-gray-400 mb-2" />
@endswitch
<span class="text-xs text-gray-500 font-medium">
{{ strtoupper(pathinfo($media->name, PATHINFO_EXTENSION)) }}
</span>
</div>
@endif
</div>
{{-- Media Info --}}
<div class="p-3">
<h3 class="text-sm font-medium text-gray-900 truncate"
title="{{ $media->name }}">
{{ $media->name }}
</h3>
<p class="text-xs text-gray-500 mt-1">
{{ $this->formatFileSize($media->size) }}
</p>
</div>
{{-- Actions Overlay --}}
<div class="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center space-x-2">
<flux:button wire:click="viewMedia({{ $media->id }})"
size="sm"
variant="white">
View
</flux:button>
<flux:button wire:click="editMedia({{ $media->id }})"
size="sm"
variant="white">
Edit
</flux:button>
<flux:button wire:click="deleteMedia({{ $media->id }})"
size="sm"
variant="danger">
Delete
</flux:button>
</div>
{{-- Selection Checkbox --}}
@if($selectionMode)
<div class="absolute top-2 left-2">
<flux:checkbox wire:model.live="selectedMedia"
value="{{ $media->id }}"
class="bg-white" />
</div>
@endif
</div>
@empty
<div class="col-span-full text-center py-12">
<flux:icon.photo class="w-12 h-12 mx-auto text-gray-300 mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">No media files</h3>
<p class="text-gray-500 mb-4">Upload your first media file to get started.</p>
<flux:button wire:click="$dispatch('open-upload-modal')" variant="primary">
Upload Media
</flux:button>
</div>
@endforelse
</div>
{{-- Pagination --}}
@if($mediaItems->hasPages())
<div class="flex justify-center">
{{ $mediaItems->links() }}
</div>
@endif
{{-- Upload Modal --}}
<flux:modal name="upload-modal" class="md:w-2xl">
<div class="space-y-6">
<flux:heading size="lg">Upload Media</flux:heading>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center"
x-data="{
isDragging: false,
handleDrop(e) {
this.isDragging = false;
// Handle file drop
}
}"
x-on:dragover.prevent="isDragging = true"
x-on:dragleave.prevent="isDragging = false"
x-on:drop.prevent="handleDrop"
:class="{ 'border-blue-400 bg-blue-50': isDragging }">
<flux:icon.cloud-arrow-up class="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Upload files</h3>
<p class="text-gray-500 mb-4">Drag and drop files here or click to browse</p>
<flux:button wire:click="triggerFileInput" variant="primary">
Choose Files
</flux:button>
<input type="file"
wire:model="uploadFiles"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
class="hidden"
id="file-input">
</div>
{{-- Upload Progress --}}
@if($uploading)
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Uploading...</span>
<span class="text-sm text-gray-500">{{ $uploadProgress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {{ $uploadProgress }}%"></div>
</div>
</div>
@endif
</div>
</flux:modal>
{{-- Media Detail Modal --}}
<flux:modal name="media-detail" class="md:w-4xl">
@if($viewingMedia)
<div class="space-y-6">
<flux:heading size="lg">{{ $viewingMedia->name }}</flux:heading>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Media Preview --}}
<div class="space-y-4">
@if(str_starts_with($viewingMedia->mime_type, 'image/'))
<img src="{{ $viewingMedia->getUrl() }}"
alt="{{ $viewingMedia->name }}"
class="w-full rounded-lg">
@else
<div class="bg-gray-100 rounded-lg p-8 text-center">
<flux:icon.document class="w-16 h-16 mx-auto text-gray-400 mb-4" />
<p class="text-gray-600">{{ $viewingMedia->name }}</p>
</div>
@endif
<flux:button href="{{ $viewingMedia->getUrl() }}"
target="_blank"
variant="outline"
class="w-full">
Download Original
</flux:button>
</div>
{{-- Media Details --}}
<div class="space-y-4">
<flux:field>
<flux:label>File Name</flux:label>
<flux:input wire:model.live="editingMedia.name" />
</flux:field>
<flux:field>
<flux:label>Alt Text</flux:label>
<flux:input wire:model.live="editingMedia.alt_text"
placeholder="Description for accessibility" />
</flux:field>
<flux:field>
<flux:label>Caption</flux:label>
<flux:textarea wire:model.live="editingMedia.caption"
rows="3"
placeholder="Optional caption" />
</flux:field>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-700">File Type:</span>
<span class="text-gray-600">{{ $viewingMedia->mime_type }}</span>
</div>
<div>
<span class="font-medium text-gray-700">File Size:</span>
<span class="text-gray-600">{{ $this->formatFileSize($viewingMedia->size) }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Uploaded:</span>
<span class="text-gray-600">{{ $viewingMedia->created_at->format('M j, Y') }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Collection:</span>
<span class="text-gray-600">{{ $viewingMedia->collection_name }}</span>
</div>
</div>
<div class="flex space-x-3 pt-4">
<flux:button wire:click="updateMedia" variant="primary">
Update
</flux:button>
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
Cancel
</flux:button>
</div>
</div>
</div>
</div>
@endif
</flux:modal>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// File input trigger
window.addEventListener('triggerFileInput', function() {
document.getElementById('file-input').click();
});
});
</script>
@endpush

View file

@ -0,0 +1,229 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Navigation Manager</h1>
<p class="text-sm text-gray-500 mt-1">
Manage site navigation and menu structures
</p>
</div>
<div class="flex items-center space-x-3">
<flux:button wire:click="createNavigation" variant="primary">
Create Navigation
</flux:button>
</div>
</div>
{{-- Navigation Selector --}}
<flux:card>
<div class="flex items-center space-x-4">
<div class="flex-1">
<flux:select wire:model.live="selectedNavigationId" placeholder="Select Navigation">
@foreach($navigations as $nav)
<flux:option value="{{ $nav->id }}">{{ $nav->display_name }}</flux:option>
@endforeach
</flux:select>
</div>
@if($selectedNavigationId)
<flux:button wire:click="editNavigation({{ $selectedNavigationId }})" size="sm">
Edit Navigation
</flux:button>
<flux:button wire:click="deleteNavigation({{ $selectedNavigationId }})"
variant="danger" size="sm">
Delete
</flux:button>
@endif
</div>
</flux:card>
{{-- Navigation Items --}}
@if($selectedNavigationId && $navigationItems)
<flux:card>
<flux:card.header>
<div class="flex items-center justify-between">
<flux:heading size="lg">Navigation Items</flux:heading>
<flux:button wire:click="addNavigationItem" size="sm">
Add Item
</flux:button>
</div>
</flux:card.header>
<div class="space-y-2" wire:sortable="updateItemOrder">
@forelse($navigationItems as $item)
<div wire:sortable.item="{{ $item->id }}"
wire:key="nav-item-{{ $item->id }}"
class="nested-sortable-item">
@include('flux-cms-components::partials.navigation-item', ['item' => $item, 'level' => 0])
</div>
@empty
<div class="text-center py-8 text-gray-500">
<flux:icon.bars-3 class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No navigation items yet.</p>
<p class="text-sm">Click "Add Item" to create your first navigation item.</p>
</div>
@endforelse
</div>
</flux:card>
@endif
{{-- Navigation Form Modal --}}
<flux:modal name="navigation-form" class="md:w-2xl">
<div class="space-y-6">
<flux:heading size="lg">
{{ $editingNavigation ? 'Edit Navigation' : 'Create Navigation' }}
</flux:heading>
<div class="space-y-4">
<flux:field>
<flux:label>Name</flux:label>
<flux:input wire:model.live="navigationForm.name"
placeholder="main-menu" />
<flux:description>Unique identifier for this navigation (lowercase, no spaces)</flux:description>
<flux:error name="navigationForm.name" />
</flux:field>
<flux:field>
<flux:label>Display Name</flux:label>
<flux:input wire:model.live="navigationForm.display_name"
placeholder="Main Menu" />
<flux:description>Human-readable name for this navigation</flux:description>
<flux:error name="navigationForm.display_name" />
</flux:field>
<flux:field>
<flux:checkbox wire:model.live="navigationForm.is_active">
Active
</flux:checkbox>
<flux:description>Inactive navigations will not be displayed</flux:description>
</flux:field>
</div>
<div class="flex justify-end space-x-3">
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
Cancel
</flux:button>
<flux:button wire:click="saveNavigation" variant="primary">
{{ $editingNavigation ? 'Update' : 'Create' }}
</flux:button>
</div>
</div>
</flux:modal>
{{-- Navigation Item Form Modal --}}
<flux:modal name="navigation-item-form" class="md:w-2xl">
<div class="space-y-6">
<flux:heading size="lg">
{{ $editingItem ? 'Edit Navigation Item' : 'Add Navigation Item' }}
</flux:heading>
{{-- Language Tabs --}}
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
@foreach($availableLocales as $locale)
<button wire:click="setActiveLocale('{{ $locale }}')"
class="py-2 px-1 border-b-2 font-medium text-sm {{ $activeLocale === $locale ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ strtoupper($locale) }}
</button>
@endforeach
</nav>
</div>
<div class="space-y-4">
{{-- Title (Translatable) --}}
<flux:field>
<flux:label>Title ({{ strtoupper($activeLocale) }})</flux:label>
<flux:input wire:model.live="itemForm.title.{{ $activeLocale }}"
placeholder="Navigation item title" />
<flux:error name="itemForm.title.{{ $activeLocale }}" />
</flux:field>
{{-- Link Type --}}
<flux:field>
<flux:label>Link Type</flux:label>
<flux:select wire:model.live="itemForm.link_type">
<flux:option value="page">Page</flux:option>
<flux:option value="url">Custom URL</flux:option>
<flux:option value="none">No Link</flux:option>
</flux:select>
</flux:field>
{{-- Page Selection --}}
@if($itemForm['link_type'] === 'page')
<flux:field>
<flux:label>Select Page</flux:label>
<flux:select wire:model.live="itemForm.page_id" placeholder="Choose a page">
@foreach($availablePages as $page)
<flux:option value="{{ $page->id }}">{{ $page->title }}</flux:option>
@endforeach
</flux:select>
<flux:error name="itemForm.page_id" />
</flux:field>
@endif
{{-- Custom URL --}}
@if($itemForm['link_type'] === 'url')
<flux:field>
<flux:label>URL</flux:label>
<flux:input wire:model.live="itemForm.url"
placeholder="https://example.com" />
<flux:error name="itemForm.url" />
</flux:field>
<flux:field>
<flux:label>Target</flux:label>
<flux:select wire:model.live="itemForm.target">
<flux:option value="_self">Same Window</flux:option>
<flux:option value="_blank">New Window</flux:option>
</flux:select>
</flux:field>
@endif
{{-- Parent Item --}}
<flux:field>
<flux:label>Parent Item</flux:label>
<flux:select wire:model.live="itemForm.parent_id" placeholder="No parent (top level)">
@foreach($this->getParentOptions() as $option)
<flux:option value="{{ $option['id'] }}">
{{ str_repeat('— ', $option['level']) }}{{ $option['title'] }}
</flux:option>
@endforeach
</flux:select>
<flux:description>Choose a parent to create a sub-menu item</flux:description>
</flux:field>
{{-- Settings --}}
<flux:field>
<flux:checkbox wire:model.live="itemForm.is_active">
Active
</flux:checkbox>
<flux:description>Inactive items will not be displayed</flux:description>
</flux:field>
</div>
<div class="flex justify-end space-x-3">
<flux:button wire:click="$dispatch('close-modal')" variant="ghost">
Cancel
</flux:button>
<flux:button wire:click="saveNavigationItem" variant="primary">
{{ $editingItem ? 'Update' : 'Add' }}
</flux:button>
</div>
</div>
</flux:modal>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize nested sortable
const initializeNestedSortable = () => {
// Implementation for nested drag & drop would go here
// You might want to use a library like SortableJS with nested support
};
initializeNestedSortable();
});
</script>
@endpush

View file

@ -0,0 +1,169 @@
<div class="space-y-6">
{{-- Page Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">
{{ $editingPage ? 'Edit Page' : 'Create Page' }}
</h1>
@if($editingPage)
<p class="text-sm text-gray-500 mt-1">
Last updated {{ $editingPage->updated_at->diffForHumans() }}
</p>
@endif
</div>
<div class="flex items-center space-x-3">
@if($editingPage && $editingPage->is_published)
<flux:badge color="green" size="sm">Published</flux:badge>
@elseif($editingPage)
<flux:badge color="yellow" size="sm">Draft</flux:badge>
@endif
<flux:button wire:click="save" variant="primary">
{{ $editingPage ? 'Update' : 'Create' }} Page
</flux:button>
</div>
</div>
{{-- Page Settings --}}
<flux:card>
<flux:card.header>
<flux:heading size="lg">Page Settings</flux:heading>
</flux:card.header>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Basic Information --}}
<div class="space-y-4">
<flux:field>
<flux:label>Title</flux:label>
<flux:input wire:model.live="pageData.title" placeholder="Enter page title" />
<flux:error name="pageData.title" />
</flux:field>
<flux:field>
<flux:label>Slug</flux:label>
<flux:input wire:model.live="pageData.slug" placeholder="page-url-slug" />
<flux:error name="pageData.slug" />
</flux:field>
<flux:field>
<flux:label>Template</flux:label>
<flux:select wire:model.live="pageData.template" placeholder="Select template">
@foreach($availableTemplates as $template)
<flux:option value="{{ $template }}">{{ $template }}</flux:option>
@endforeach
</flux:select>
<flux:error name="pageData.template" />
</flux:field>
</div>
{{-- SEO & Publishing --}}
<div class="space-y-4">
<flux:field>
<flux:label>Meta Title</flux:label>
<flux:input wire:model.live="pageData.meta_title" placeholder="SEO title" />
<flux:error name="pageData.meta_title" />
</flux:field>
<flux:field>
<flux:label>Meta Description</flux:label>
<flux:textarea wire:model.live="pageData.meta_description" placeholder="SEO description" rows="3" />
<flux:error name="pageData.meta_description" />
</flux:field>
<div class="flex items-center space-x-4">
<flux:field>
<flux:checkbox wire:model.live="pageData.is_published">
Published
</flux:checkbox>
</flux:field>
<flux:field>
<flux:checkbox wire:model.live="pageData.show_in_navigation">
Show in Navigation
</flux:checkbox>
</flux:field>
</div>
</div>
</div>
</flux:card>
{{-- Page Components --}}
<flux:card>
<flux:card.header>
<div class="flex items-center justify-between">
<flux:heading size="lg">Page Components</flux:heading>
<flux:button wire:click="$dispatch('open-component-modal')" size="sm">
Add Component
</flux:button>
</div>
</flux:card.header>
<div class="space-y-4" wire:sortable="updateComponentOrder">
@forelse($components as $index => $component)
<div wire:sortable.item="{{ $component['id'] }}"
wire:key="component-{{ $component['id'] }}"
class="p-4 border border-gray-200 rounded-lg bg-gray-50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<flux:icon.bars-3 class="w-5 h-5 text-gray-400 cursor-move" wire:sortable.handle />
<span class="font-medium text-gray-900">
{{ $component['component_type'] }}
</span>
@if(!$component['is_active'])
<flux:badge color="gray" size="sm">Inactive</flux:badge>
@endif
</div>
<div class="flex items-center space-x-2">
<flux:button wire:click="editComponent({{ $index }})" size="sm" variant="ghost">
Edit
</flux:button>
<flux:button wire:click="toggleComponent({{ $index }})" size="sm" variant="ghost">
{{ $component['is_active'] ? 'Disable' : 'Enable' }}
</flux:button>
<flux:button wire:click="removeComponent({{ $index }})" size="sm" variant="danger">
Remove
</flux:button>
</div>
</div>
{{-- Component Preview --}}
<div class="text-sm text-gray-600">
@if(!empty($component['content']['title']))
<strong>Title:</strong> {{ $component['content']['title'] }}
@endif
</div>
</div>
@empty
<div class="text-center py-8 text-gray-500">
<flux:icon.puzzle-piece class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No components added yet.</p>
<p class="text-sm">Click "Add Component" to get started.</p>
</div>
@endforelse
</div>
</flux:card>
{{-- Component Selection Modal --}}
<flux:modal name="component-modal" class="md:w-2xl">
<div class="space-y-6">
<flux:heading size="lg">Add Component</flux:heading>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach($availableComponents as $componentClass => $componentInfo)
<div wire:click="addComponent('{{ $componentClass }}')"
class="p-4 border border-gray-200 rounded-lg cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors">
<h3 class="font-medium text-gray-900 mb-2">
{{ $componentInfo['name'] }}
</h3>
<p class="text-sm text-gray-600">
{{ $componentInfo['description'] ?? 'No description available' }}
</p>
</div>
@endforeach
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,226 @@
<div class="flux-cms-blog-list">
{{-- Header --}}
@if($showHeader)
<div class="blog-header mb-8">
@if($title)
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ $title }}</h1>
@endif
@if($description)
<p class="text-lg text-gray-600">{{ $description }}</p>
@endif
</div>
@endif
{{-- Filters --}}
@if($showFilters)
<div class="blog-filters mb-8 space-y-4">
<div class="flex flex-wrap gap-4">
{{-- Search --}}
<div class="flex-1 min-w-64">
<input type="text"
wire:model.live="search"
placeholder="Search posts..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- Category Filter --}}
@if($categories->isNotEmpty())
<select wire:model.live="selectedCategory"
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category }}">{{ $category }}</option>
@endforeach
</select>
@endif
{{-- Tag Filter --}}
@if($tags->isNotEmpty())
<select wire:model.live="selectedTag"
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">All Tags</option>
@foreach($tags as $tag)
<option value="{{ $tag }}">{{ $tag }}</option>
@endforeach
</select>
@endif
</div>
{{-- Active Filters --}}
@if($search || $selectedCategory || $selectedTag)
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">Active filters:</span>
@if($search)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Search: "{{ $search }}"
<button wire:click="$set('search', '')" class="ml-1 text-blue-600 hover:text-blue-800">×</button>
</span>
@endif
@if($selectedCategory)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Category: {{ $selectedCategory }}
<button wire:click="$set('selectedCategory', '')" class="ml-1 text-green-600 hover:text-green-800">×</button>
</span>
@endif
@if($selectedTag)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Tag: {{ $selectedTag }}
<button wire:click="$set('selectedTag', '')" class="ml-1 text-purple-600 hover:text-purple-800">×</button>
</span>
@endif
</div>
@endif
</div>
@endif
{{-- Posts Grid --}}
<div class="blog-posts">
@if($posts->isNotEmpty())
<div class="grid gap-8 {{ $this->getGridClasses() }}">
@foreach($posts as $post)
<article class="blog-post-card bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
wire:key="post-{{ $post->id }}">
{{-- Featured Image --}}
@if($showFeaturedImages && $post->getFeaturedImage())
<div class="aspect-w-16 aspect-h-9">
<img src="{{ $post->getFeaturedImageUrl('hero') }}"
alt="{{ $post->getTranslation('title', app()->getLocale()) }}"
class="w-full h-48 object-cover">
</div>
@endif
<div class="p-6">
{{-- Meta Information --}}
@if($showMeta)
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-3">
@if($showDate)
<time datetime="{{ $post->published_at->toISOString() }}">
{{ $post->published_at->format('M j, Y') }}
</time>
@endif
@if($showAuthor && $post->author)
<span>By {{ $post->author->name }}</span>
@endif
@if($showCategory && $post->category)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ $post->category }}
</span>
@endif
@if($showReadingTime)
<span>{{ $post->reading_time }} min read</span>
@endif
</div>
@endif
{{-- Title --}}
<h2 class="text-xl font-semibold text-gray-900 mb-3">
<a href="{{ $post->getUrl() }}"
class="hover:text-blue-600 transition-colors">
{{ $post->getTranslation('title', app()->getLocale()) }}
</a>
</h2>
{{-- Excerpt --}}
@if($showExcerpts && $post->excerpt)
<p class="text-gray-600 mb-4 line-clamp-3">
{{ $post->getTranslation('excerpt', app()->getLocale()) }}
</p>
@endif
{{-- Tags --}}
@if($showTags && $post->tags && count($post->tags) > 0)
<div class="flex flex-wrap gap-2 mb-4">
@foreach($post->tags as $tag)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ $tag }}
</span>
@endforeach
</div>
@endif
{{-- Read More Link --}}
@if($showReadMore)
<a href="{{ $post->getUrl() }}"
class="inline-flex items-center text-blue-600 hover:text-blue-800 font-medium">
{{ $readMoreText }}
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
@endif
{{-- Featured Badge --}}
@if($post->is_featured)
<div class="absolute top-4 right-4">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Featured
</span>
</div>
@endif
</div>
</article>
@endforeach
</div>
{{-- Pagination --}}
@if($posts->hasPages() && $showPagination)
<div class="mt-8 flex justify-center">
{{ $posts->links() }}
</div>
@endif
@else
{{-- Empty State --}}
<div class="text-center py-12">
<div class="max-w-md mx-auto">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts found</h3>
<p class="text-gray-500">
@if($search || $selectedCategory || $selectedTag)
Try adjusting your filters to find what you're looking for.
@else
There are no published blog posts yet.
@endif
</p>
</div>
</div>
@endif
</div>
</div>
{{-- Styles --}}
@pushOnce('styles')
<style>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aspect-w-16 {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
}
.aspect-w-16 img {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>
@endPushOnce

View file

@ -0,0 +1,339 @@
<article class="flux-cms-blog-post max-w-4xl mx-auto">
{{-- Post Header --}}
<header class="blog-post-header mb-8">
{{-- Featured Image --}}
@if($post->getFeaturedImage())
<div class="aspect-w-16 aspect-h-9 mb-8">
<img src="{{ $post->getFeaturedImageUrl('hero') }}"
alt="{{ $post->getTranslation('title', app()->getLocale()) }}"
class="w-full h-64 md:h-96 object-cover rounded-lg">
</div>
@endif
{{-- Category & Featured Badge --}}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
@if($post->category)
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{{ $post->category }}
</span>
@endif
@if($post->is_featured)
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
Featured
</span>
@endif
</div>
{{-- Share Buttons --}}
@if($showShareButtons)
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">Share:</span>
<a href="https://twitter.com/intent/tweet?url={{ urlencode(request()->url()) }}&text={{ urlencode($post->getTranslation('title', app()->getLocale())) }}"
target="_blank"
class="text-gray-400 hover:text-blue-500 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(request()->url()) }}"
target="_blank"
class="text-gray-400 hover:text-blue-600 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ urlencode(request()->url()) }}"
target="_blank"
class="text-gray-400 hover:text-blue-700 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
@endif
</div>
{{-- Title --}}
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-gray-900 leading-tight mb-6">
{{ $post->getTranslation('title', app()->getLocale()) }}
</h1>
{{-- Meta Information --}}
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-6">
@if($showAuthor && $post->author)
<div class="flex items-center space-x-2">
@if($post->author->profile_photo_url ?? false)
<img src="{{ $post->author->profile_photo_url }}"
alt="{{ $post->author->name }}"
class="w-8 h-8 rounded-full">
@endif
<span>By <strong>{{ $post->author->name }}</strong></span>
</div>
@endif
@if($showDate)
<time datetime="{{ $post->published_at->toISOString() }}"
class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span>{{ $post->published_at->format('F j, Y') }}</span>
</time>
@endif
@if($showReadingTime)
<div class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>{{ $post->reading_time }} min read</span>
</div>
@endif
@if($showUpdatedDate && $post->updated_at->gt($post->published_at))
<div class="flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Updated {{ $post->updated_at->format('F j, Y') }}</span>
</div>
@endif
</div>
{{-- Excerpt --}}
@if($showExcerpt && $post->excerpt)
<div class="text-lg text-gray-600 leading-relaxed border-l-4 border-blue-500 pl-6 italic">
{{ $post->getTranslation('excerpt', app()->getLocale()) }}
</div>
@endif
</header>
{{-- Post Content --}}
<div class="blog-post-content prose prose-lg max-w-none">
{!! $post->getTranslation('content', app()->getLocale()) !!}
</div>
{{-- Tags --}}
@if($showTags && $post->tags && count($post->tags) > 0)
<div class="mt-8 pt-8 border-t border-gray-200">
<h3 class="text-sm font-medium text-gray-900 mb-3">Tags</h3>
<div class="flex flex-wrap gap-2">
@foreach($post->tags as $tag)
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors">
#{{ $tag }}
</span>
@endforeach
</div>
</div>
@endif
{{-- Author Bio --}}
@if($showAuthorBio && $post->author)
<div class="mt-12 pt-8 border-t border-gray-200">
<div class="flex items-start space-x-4">
@if($post->author->profile_photo_url ?? false)
<img src="{{ $post->author->profile_photo_url }}"
alt="{{ $post->author->name }}"
class="w-16 h-16 rounded-full">
@endif
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ $post->author->name }}</h3>
@if($post->author->bio ?? false)
<p class="text-gray-600 mt-1">{{ $post->author->bio }}</p>
@endif
</div>
</div>
</div>
@endif
{{-- Navigation to Other Posts --}}
@if($showNavigation && ($previousPost || $nextPost))
<nav class="mt-12 pt-8 border-t border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@if($previousPost)
<a href="{{ $previousPost->getUrl() }}" class="group">
<div class="flex items-center space-x-3 p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
<svg class="w-5 h-5 text-gray-400 group-hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
<div>
<div class="text-sm text-gray-500">Previous</div>
<div class="font-medium text-gray-900 group-hover:text-blue-600">
{{ str_limit($previousPost->getTranslation('title', app()->getLocale()), 50) }}
</div>
</div>
</div>
</a>
@endif
@if($nextPost)
<a href="{{ $nextPost->getUrl() }}" class="group">
<div class="flex items-center justify-end space-x-3 p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
<div class="text-right">
<div class="text-sm text-gray-500">Next</div>
<div class="font-medium text-gray-900 group-hover:text-blue-600">
{{ str_limit($nextPost->getTranslation('title', app()->getLocale()), 50) }}
</div>
</div>
<svg class="w-5 h-5 text-gray-400 group-hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</a>
@endif
</div>
</nav>
@endif
{{-- SEO & Structured Data --}}
@pushOnce('meta')
<title>{{ $post->getSeoTitle() }}</title>
<meta name="description" content="{{ $post->getSeoDescription() }}">
<meta name="author" content="{{ $post->author?->name }}">
{{-- Open Graph --}}
<meta property="og:title" content="{{ $post->getSeoTitle() }}">
<meta property="og:description" content="{{ $post->getSeoDescription() }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ $post->getUrl() }}">
@if($post->getFeaturedImage())
<meta property="og:image" content="{{ $post->getFeaturedImageUrl('hero') }}">
@endif
<meta property="article:published_time" content="{{ $post->published_at->toISOString() }}">
<meta property="article:modified_time" content="{{ $post->updated_at->toISOString() }}">
@if($post->author)
<meta property="article:author" content="{{ $post->author->name }}">
@endif
{{-- Twitter Card --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $post->getSeoTitle() }}">
<meta name="twitter:description" content="{{ $post->getSeoDescription() }}">
@if($post->getFeaturedImage())
<meta name="twitter:image" content="{{ $post->getFeaturedImageUrl('hero') }}">
@endif
@endPushOnce
@pushOnce('structured-data')
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "{{ $post->getTranslation('title', app()->getLocale()) }}",
"description": "{{ $post->getSeoDescription() }}",
"image": "{{ $post->getFeaturedImageUrl('hero') }}",
"author": {
"@type": "Person",
"name": "{{ $post->author?->name }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ config('app.name') }}"
},
"datePublished": "{{ $post->published_at->toISOString() }}",
"dateModified": "{{ $post->updated_at->toISOString() }}",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ $post->getUrl() }}"
}
}
</script>
@endPushOnce
</article>
{{-- Styles for content --}}
@pushOnce('styles')
<style>
.aspect-w-16 {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
}
.aspect-w-16 img {
position: absolute;
height: 100%;
width: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.prose {
color: #374151;
max-width: none;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
color: #111827;
font-weight: 600;
line-height: 1.25;
}
.prose h2 {
font-size: 1.875rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.prose h3 {
font-size: 1.5rem;
margin-top: 1.75rem;
margin-bottom: 0.75rem;
}
.prose p {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
line-height: 1.75;
}
.prose a {
color: #2563eb;
text-decoration: underline;
}
.prose a:hover {
color: #1d4ed8;
}
.prose blockquote {
font-style: italic;
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1.5rem 0;
color: #6b7280;
}
.prose ul, .prose ol {
margin: 1.25rem 0;
padding-left: 1.25rem;
}
.prose li {
margin: 0.5rem 0;
}
.prose img {
margin: 2rem 0;
border-radius: 0.5rem;
}
.prose pre {
background-color: #f3f4f6;
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
}
.prose code {
background-color: #f3f4f6;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
</style>
@endPushOnce

View file

@ -0,0 +1,135 @@
<nav class="flux-cms-navigation" data-navigation="{{ $navigation->name }}">
@if($navigationItems->isNotEmpty())
<ul class="navigation-items {{ $cssClasses }}">
@foreach($navigationItems as $item)
<li class="navigation-item {{ $item->hasChildren() ? 'has-children' : '' }} {{ $this->isActive($item) ? 'active' : '' }}"
data-item-id="{{ $item->id }}">
{{-- Item Link --}}
@if($item->page_id || $item->url)
<a href="{{ $item->getEffectiveUrl() }}"
@if($item->target) target="{{ $item->target }}" @endif
class="navigation-link {{ $this->isActive($item) ? 'active' : '' }}">
{{ $item->getTranslation('title', app()->getLocale()) }}
</a>
@else
<span class="navigation-text">
{{ $item->getTranslation('title', app()->getLocale()) }}
</span>
@endif
{{-- Sub-navigation --}}
@if($item->hasChildren() && $showSubmenus)
<ul class="sub-navigation">
@foreach($item->children as $child)
@if($child->is_active)
<li class="sub-navigation-item {{ $this->isActive($child) ? 'active' : '' }}"
data-item-id="{{ $child->id }}">
@if($child->page_id || $child->url)
<a href="{{ $child->getEffectiveUrl() }}"
@if($child->target) target="{{ $child->target }}" @endif
class="sub-navigation-link {{ $this->isActive($child) ? 'active' : '' }}">
{{ $child->getTranslation('title', app()->getLocale()) }}
</a>
@else
<span class="sub-navigation-text">
{{ $child->getTranslation('title', app()->getLocale()) }}
</span>
@endif
{{-- Third level navigation if needed --}}
@if($child->hasChildren() && $maxDepth > 2)
<ul class="sub-sub-navigation">
@foreach($child->children as $grandchild)
@if($grandchild->is_active)
<li class="sub-sub-navigation-item {{ $this->isActive($grandchild) ? 'active' : '' }}"
data-item-id="{{ $grandchild->id }}">
@if($grandchild->page_id || $grandchild->url)
<a href="{{ $grandchild->getEffectiveUrl() }}"
@if($grandchild->target) target="{{ $grandchild->target }}" @endif
class="sub-sub-navigation-link {{ $this->isActive($grandchild) ? 'active' : '' }}">
{{ $grandchild->getTranslation('title', app()->getLocale()) }}
</a>
@else
<span class="sub-sub-navigation-text">
{{ $grandchild->getTranslation('title', app()->getLocale()) }}
</span>
@endif
</li>
@endif
@endforeach
</ul>
@endif
</li>
@endif
@endforeach
</ul>
@endif
</li>
@endforeach
</ul>
@else
{{-- Empty navigation state --}}
@if(config('app.debug'))
<div class="navigation-empty text-gray-500 text-sm">
Navigation "{{ $navigation->name }}" has no active items.
</div>
@endif
@endif
</nav>
{{-- Default Styles (can be overridden in your theme) --}}
@pushOnce('styles')
<style>
.flux-cms-navigation .navigation-items {
list-style: none;
margin: 0;
padding: 0;
}
.flux-cms-navigation .navigation-item {
position: relative;
}
.flux-cms-navigation .navigation-link,
.flux-cms-navigation .navigation-text {
display: block;
text-decoration: none;
color: inherit;
}
.flux-cms-navigation .navigation-link:hover {
color: #2563eb;
}
.flux-cms-navigation .navigation-link.active {
font-weight: 600;
color: #2563eb;
}
.flux-cms-navigation .sub-navigation,
.flux-cms-navigation .sub-sub-navigation {
list-style: none;
margin: 0;
padding: 0;
margin-left: 1rem;
}
.flux-cms-navigation .has-children > .navigation-link::after {
content: '▼';
font-size: 0.75em;
margin-left: 0.5rem;
opacity: 0.6;
}
/* Mobile-first responsive design */
@media (max-width: 768px) {
.flux-cms-navigation .sub-navigation {
margin-left: 0;
padding-left: 1rem;
}
}
</style>
@endPushOnce

View file

@ -0,0 +1,77 @@
<div class="flux-cms-page" data-page-id="{{ $page->id }}">
{{-- SEO Meta Tags (if not handled by layout) --}}
@pushOnce('meta')
<title>{{ $page->getSeoTitle() }}</title>
<meta name="description" content="{{ $page->getSeoDescription() }}">
@if($page->meta_keywords)
<meta name="keywords" content="{{ $page->meta_keywords }}">
@endif
{{-- Open Graph --}}
<meta property="og:title" content="{{ $page->getSeoTitle() }}">
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ $page->getUrl() }}">
{{-- Twitter Card --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $page->getSeoTitle() }}">
<meta name="twitter:description" content="{{ $page->getSeoDescription() }}">
@endPushOnce
{{-- Page Content --}}
<div class="space-y-8">
@forelse($components as $component)
@if($component->is_active)
<div class="flux-cms-component"
data-component-id="{{ $component->id }}"
data-component-type="{{ $component->component_type }}"
@if($component->settings['css_classes'] ?? false)
class="{{ $component->settings['css_classes'] }}"
@endif>
{{-- Render the actual Livewire component --}}
@livewire($component->component_type, [
'content' => $component->content,
'settings' => $component->settings,
'componentId' => $component->id
], key('component-'.$component->id))
</div>
@endif
@empty
{{-- Empty state for pages with no components --}}
<div class="text-center py-16">
<div class="max-w-md mx-auto">
<h1 class="text-3xl font-bold text-gray-900 mb-4">
{{ $page->title }}
</h1>
@if($page->content)
<div class="prose prose-lg text-gray-600">
{!! $page->content !!}
</div>
@else
<p class="text-gray-600">
This page doesn't have any content components yet.
</p>
@endif
</div>
</div>
@endforelse
</div>
{{-- JSON-LD Structured Data --}}
@pushOnce('structured-data')
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "{{ $page->getSeoTitle() }}",
"description": "{{ $page->getSeoDescription() }}",
"url": "{{ $page->getUrl() }}",
"dateModified": "{{ $page->updated_at->toISOString() }}",
"inLanguage": "{{ app()->getLocale() }}"
}
</script>
@endPushOnce
</div>

View file

@ -0,0 +1,92 @@
<div class="flex items-center justify-between p-3 border border-gray-200 rounded-lg bg-white {{ $level > 0 ? 'ml-6' : '' }}"
style="margin-left: {{ $level * 1.5 }}rem;">
<div class="flex items-center space-x-3 flex-1">
{{-- Drag Handle --}}
<div wire:sortable.handle class="cursor-move text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
</svg>
</div>
{{-- Item Content --}}
<div class="flex-1">
<div class="flex items-center space-x-2">
<span class="font-medium text-gray-900">
{{ $item->getTranslation('title', app()->getLocale()) }}
</span>
{{-- Status Badges --}}
@if(!$item->is_active)
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
@endif
@if($item->hasChildren())
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ $item->children->count() }} children
</span>
@endif
</div>
<div class="text-sm text-gray-500 mt-1">
@if($item->page_id && $item->page)
<span class="inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Page: {{ $item->page->title }}
</span>
@elseif($item->url)
<span class="inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.102m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
URL: {{ $item->url }}
@if($item->target === '_blank')
<svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
@endif
</span>
@else
<span class="text-gray-400">No link</span>
@endif
</div>
</div>
</div>
{{-- Actions --}}
<div class="flex items-center space-x-2">
<button wire:click="editNavigationItem({{ $item->id }})"
class="text-gray-400 hover:text-blue-600 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button wire:click="addChildItem({{ $item->id }})"
class="text-gray-400 hover:text-green-600 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</button>
<button wire:click="deleteNavigationItem({{ $item->id }})"
class="text-gray-400 hover:text-red-600 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
{{-- Render children recursively --}}
@if($item->children->isNotEmpty() && $level < 3)
<div class="mt-2 space-y-2">
@foreach($item->children as $child)
@include('flux-cms-components::partials.navigation-item', ['item' => $child, 'level' => $level + 1])
@endforeach
</div>
@endif

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

View file

@ -0,0 +1,38 @@
<?php
namespace FluxCms\Components\Tests\Feature\Backend;
use Livewire\Livewire;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Components\Livewire\Backend\BlogEditor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class BlogEditorTest extends TestCase
{
use RefreshDatabase;
public function test_can_render_blog_editor()
{
$post = BlogPost::factory()->create();
Livewire::test(BlogEditor::class, ['post' => $post])
->assertSee($post->title);
}
public function test_can_update_post_and_sync_tags()
{
$post = BlogPost::factory()->create();
$post->attachTag('Old Tag');
Livewire::test(BlogEditor::class, ['post' => $post])
->set('post.title', 'Updated Title')
->set('tags', 'new tag 1, new tag 2')
->call('save');
$post->refresh();
$this->assertEquals('Updated Title', $post->title);
$this->assertCount(2, $post->tags);
$this->assertEquals(['new tag 1', 'new tag 2'], $post->tags->pluck('name')->toArray());
}
}