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