├── stubs ├── .gitkeep ├── livewire │ ├── views │ │ ├── elements │ │ │ ├── text-element.blade.php │ │ │ ├── image-element.blade.php │ │ │ └── video-element.blade.php │ │ ├── pages │ │ │ └── show.blade.php │ │ └── page.blade.php │ ├── elements │ │ ├── TextElement.php │ │ ├── ImageElement.php │ │ └── VideoElement.php │ └── Page.php ├── vue │ ├── elements │ │ ├── TextElement.vue │ │ ├── ImageElement.vue │ │ └── VideoElement.vue │ └── Page.vue └── controllers │ ├── LivewirePageController.php.stub │ └── InertiaPageController.php.stub ├── resources ├── views │ ├── .gitkeep │ ├── pages │ │ ├── filamentor.blade.php │ │ └── builder.blade.php │ └── elements │ │ ├── text.blade.php │ │ ├── video.blade.php │ │ └── image.blade.php ├── lang │ └── en │ │ └── filamentor.php ├── page-resource │ └── pages │ │ ├── edit-page.blade.php │ │ └── element-editor.blade.php ├── js │ ├── store.js │ └── dragHandlers.js └── css │ └── filamentor.css ├── src ├── Filamentor.php ├── Testing │ └── TestsFilamentor.php ├── Contracts │ └── ElementInterface.php ├── Resources │ ├── PageResource │ │ └── Pages │ │ │ ├── CreatePage.php │ │ │ ├── ListPages.php │ │ │ └── EditPage.php │ └── PageResource.php ├── Facades │ └── Filamentor.php ├── Commands │ ├── FilamentorCommand.php │ └── InstallFilamentor.php ├── Pages │ └── Filamentor.php ├── Support │ └── ElementRegistry.php ├── Elements │ ├── Video.php │ ├── Text.php │ └── Image.php ├── FilamentorPlugin.php ├── Models │ └── Page.php └── FilamentorServiceProvider.php ├── config └── filamentor.php ├── postcss.config.cjs ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_filamentor_table.php.stub ├── vite.config.js ├── CHANGELOG.md ├── LICENSE.md ├── bin └── build.js ├── dist ├── filamentor.css ├── filamentor.umd.cjs └── alpine-sort.js ├── composer.json └── README.md /stubs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Filamentor.php: -------------------------------------------------------------------------------- 1 | 2 | {!! $content['text'] !!} 3 | 4 | -------------------------------------------------------------------------------- /stubs/livewire/views/pages/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |
-------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /resources/views/pages/filamentor.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Welcome to Filamentor3!

4 |

Your page builder is ready.

5 |
6 |
7 | -------------------------------------------------------------------------------- /stubs/livewire/views/elements/image-element.blade.php: -------------------------------------------------------------------------------- 1 |
2 | {{ $content['alt'] }} 5 |
6 | -------------------------------------------------------------------------------- /resources/views/elements/text.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ $content ?? 'Add your text here' }} 4 |
5 |
6 | -------------------------------------------------------------------------------- /src/Testing/TestsFilamentor.php: -------------------------------------------------------------------------------- 1 | 2 | defineProps({ 3 | content: { 4 | type: Object, 5 | required: true 6 | } 7 | }) 8 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /stubs/vue/elements/ImageElement.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /src/Contracts/ElementInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /resources/views/elements/image.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @if(isset($image) && $image) 3 | {{ $alt ?? '' }} 4 | @else 5 |
6 | Add an image 7 |
8 | @endif 9 |
10 | -------------------------------------------------------------------------------- /resources/page-resource/pages/edit-page.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $this->form }} 4 | 5 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Commands/FilamentorCommand.php: -------------------------------------------------------------------------------- 1 | comment('All done'); 16 | 17 | return self::SUCCESS; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stubs/livewire/elements/TextElement.php: -------------------------------------------------------------------------------- 1 | content = $content; 14 | } 15 | 16 | public function render() 17 | { 18 | return view('livewire.elements.text-element'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stubs/livewire/elements/ImageElement.php: -------------------------------------------------------------------------------- 1 | content = $content; 14 | } 15 | 16 | public function render() 17 | { 18 | return view('livewire.elements.image-element'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stubs/livewire/elements/VideoElement.php: -------------------------------------------------------------------------------- 1 | content = $content; 14 | } 15 | 16 | public function render() 17 | { 18 | return view('livewire.elements.video-element'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Pages/Filamentor.php: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | build: { 5 | lib: { 6 | entry: { 7 | 'filamentor': 'resources/js/filamentor.js', 8 | }, 9 | name: 'filamentor', 10 | fileName: 'filamentor' 11 | }, 12 | outDir: 'dist', 13 | cssCodeSplit: true 14 | }, 15 | css: { 16 | extract: true 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /stubs/controllers/LivewirePageController.php.stub: -------------------------------------------------------------------------------- 1 | firstOrFail(); 12 | 13 | if (!$page->is_published) { 14 | abort(404); 15 | } 16 | 17 | return view('pages.show', compact('page')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Resources/PageResource/Pages/ListPages.php: -------------------------------------------------------------------------------- 1 | 2 | @if(str_contains($activeElementType, 'Text')) 3 | 4 | {{ $this->form }} 5 | 6 | @else 7 | 15 | @endif 16 | 17 | -------------------------------------------------------------------------------- /src/Support/ElementRegistry.php: -------------------------------------------------------------------------------- 1 | elements[$elementClass])) { 12 | $this->elements[$elementClass] = new $elementClass(); 13 | } 14 | } 15 | 16 | public function getElements(): array 17 | { 18 | return $this->elements; 19 | } 20 | 21 | public function getElement(string $elementClass) 22 | { 23 | return $this->elements[$elementClass] ?? null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `filamentor` will be documented in this file. 4 | 5 | ## [1.1.0] - 2025-05-30 6 | ### Added 7 | - Enhanced page builder to allow dragging and dropping columns between different rows. 8 | - Implemented new drag-and-drop handlers and Alpine.js store logic for cross-row column movement. 9 | - Added CSS for visual drop indicators for columns between rows. 10 | 11 | ### Changed 12 | - Updated Blade templates for the page builder UI to support new column drag-and-drop functionality. 13 | - Refactored column reordering logic in the Alpine.js store. 14 | 15 | ## 1.0.0 - 2025-02-06 16 | 17 | - Initial release 18 | -------------------------------------------------------------------------------- /stubs/vue/elements/VideoElement.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | -------------------------------------------------------------------------------- /src/Elements/Video.php: -------------------------------------------------------------------------------- 1 | [ 28 | 'type' => 'text', 29 | 'label' => 'YouTube URL', 30 | 'default' => '' 31 | ] 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Elements/Text.php: -------------------------------------------------------------------------------- 1 | render(); 12 | } 13 | 14 | public function getIcon(): string 15 | { 16 | return 'heroicon-o-document-text'; 17 | } 18 | 19 | public function getName(): string 20 | { 21 | return 'Text'; 22 | } 23 | 24 | public function getSettings(): array 25 | { 26 | return [ 27 | 'content' => [ 28 | 'type' => 'textarea', 29 | 'label' => 'Content', 30 | 'default' => '' 31 | ] 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/FilamentorPlugin.php: -------------------------------------------------------------------------------- 1 | resources([ 19 | Resources\PageResource::class, 20 | ]); 21 | } 22 | 23 | public function boot(Panel $panel): void 24 | { 25 | // 26 | } 27 | 28 | public static function make(): static 29 | { 30 | return app(static::class); 31 | } 32 | 33 | public static function get(): static 34 | { 35 | /** @var static $plugin */ 36 | $plugin = filament(app(static::class)->getId()); 37 | 38 | return $plugin; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /database/migrations/create_filamentor_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('title'); 14 | $table->string('slug')->unique(); 15 | $table->text('description')->nullable(); 16 | $table->json('layout')->nullable(); 17 | $table->boolean('is_published')->default(false); 18 | $table->string('meta_title')->nullable(); 19 | $table->text('meta_description')->nullable(); 20 | $table->string('og_image')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/Elements/Image.php: -------------------------------------------------------------------------------- 1 | render(); 12 | } 13 | 14 | public function getIcon(): string 15 | { 16 | return 'heroicon-o-photo'; 17 | } 18 | 19 | public function getName(): string 20 | { 21 | return 'Image'; 22 | } 23 | 24 | public function getSettings(): array 25 | { 26 | return [ 27 | 'image' => [ 28 | 'type' => 'file', 29 | 'label' => 'Image', 30 | 'default' => '' 31 | ], 32 | 'alt' => [ 33 | 'type' => 'text', 34 | 'label' => 'Alt Text', 35 | 'default' => '' 36 | ] 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /stubs/controllers/InertiaPageController.php.stub: -------------------------------------------------------------------------------- 1 | firstOrFail(); 13 | 14 | if (!$page->is_published) { 15 | abort(404); 16 | } 17 | 18 | return Inertia::render('Page', [ 19 | 'title' => $page->title, 20 | 'description' => $page->description, 21 | 'layout' => json_decode($page->layout, true), 22 | 'is_published' => $page->is_published, 23 | 'created_at' => $page->created_at, 24 | 'updated_at' => $page->updated_at, 25 | 'meta_title' => $page->meta_title, 26 | 'meta_description' => $page->meta_description, 27 | 'og_image' => $page->og_image, 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) geosem42 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /stubs/livewire/Page.php: -------------------------------------------------------------------------------- 1 | page = $page; 19 | 20 | // Decoded arrays and JSON strings 21 | if (is_string($page->layout)) { 22 | $cleanLayout = trim($page->layout, '"'); 23 | $this->content = json_decode($cleanLayout, true); 24 | } else { 25 | $this->content = $page->layout; 26 | } 27 | 28 | // Set SEO metadata 29 | $this->pageTitle = $page->meta_title ?: $page->title; 30 | $this->pageDescription = $page->meta_description ?: $page->description; 31 | $this->ogImage = $page->og_image; 32 | 33 | $this->layout = json_decode($cleanLayout, true); 34 | } 35 | 36 | public function render() 37 | { 38 | return view('livewire.page', [ 39 | 'content' => $this->layout 40 | ]); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /bin/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | 3 | const isDev = process.argv.includes('--dev') 4 | 5 | async function compile(options) { 6 | const context = await esbuild.context(options) 7 | 8 | if (isDev) { 9 | await context.watch() 10 | } else { 11 | await context.rebuild() 12 | await context.dispose() 13 | } 14 | } 15 | 16 | const defaultOptions = { 17 | define: { 18 | 'process.env.NODE_ENV': isDev ? `'development'` : `'production'`, 19 | }, 20 | bundle: true, 21 | mainFields: ['module', 'main'], 22 | platform: 'neutral', 23 | sourcemap: isDev ? 'inline' : false, 24 | sourcesContent: isDev, 25 | treeShaking: true, 26 | target: ['es2020'], 27 | minify: !isDev, 28 | plugins: [{ 29 | name: 'watchPlugin', 30 | setup: function (build) { 31 | build.onStart(() => { 32 | console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 33 | }) 34 | 35 | build.onEnd((result) => { 36 | if (result.errors.length > 0) { 37 | console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors) 38 | } else { 39 | console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 40 | } 41 | }) 42 | } 43 | }], 44 | } 45 | 46 | compile({ 47 | ...defaultOptions, 48 | entryPoints: ['./resources/js/index.js'], 49 | outfile: './resources/dist/filamentor.js', 50 | }) 51 | -------------------------------------------------------------------------------- /src/Models/Page.php: -------------------------------------------------------------------------------- 1 | 'array', 26 | 'is_published' => 'boolean', 27 | ]; 28 | 29 | // Get the meta title or fall back to the regular title 30 | public function getMetaTitleAttribute($value) 31 | { 32 | return $value ?: $this->title; 33 | } 34 | 35 | // Get URL-friendly slug 36 | public function getUrlAttribute() 37 | { 38 | return url($this->slug); 39 | } 40 | 41 | // Is the page accessible to visitors? 42 | public function getIsAccessibleAttribute() 43 | { 44 | return $this->is_published; 45 | } 46 | 47 | protected static function boot() 48 | { 49 | parent::boot(); 50 | 51 | static::creating(function ($page) { 52 | // Only auto-generate slug if not provided 53 | if (empty($page->slug)) { 54 | $page->slug = Str::slug($page->title); 55 | } 56 | }); 57 | 58 | static::updating(function ($page) { 59 | // If title changed but slug didn't, update the slug 60 | if ($page->isDirty('title') && !$page->isDirty('slug')) { 61 | $page->slug = Str::slug($page->title); 62 | } 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /dist/filamentor.css: -------------------------------------------------------------------------------- 1 | .filamentor-border{border:1px solid #e2e2e2}.dark .filamentor-border{border:1px solid #3a3a3a}.filamentor-spacing-input{border-color:#e5e7eb}.dark .filamentor-spacing-input{border-color:#3a3a3a}input.filamentor-spacing-input:focus{border-color:#ababac;outline:none}.dark .filamentor-spacing-input:focus{border-color:#5a5a5a;outline:none}.filamentor-spacing-input-first{border-top-left-radius:10px!important;border-bottom-left-radius:10px!important}.filamentor-spacing-input-middle{border-left:0}.filamentor-spacing-input-last{border-left:0;border-top-right-radius:10px!important;border-bottom-right-radius:10px!important}.filamentor-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1rem}.filamentor-element-card{background-color:#fff;border:1px solid #e2e2e2}.dark .filamentor-element-card{background-color:#1f2937;border:1px solid #3a3a3a}.filamentor-element-card:hover{background-color:#f3f4f6;border-color:#d1d5db}.dark .filamentor-element-card:hover{background-color:#374151;border-color:#4b5563}.sortable-ghost{opacity:.5;background:#e5e7eb}.sortable-chosen{opacity:1}.column-item{transition:transform .15s ease}.column-handle{cursor:grab}.column-handle:active{cursor:grabbing}.dragging{opacity:.5;border:2px dashed #707070}.dark .dragging{opacity:.5;border:2px dashed #d3d3d3}.drop-target{border:2px solid #707070;transform:translateY(2px);transition:all .2s ease}.dark .drop-target{border:2px solid #d3d3d3;transform:translateY(2px);transition:all .2s ease}.column-droppable{border:2px dashed #e5e7eb}.dragging-column{opacity:.5;border:2px dashed #707070!important}.drop-target-column{border:2px solid #707070!important}.dragging-column~.row-drop-indicator{display:none!important}.filamentor-btn-hover{border-radius:.25rem}.filamentor-btn-hover:hover{background-color:#f3f4f6}.dark .filamentor-btn-hover:hover{background-color:#374151}.column-item-wrapper.drop-before{outline:2px solid #3b82f6;outline-offset:-2px;border-left:3px solid #3b82f6!important}.column-item-wrapper.drop-after{outline:2px solid #3b82f6;outline-offset:-2px;border-right:3px solid #3b82f6!important}.columns-container.drop-inside{background-color:#eff6ff;outline:2px dashed #60a5fa;min-height:100px}.dragging-column{opacity:.5;border:2px dashed #707070!important;background-color:#f9fafb}.dark .dragging-column{background-color:#374151} 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geosem42/filamentor", 3 | "description": "Filamentor - A flexible page builder for Laravel Filament", 4 | "version": "1.1.1", 5 | "keywords": [ 6 | "geosem42", 7 | "laravel", 8 | "filamentor" 9 | ], 10 | "homepage": "https://github.com/geosem42/filamentor", 11 | "support": { 12 | "issues": "https://github.com/geosem42/filamentor/issues", 13 | "source": "https://github.com/geosem42/filamentor" 14 | }, 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "George Semaan", 19 | "email": "geosem042@gmail.com", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.1", 25 | "filament/filament": "^3.0", 26 | "spatie/laravel-package-tools": "^1.15.0", 27 | "intervention/image": "^3.0" 28 | }, 29 | "require-dev": { 30 | "nunomaduro/collision": "^7.9", 31 | "orchestra/testbench": "^8.0", 32 | "pestphp/pest": "^2.1", 33 | "pestphp/pest-plugin-arch": "^2.0", 34 | "pestphp/pest-plugin-laravel": "^2.0" 35 | }, 36 | "suggest": { 37 | "livewire/livewire": "Required for using the Livewire frontend (^3.0)", 38 | "inertiajs/inertia-laravel": "Required for using the Vue/Inertia frontend (^1.0)" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Geosem42\\Filamentor\\": "src/", 43 | "Geosem42\\Filamentor\\Database\\Factories\\": "database/factories/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Geosem42\\Filamentor\\Tests\\": "tests/" 49 | } 50 | }, 51 | "scripts": { 52 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 53 | "test": "vendor/bin/pest", 54 | "test-coverage": "vendor/bin/pest --coverage" 55 | }, 56 | "config": { 57 | "sort-packages": true, 58 | "allow-plugins": { 59 | "pestphp/pest-plugin": true, 60 | "phpstan/extension-installer": true 61 | } 62 | }, 63 | "extra": { 64 | "laravel": { 65 | "providers": [ 66 | "Geosem42\\Filamentor\\FilamentorServiceProvider" 67 | ], 68 | "aliases": { 69 | "Filamentor": "Geosem42\\Filamentor\\Facades\\Filamentor" 70 | } 71 | } 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } -------------------------------------------------------------------------------- /stubs/livewire/views/page.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | use Illuminate\Support\Str; 3 | @endphp 4 | 5 | @push('meta') 6 | {{ $pageTitle }} 7 | @if($pageDescription) 8 | 9 | @endif 10 | @if($pageTitle) 11 | 12 | @endif 13 | @if($pageDescription) 14 | 15 | @endif 16 | @if($ogImage) 17 | 18 | @endif 19 | 20 | @endpush 21 | 22 |
23 |
24 | @foreach($content as $row) 25 |
36 | @foreach($row['columns'] as $column) 37 |
48 | @foreach($column['elements'] as $element) 49 | @livewire(Str::lower(class_basename($element['type'])) . '-element', 50 | ['content' => $element['content']], key($element['type'] . '-' . $loop->index)) 51 | @endforeach 52 |
53 | @endforeach 54 |
55 | @endforeach 56 |
57 |
58 | -------------------------------------------------------------------------------- /src/Resources/PageResource.php: -------------------------------------------------------------------------------- 1 | schema([ 23 | Forms\Components\Section::make('Basic Information') 24 | ->columnSpan(9) 25 | ->schema([ 26 | Forms\Components\TextInput::make('title') 27 | ->required() 28 | ->maxLength(255) 29 | ->live(onBlur: true) 30 | ->afterStateUpdated(fn(string $state, Forms\Set $set) => 31 | $set('slug', Str::slug($state))), 32 | Forms\Components\TextInput::make('slug') 33 | ->required() 34 | ->maxLength(255) 35 | ->unique(ignoreRecord: true), 36 | Forms\Components\Textarea::make('description') 37 | ->maxLength(65535), 38 | ]), 39 | 40 | Forms\Components\Section::make('Publishing') 41 | ->columnSpan(3) 42 | ->schema([ 43 | Forms\Components\Toggle::make('is_published') 44 | ->default(false) 45 | ->helperText('Make this page visible to the public'), 46 | ]), 47 | 48 | Forms\Components\Hidden::make('layout') 49 | ->default('[]') // Set empty array as default 50 | ->afterStateHydrated(function ($component, $state) { 51 | $component->state($state === 'null' ? '[]' : $state); 52 | }) 53 | ->dehydrateStateUsing(fn ($state) => $state ?: '[]'), 54 | 55 | ]) 56 | ->columns(12); 57 | 58 | } 59 | public static function table(Table $table): Table 60 | { 61 | return $table 62 | ->columns([ 63 | Tables\Columns\TextColumn::make('title'), 64 | Tables\Columns\TextColumn::make('slug')->prefix('/'), 65 | Tables\Columns\IconColumn::make('is_published') 66 | ->boolean(), 67 | Tables\Columns\TextColumn::make('created_at') 68 | ->dateTime(), 69 | ]); 70 | } 71 | 72 | public static function getPages(): array 73 | { 74 | return [ 75 | 'index' => PageResource\Pages\ListPages::route('/'), 76 | 'create' => PageResource\Pages\CreatePage::route('/create'), 77 | 'edit' => PageResource\Pages\EditPage::route('/{record}/edit'), 78 | ]; 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /resources/js/store.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('alpine:init', () => { 2 | Alpine.store('rows', { 3 | items: [], 4 | 5 | init() { 6 | // Existing init logic 7 | }, 8 | 9 | setRows(rows) { 10 | this.items = rows; 11 | }, 12 | 13 | getColumn(rowIndex, columnIndex) { 14 | return this.items[rowIndex]?.columns[columnIndex] || {}; 15 | }, 16 | 17 | getColumns(rowIndex) { 18 | return this.items[rowIndex]?.columns || []; 19 | }, 20 | 21 | reorderRows(evt) { 22 | if (!evt || typeof evt.newIndex === 'undefined' || typeof evt.oldIndex === 'undefined') { 23 | return this.items; 24 | } 25 | 26 | const newIndex = evt.newIndex; 27 | const oldIndex = evt.oldIndex; 28 | 29 | let newItems = [...this.items]; 30 | const item = newItems.splice(oldIndex, 1)[0]; 31 | newItems.splice(newIndex, 0, item); 32 | 33 | newItems.forEach((row, index) => { 34 | row.order = index; 35 | }); 36 | 37 | this.items = newItems; 38 | return this.items; 39 | }, 40 | 41 | // Modified reorderColumns to handle columns within the same row (existing functionality) 42 | reorderColumns({ rowId, oldIndex, newIndex }) { 43 | const rows = [...this.items]; 44 | const targetRow = rows.find(r => r.id === rowId); 45 | 46 | if (targetRow && Array.isArray(targetRow.columns)) { 47 | const [movedColumn] = targetRow.columns.splice(oldIndex, 1); 48 | targetRow.columns.splice(newIndex, 0, movedColumn); 49 | targetRow.columns.forEach((col, idx) => col.order = idx); // Update order 50 | } 51 | 52 | this.items = rows; 53 | return this.items; 54 | }, 55 | 56 | // New method to move columns between rows or reorder within the same row 57 | moveColumnAndReorder({ sourceRowId, draggedColumnId, targetRowId, newIndexInTargetRow }) { 58 | const rows = [...this.items]; // Create a new array reference for Alpine reactivity 59 | const sourceRow = rows.find(r => r.id === sourceRowId); 60 | const targetRow = rows.find(r => r.id === targetRowId); 61 | 62 | if (!sourceRow || !targetRow) { 63 | console.error("Source or Target Row not found for moving column", { sourceRowId, targetRowId }); 64 | return this.items; // Return original if error 65 | } 66 | 67 | // Ensure columns arrays exist 68 | if (!Array.isArray(sourceRow.columns)) sourceRow.columns = []; 69 | if (!Array.isArray(targetRow.columns)) targetRow.columns = []; 70 | 71 | const sourceColumnIndex = sourceRow.columns.findIndex(c => c.id === draggedColumnId); 72 | if (sourceColumnIndex === -1) { 73 | console.error("Dragged column not found in source row", { draggedColumnId, sourceRowId }); 74 | return this.items; 75 | } 76 | 77 | const [movedColumn] = sourceRow.columns.splice(sourceColumnIndex, 1); 78 | 79 | // Ensure newIndexInTargetRow is within bounds for the target row's columns array 80 | const clampedNewIndex = Math.max(0, Math.min(newIndexInTargetRow, targetRow.columns.length)); 81 | targetRow.columns.splice(clampedNewIndex, 0, movedColumn); 82 | 83 | // Update order property for columns in both affected rows 84 | sourceRow.columns.forEach((col, index) => col.order = index); 85 | targetRow.columns.forEach((col, index) => col.order = index); 86 | 87 | this.items = rows; // Assign the modified array back to trigger reactivity 88 | return this.items; 89 | } 90 | }); 91 | }); -------------------------------------------------------------------------------- /resources/css/filamentor.css: -------------------------------------------------------------------------------- 1 | .filamentor-border { 2 | border: 1px solid #e2e2e2; 3 | } 4 | 5 | .dark .filamentor-border { 6 | border: 1px solid #3a3a3a; 7 | } 8 | 9 | .filamentor-spacing-input { 10 | border-color: #e5e7eb; 11 | } 12 | 13 | .dark .filamentor-spacing-input { 14 | border-color: #3a3a3a; 15 | } 16 | 17 | input.filamentor-spacing-input:focus { 18 | border-color: #ababac; 19 | outline: none; 20 | } 21 | 22 | .dark .filamentor-spacing-input:focus { 23 | border-color: #5a5a5a; 24 | outline: none; 25 | } 26 | 27 | .filamentor-spacing-input-first { 28 | border-top-left-radius: 10px !important; 29 | border-bottom-left-radius: 10px !important; 30 | } 31 | 32 | .filamentor-spacing-input-middle { 33 | border-left: 0; 34 | } 35 | 36 | .filamentor-spacing-input-last { 37 | border-left: 0; 38 | border-top-right-radius: 10px !important; 39 | border-bottom-right-radius: 10px !important; 40 | } 41 | 42 | 43 | .filamentor-grid { 44 | display: grid; 45 | grid-template-columns: repeat(2, 1fr); 46 | gap: 1rem; 47 | } 48 | 49 | .filamentor-element-card { 50 | background-color: white; 51 | border: 1px solid #e2e2e2; 52 | } 53 | 54 | .dark .filamentor-element-card { 55 | background-color: #1f2937; 56 | border: 1px solid #3a3a3a; 57 | } 58 | 59 | .filamentor-element-card:hover { 60 | background-color: #f3f4f6; 61 | border-color: #d1d5db; 62 | } 63 | 64 | .dark .filamentor-element-card:hover { 65 | background-color: #374151; 66 | border-color: #4b5563; 67 | } 68 | 69 | .dark .filamentor-element-card:hover { 70 | background-color: #374151; 71 | border-color: #4b5563; 72 | } 73 | 74 | .sortable-ghost { 75 | opacity: 0.5; 76 | background: #e5e7eb; 77 | } 78 | 79 | .sortable-chosen { 80 | opacity: 1; 81 | } 82 | 83 | .column-item { 84 | transition: transform 0.15s ease; 85 | } 86 | 87 | .column-handle { 88 | cursor: grab; 89 | } 90 | 91 | .column-handle:active { 92 | cursor: grabbing; 93 | } 94 | 95 | .dragging { 96 | opacity: 0.5; 97 | border: 2px dashed #707070; 98 | } 99 | .dark .dragging { 100 | opacity: 0.5; 101 | border: 2px dashed #d3d3d3; 102 | } 103 | 104 | .drop-target { 105 | border: 2px solid #707070; 106 | transform: translateY(2px); 107 | transition: all 0.2s ease; 108 | } 109 | .dark .drop-target { 110 | border: 2px solid #d3d3d3; 111 | transform: translateY(2px); 112 | transition: all 0.2s ease; 113 | } 114 | 115 | .column-droppable { 116 | border: 2px dashed #e5e7eb; 117 | } 118 | 119 | .dragging-column { 120 | opacity: 0.5; 121 | border: 2px dashed #707070 !important; 122 | } 123 | 124 | .drop-target-column { 125 | border: 2px solid #707070 !important; 126 | } 127 | 128 | /* Prevent row drop indicators when dragging columns */ 129 | .dragging-column ~ .row-drop-indicator { 130 | display: none !important; 131 | } 132 | 133 | /* Simple button hover background effect */ 134 | .filamentor-btn-hover { 135 | border-radius: 0.25rem; 136 | } 137 | 138 | .filamentor-btn-hover:hover { 139 | background-color: #f3f4f6; 140 | } 141 | 142 | .dark .filamentor-btn-hover:hover { 143 | background-color: #374151; 144 | } 145 | 146 | .column-item-wrapper.drop-before { 147 | outline: 2px solid #3b82f6; /* Tailwind's blue-500 */ 148 | outline-offset: -2px; 149 | border-left: 3px solid #3b82f6 !important; /* Adjust as needed */ 150 | } 151 | .column-item-wrapper.drop-after { 152 | outline: 2px solid #3b82f6; 153 | outline-offset: -2px; 154 | border-right: 3px solid #3b82f6 !important; /* Adjust as needed */ 155 | } 156 | .columns-container.drop-inside { 157 | background-color: #eff6ff; /* Tailwind's blue-100 or a light indicator color */ 158 | outline: 2px dashed #60a5fa; /* Tailwind's blue-400 */ 159 | min-height: 100px; /* Ensure it's visible when empty */ 160 | } 161 | 162 | .dragging-column { /* Ensure this is distinct */ 163 | opacity: 0.5; 164 | border: 2px dashed #707070 !important; 165 | background-color: #f9fafb; /* Tailwind's gray-50 */ 166 | } 167 | .dark .dragging-column { 168 | background-color: #374151; /* Tailwind's gray-700 for dark mode */ 169 | } 170 | -------------------------------------------------------------------------------- /stubs/vue/Page.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 106 | -------------------------------------------------------------------------------- /src/Commands/InstallFilamentor.php: -------------------------------------------------------------------------------- 1 | publishMigrations(); 16 | 17 | // Assets publishing 18 | $this->publishAssets([ 19 | __DIR__ . '/../../dist/filamentor.js' => public_path('js/filamentor/filamentor.js'), 20 | __DIR__ . '/../../dist/filamentor.css' => public_path('css/filamentor/filamentor.css'), 21 | __DIR__ . '/../../dist/alpine-sort.js' => public_path('js/filamentor/alpine-sort.js'), 22 | ]); 23 | 24 | // Frontend choice and publishing will come next 25 | $stack = $this->choice( 26 | 'Which frontend stack would you like to use?', 27 | ['Vue', 'Livewire'], 28 | 0 29 | ); 30 | 31 | $this->publishStackFiles($stack); 32 | 33 | // Show next steps 34 | $this->info('Filamentor installed successfully!'); 35 | $this->info("Add this route to your routes/web.php:"); 36 | $this->line("Route::get('/{slug}', [App\Http\Controllers\PageController::class, 'show'])->name('page.show');"); 37 | } 38 | 39 | private function publishAssets(array $assets) 40 | { 41 | $this->info('Publishing assets...'); 42 | foreach ($assets as $from => $to) { 43 | if (!file_exists(dirname($to))) { 44 | mkdir(dirname($to), 0755, true); 45 | } 46 | 47 | if (file_exists($from)) { 48 | copy($from, $to); 49 | } 50 | } 51 | } 52 | 53 | private function publishMigrations() 54 | { 55 | $this->info('Publishing migrations...'); 56 | 57 | $from = __DIR__ . '/../../database/migrations'; 58 | $to = database_path('migrations'); 59 | 60 | if (!is_dir($from)) { 61 | return; 62 | } 63 | 64 | if (!is_dir($to)) { 65 | mkdir($to, 0755, true); 66 | } 67 | 68 | $files = scandir($from); 69 | foreach ($files as $file) { 70 | if ($file === '.' || $file === '..') { 71 | continue; 72 | } 73 | 74 | $baseName = preg_replace('/\.(php|stub)$/', '', $file); 75 | 76 | // Check if migration already exists 77 | $existingFiles = glob($to . '/*' . $baseName . '*'); 78 | if (!empty($existingFiles)) { 79 | continue; 80 | } 81 | 82 | $newFileName = date('Y_m_d_His_') . $baseName; 83 | copy($from . '/' . $file, $to . '/' . $newFileName); 84 | } 85 | } 86 | 87 | private function publishStackFiles(string $stack) 88 | { 89 | $this->info("Publishing {$stack} files..."); 90 | 91 | $files = match ($stack) { 92 | 'Vue' => [ 93 | __DIR__ . '/../../stubs/vue/Page.vue' => resource_path('js/Pages/Page.vue'), 94 | __DIR__ . '/../../stubs/vue/elements/TextElement.vue' => resource_path('js/Components/Elements/TextElement.vue'), 95 | __DIR__ . '/../../stubs/vue/elements/ImageElement.vue' => resource_path('js/Components/Elements/ImageElement.vue'), 96 | __DIR__ . '/../../stubs/vue/elements/VideoElement.vue' => resource_path('js/Components/Elements/VideoElement.vue'), 97 | __DIR__ . '/../../stubs/controllers/InertiaPageController.php.stub' => app_path('Http/Controllers/PageController.php'), 98 | ], 99 | 'Livewire' => [ 100 | // Livewire Components 101 | __DIR__ . '/../../stubs/livewire/Page.php' => app_path('Livewire/Page.php'), 102 | __DIR__ . '/../../stubs/livewire/elements/TextElement.php' => app_path('Livewire/Elements/TextElement.php'), 103 | __DIR__ . '/../../stubs/livewire/elements/ImageElement.php' => app_path('Livewire/Elements/ImageElement.php'), 104 | __DIR__ . '/../../stubs/livewire/elements/VideoElement.php' => app_path('Livewire/Elements/VideoElement.php'), 105 | __DIR__ . '/../../stubs/controllers/LivewirePageController.php.stub' => app_path('Http/Controllers/PageController.php'), 106 | 107 | // Livewire Views 108 | __DIR__ . '/../../stubs/livewire/views/page.blade.php' => resource_path('views/livewire/page.blade.php'), 109 | __DIR__ . '/../../stubs/livewire/views/pages/show.blade.php' => resource_path('views/pages/show.blade.php'), 110 | __DIR__ . '/../../stubs/livewire/views/elements/text-element.blade.php' => resource_path('views/livewire/elements/text-element.blade.php'), 111 | __DIR__ . '/../../stubs/livewire/views/elements/image-element.blade.php' => resource_path('views/livewire/elements/image-element.blade.php'), 112 | __DIR__ . '/../../stubs/livewire/views/elements/video-element.blade.php' => resource_path('views/livewire/elements/video-element.blade.php'), 113 | ], 114 | }; 115 | 116 | foreach ($files as $from => $to) { 117 | if (!file_exists(dirname($to))) { 118 | mkdir(dirname($to), 0755, true); 119 | } 120 | copy($from, $to); 121 | } 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/FilamentorServiceProvider.php: -------------------------------------------------------------------------------- 1 | name(static::$name) 40 | ->hasCommands($this->getCommands()) 41 | ->hasInstallCommand(function (InstallCommand $command) { 42 | $command 43 | ->publishConfigFile() 44 | ->publishMigrations() 45 | ->askToRunMigrations() 46 | ->askToStarRepoOnGitHub('geosem42/filamentor'); 47 | }); 48 | 49 | $configFileName = $package->shortName(); 50 | 51 | if (file_exists($package->basePath("/../config/{$configFileName}.php"))) { 52 | $package->hasConfigFile(); 53 | } 54 | 55 | if (file_exists($package->basePath('/../database/migrations'))) { 56 | $package->hasMigrations($this->getMigrations()); 57 | } 58 | 59 | if (file_exists($package->basePath('/../resources/lang'))) { 60 | $package->hasTranslations(); 61 | } 62 | 63 | if (file_exists($package->basePath('/../resources/views'))) { 64 | $package->hasViews(static::$viewNamespace); 65 | } 66 | } 67 | 68 | public function packageRegistered(): void {} 69 | 70 | public function packageBooted(): void 71 | { 72 | // Register Elements 73 | $registry = new ElementRegistry(); 74 | $registry->register(Text::class); 75 | $registry->register(Image::class); 76 | $registry->register(Video::class); 77 | $this->app->instance(ElementRegistry::class, $registry); 78 | 79 | // Register Livewire Components Right Here! 80 | Livewire::component('page', \App\Livewire\Page::class); 81 | Livewire::component('text-element', \App\Livewire\Elements\TextElement::class); 82 | Livewire::component('image-element', \App\Livewire\Elements\ImageElement::class); 83 | Livewire::component('video-element', \App\Livewire\Elements\VideoElement::class); 84 | 85 | if ($this->app->runningInConsole()) { 86 | $this->commands([ 87 | Commands\InstallFilamentor::class 88 | ]); 89 | } 90 | 91 | // Asset Registration 92 | FilamentAsset::register( 93 | $this->getAssets(), 94 | $this->getAssetPackageName() 95 | ); 96 | 97 | FilamentAsset::registerScriptData( 98 | $this->getScriptData(), 99 | $this->getAssetPackageName() 100 | ); 101 | 102 | // Icon Registration 103 | FilamentIcon::register($this->getIcons()); 104 | 105 | // Handle Stubs 106 | if (app()->runningInConsole()) { 107 | foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) { 108 | $this->publishes([ 109 | $file->getRealPath() => base_path("stubs/filamentor/{$file->getFilename()}"), 110 | ], 'filamentor-stubs'); 111 | } 112 | } 113 | 114 | // Testing 115 | Testable::mixin(new TestsFilamentor); 116 | } 117 | 118 | protected function getAssetPackageName(): ?string 119 | { 120 | return 'filamentor'; 121 | } 122 | 123 | /** 124 | * @return array 125 | */ 126 | protected function getAssets(): array 127 | { 128 | return [ 129 | Js::make('alpine-sort', __DIR__ . '/../dist/alpine-sort.js'), 130 | Js::make('filamentor', __DIR__ . '/../dist/filamentor.js'), 131 | Css::make('filamentor', __DIR__ . '/../dist/filamentor.css'), 132 | ]; 133 | } 134 | 135 | /** 136 | * @return array 137 | */ 138 | protected function getCommands(): array 139 | { 140 | return [ 141 | FilamentorCommand::class, 142 | ]; 143 | } 144 | 145 | /** 146 | * @return array 147 | */ 148 | protected function getIcons(): array 149 | { 150 | return []; 151 | } 152 | 153 | /** 154 | * @return array 155 | */ 156 | protected function getRoutes(): array 157 | { 158 | return []; 159 | } 160 | 161 | /** 162 | * @return array 163 | */ 164 | protected function getScriptData(): array 165 | { 166 | return []; 167 | } 168 | 169 | /** 170 | * @return array 171 | */ 172 | protected function getMigrations(): array 173 | { 174 | return [ 175 | 'create_filamentor_table', 176 | ]; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Filamentor - Drag & Drop Page Builder for Filament 2 | 3 | Filamentor is a powerful, flexible page builder plugin for Laravel Filament that empowers you to create dynamic pages with a modern drag-and-drop interface. Build professional layouts without writing code using an intuitive grid-based system. 4 | 5 | ![Filamentor Screenshot](https://i.imgur.com/hbOquvS.png) 6 | 7 | ## Key Features 8 | 9 | - **Drag & Drop Interface**: Easily create and arrange content with intuitive drag-and-drop interactions 10 | - **Responsive Grid System**: Build layouts using a flexible row/column grid that adapts to different screen sizes 11 | - **Ready-to-Use Elements**: Includes text, image, and video elements out of the box 12 | - **Margin & Padding Controls**: Fine-tune spacing with visual controls for perfect layouts 13 | - **Custom CSS Classes**: Apply custom styling to any row or column for unlimited design possibilities 14 | - **Multiple Frontend Options**: Works with both Vue/Inertia and Livewire stacks 15 | - **Seamless Filament Integration**: Appears as a native feature in your Filament admin panel 16 | ## Installation 17 | 18 | ### Prerequisites 19 | 20 | - PHP 8.1 or higher 21 | - Laravel 11.x or higher 22 | - Filament 3.x 23 | - Tailwind CSS 24 | 25 | ## CSS Framework Dependency 26 | 27 | Filamentor relies on TailwindCSS for its layout system, particularly for: 28 | 29 | - Grid layouts (`grid-cols-*` classes) 30 | - Spacing utilities (`p-*`, `m-*` classes) 31 | - Responsive design classes 32 | 33 | Make sure your layout includes Tailwind CSS. 34 | 35 | ### Step 1: Install the Package 36 | 37 | ```bash 38 | composer require geosem42/filamentor 39 | ``` 40 | 41 | ### Step 2: Run the Installation Command 42 | 43 | ```bash 44 | php artisan filamentor:install 45 | ``` 46 | 47 | This command will: 48 | 49 | 1. Publish migration files to your database/migrations directory 50 | 2. Create necessary public asset directories and copy required files: 51 | - `/public/js/filamentor/filamentor.js` 52 | - `/public/css/filamentor/filamentor.css` 53 | 3. Prompt you to select your preferred frontend stack (Vue or Livewire) 54 | 4. Publish stack-specific files. 55 | 56 | ### Step 3: Run Migrations 57 | 58 | After installing the package, run the migrations: 59 | 60 | ```bash 61 | php artisan migrate 62 | ``` 63 | 64 | This will create the necessary database tables for pages, layouts, and related entities. 65 | 66 | ### Step 4: Add Route 67 | 68 | Add the following route to your `routes/web.php`: 69 | 70 | ```php 71 | Route::get('/{slug}', [App\Http\Controllers\PageController::class, 'show'])->name('page.show'); 72 | ``` 73 | 74 | ### Step 5: Update Tailwind Configuration 75 | 76 | To ensure dynamic grid classes work properly, add the following to your `tailwind.config.js` file: 77 | 78 | ```javascript 79 | module.exports = { 80 | // ...other config 81 | safelist: [ 82 | 'grid-cols-1', 83 | 'grid-cols-2', 84 | 'grid-cols-3', 85 | 'grid-cols-4', 86 | 'grid-cols-5', 87 | 'grid-cols-6', 88 | 'grid-cols-7', 89 | 'grid-cols-8', 90 | 'grid-cols-9', 91 | 'grid-cols-10', 92 | 'grid-cols-11', 93 | 'grid-cols-12' 94 | ], 95 | // ...other config 96 | } 97 | ``` 98 | 99 | ### Step 6: Register with Filament 100 | 101 | Add the Filamentor plugin to your Filament panel provider in `app/Providers/Filament/AdminPanelProvider.php`: 102 | 103 | ```php 104 | use Geosem42\Filamentor\FilamentorPlugin; 105 | 106 | public function panel(Panel $panel): Panel 107 | { 108 | return $panel 109 | // ...other configuration 110 | ->plugins([ 111 | // ...other plugins 112 | FilamentorPlugin::make(), 113 | ]); 114 | } 115 | ``` 116 | 117 | ### Step 7: Compile Assets (if using Vue) 118 | 119 | If you're using the Vue stack, run: 120 | 121 | ```bash 122 | npm run build 123 | ``` 124 | 125 | ## Post-installation 126 | 127 | After installation, you'll find the Filamentor page builder in your Filament admin panel. You can create and manage pages through the interface. 128 | 129 | ## Template Integration Note 130 | 131 | The Filamentor page templates (both Vue and Livewire versions) do not come pre-integrated with any application layout. You will need to manually include them in your own application layout to ensure proper styling, navigation, and site structure. 132 | 133 | For example: 134 | 135 | - In Livewire: Wrap `resources/views/pages/show.blade.php` in your layout. 136 | - In Vue: Include the `resources/js/Pages/Page.vue` within your `AppLayout` layout component. 137 | 138 | This design gives you complete flexibility to integrate Filamentor pages within your existing site structure. 139 | 140 | ## Stack-Specific Notes 141 | 142 | ### Vue/Inertia Setup 143 | 144 | If you selected the Vue stack, ensure Inertia.js is properly installed and configured in your application. The page rendering happens through the Inertia Page component published during installation. 145 | 146 | ### Livewire Setup 147 | 148 | If you selected the Livewire stack, ensure Livewire is properly installed. The page rendering will use the Livewire components published during installation. 149 | 150 | For proper SEO functionality with Filamentor's Livewire implementation, your layout must include a `@stack('meta')` directive in the `` section. 151 | 152 | ## Published Files 153 | 154 | The installation command will publish the following files depending on your selected stack. 155 | #### For Vue: 156 | 157 | - `resources/js/Pages/Page.vue` 158 | - `resources/js/Components/Elements/TextElement.vue` 159 | - `resources/js/Components/Elements/ImageElement.vue` 160 | - `resources/js/Components/Elements/VideoElement.vue` 161 | - `app/Http/Controllers/PageController.php` 162 | 163 | #### For Livewire: 164 | 165 | - `app/Livewire/Page.php` 166 | - `app/Livewire/Elements/TextElement.php` 167 | - `app/Livewire/Elements/ImageElement.php` 168 | - `app/Livewire/Elements/VideoElement.php` 169 | - `app/Http/Controllers/PageController.php` 170 | - `resources/views/livewire/page.blade.php` 171 | - `resources/views/pages/show.blade.php` 172 | - `resources/views/livewire/elements/text-element.blade.php` 173 | - `resources/views/livewire/elements/image-element.blade.php` 174 | - `resources/views/livewire/elements/video-element.blade.php` 175 | 176 | 177 | ## Troubleshooting 178 | 179 | If you encounter issues with the Filamentor UI after installation: 180 | 181 | 1. Ensure all frontend assets have been published correctly 182 | 2. Verify that you've added the Tailwind safelist configuration 183 | 3. Make sure you've registered the plugin with your Filament panel 184 | 4. Check that you've run migrations and your database tables are created correctly 185 | 186 | ## License 187 | 188 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 189 | -------------------------------------------------------------------------------- /resources/js/dragHandlers.js: -------------------------------------------------------------------------------- 1 | export function createDragHandlers($wire) { 2 | return { 3 | // Row drag handlers (existing - keep as is) 4 | handleDragStart(e, row) { 5 | e.dataTransfer.setData('text/plain', row.id.toString()); 6 | e.target.classList.add('dragging'); 7 | }, 8 | handleDragOver(e) { 9 | e.preventDefault(); 10 | const dropTarget = e.target.closest('.bg-gray-50'); // Assuming this is a row element 11 | if (dropTarget && !dropTarget.classList.contains('dragging')) { 12 | document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); 13 | dropTarget.classList.add('drop-target'); 14 | } 15 | }, 16 | handleDragEnd(e) { 17 | e.target.classList.remove('dragging'); 18 | document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); 19 | }, 20 | handleDrop(e, targetRow) { 21 | e.preventDefault(); 22 | document.querySelectorAll('.dragging, .drop-target').forEach(el => el.classList.remove('dragging', 'drop-target')); 23 | 24 | const draggedId = e.dataTransfer.getData('text/plain'); 25 | const rows = Alpine.store('rows').items; 26 | const draggedIndex = rows.findIndex(r => r.id.toString() === draggedId); 27 | const targetIndex = rows.findIndex(r => r.id === targetRow.id); 28 | 29 | if (draggedIndex === -1 || targetIndex === -1) return; 30 | 31 | const updatedRows = Alpine.store('rows').reorderRows({ 32 | newIndex: targetIndex, 33 | oldIndex: draggedIndex 34 | }); 35 | 36 | const livewire = window.Livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id')); 37 | livewire.saveLayout(JSON.stringify(updatedRows)); 38 | }, 39 | 40 | // Column drag handlers (updated) 41 | handleColumnDragStart(e, column, row) { 42 | e.stopPropagation(); 43 | e.dataTransfer.setData('text/plain', JSON.stringify({ 44 | columnId: column.id, 45 | rowId: row.id, 46 | type: 'column' // Add a type to distinguish from other draggables if any 47 | })); 48 | e.target.classList.add('dragging-column'); 49 | // Optionally add a class to the body to indicate a column is being dragged 50 | document.body.classList.add('dragging-active-column'); 51 | }, 52 | 53 | handleColumnDragEnd(e) { 54 | e.stopPropagation(); 55 | document.querySelectorAll('.dragging-column').forEach(el => el.classList.remove('dragging-column')); 56 | document.querySelectorAll('.drop-before, .drop-after, .drop-inside').forEach(el => el.classList.remove('drop-before', 'drop-after', 'drop-inside')); 57 | document.body.classList.remove('dragging-active-column'); 58 | }, 59 | 60 | // Drag over an existing column item 61 | handleColumnItemDragOver(e, targetColumnData, targetRowData) { 62 | e.preventDefault(); 63 | e.stopPropagation(); 64 | 65 | const transferData = e.dataTransfer.types.includes('text/plain') ? e.dataTransfer.getData('text/plain') : null; 66 | if (transferData) { 67 | try { 68 | const parsedData = JSON.parse(transferData); 69 | if (parsedData.type !== 'column') return; // Only handle column drags 70 | } catch (err) { return; } // Not our drag type 71 | } 72 | 73 | 74 | document.querySelectorAll('.drop-before, .drop-after, .drop-inside').forEach(el => el.classList.remove('drop-before', 'drop-after', 'drop-inside')); 75 | 76 | const columnWrapper = e.currentTarget; // The div.column-item-wrapper 77 | if (columnWrapper && !columnWrapper.classList.contains('dragging-column')) { 78 | const rect = columnWrapper.getBoundingClientRect(); 79 | const isRightHalf = e.clientX > rect.left + rect.width / 2; 80 | if (isRightHalf) { 81 | columnWrapper.classList.add('drop-after'); 82 | } else { 83 | columnWrapper.classList.add('drop-before'); 84 | } 85 | } 86 | }, 87 | 88 | handleColumnItemDragLeave(e) { 89 | e.stopPropagation(); 90 | const columnWrapper = e.currentTarget; 91 | if (columnWrapper) { 92 | columnWrapper.classList.remove('drop-before', 'drop-after'); 93 | } 94 | }, 95 | 96 | // Drag over the general columns container of a row (for empty rows or appending) 97 | handleColumnContainerDragOver(e, targetRowData) { 98 | e.preventDefault(); 99 | e.stopPropagation(); 100 | 101 | const transferData = e.dataTransfer.types.includes('text/plain') ? e.dataTransfer.getData('text/plain') : null; 102 | if (transferData) { 103 | try { 104 | const parsedData = JSON.parse(transferData); 105 | if (parsedData.type !== 'column') return; 106 | } catch (err) { return; } 107 | } else { return; } 108 | 109 | 110 | document.querySelectorAll('.drop-before, .drop-after, .drop-inside').forEach(el => el.classList.remove('drop-before', 'drop-after', 'drop-inside')); 111 | 112 | const container = e.currentTarget; // The div.columns-container 113 | const isEmpty = targetRowData.columns.length === 0; 114 | // Check if directly over container or its empty state 115 | const isDirectlyOverContainerOrEmptyState = e.target === container || e.target.closest('.column-empty-state'); 116 | 117 | if (isEmpty || isDirectlyOverContainerOrEmptyState) { 118 | container.classList.add('drop-inside'); 119 | } 120 | }, 121 | 122 | handleColumnContainerDragLeave(e) { 123 | e.stopPropagation(); 124 | const container = e.currentTarget; 125 | container.classList.remove('drop-inside'); 126 | }, 127 | 128 | // Drop onto an existing column item 129 | handleColumnDropOnItem(e, targetColumnData, targetRowData) { 130 | e.preventDefault(); 131 | e.stopPropagation(); 132 | 133 | const transfer = JSON.parse(e.dataTransfer.getData('text/plain')); 134 | if (transfer.type !== 'column') return; 135 | 136 | const sourceRowId = transfer.rowId; 137 | const draggedColumnId = transfer.columnId; 138 | const targetRowId = targetRowData.id; 139 | 140 | const columnWrapper = e.currentTarget; 141 | let newIndexInTargetRow; 142 | 143 | const targetRowInStore = Alpine.store('rows').items.find(r => r.id === targetRowId); 144 | if (!targetRowInStore) return; 145 | 146 | const targetColumnIndexInStore = targetRowInStore.columns.findIndex(c => c.id === targetColumnData.id); 147 | if (targetColumnIndexInStore === -1) return; 148 | 149 | if (columnWrapper.classList.contains('drop-after')) { 150 | newIndexInTargetRow = targetColumnIndexInStore + 1; 151 | } else { // Assumes drop-before or directly on 152 | newIndexInTargetRow = targetColumnIndexInStore; 153 | } 154 | 155 | columnWrapper.classList.remove('drop-before', 'drop-after'); 156 | document.querySelectorAll('.dragging-column').forEach(el => el.classList.remove('dragging-column')); 157 | document.body.classList.remove('dragging-active-column'); 158 | 159 | const updatedRows = Alpine.store('rows').moveColumnAndReorder({ 160 | sourceRowId: sourceRowId, 161 | draggedColumnId: draggedColumnId, 162 | targetRowId: targetRowId, 163 | newIndexInTargetRow: newIndexInTargetRow 164 | }); 165 | 166 | if (updatedRows) { 167 | const livewire = window.Livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id')); 168 | livewire.saveLayout(JSON.stringify(updatedRows)); 169 | } 170 | }, 171 | 172 | // Drop into the general columns container of a row 173 | handleColumnDropInContainer(e, targetRowData) { 174 | e.preventDefault(); 175 | e.stopPropagation(); 176 | 177 | const transfer = JSON.parse(e.dataTransfer.getData('text/plain')); 178 | if (transfer.type !== 'column') return; 179 | 180 | const sourceRowId = transfer.rowId; 181 | const draggedColumnId = transfer.columnId; 182 | const targetRowId = targetRowData.id; 183 | 184 | const container = e.currentTarget; 185 | container.classList.remove('drop-inside'); 186 | document.querySelectorAll('.dragging-column').forEach(el => el.classList.remove('dragging-column')); 187 | document.body.classList.remove('dragging-active-column'); 188 | 189 | const targetRowInStore = Alpine.store('rows').items.find(r => r.id === targetRowId); 190 | if (!targetRowInStore) return; 191 | 192 | // Append to the end of the target row's columns 193 | const newIndexInTargetRow = targetRowInStore.columns.length; 194 | 195 | const updatedRows = Alpine.store('rows').moveColumnAndReorder({ 196 | sourceRowId: sourceRowId, 197 | draggedColumnId: draggedColumnId, 198 | targetRowId: targetRowId, 199 | newIndexInTargetRow: newIndexInTargetRow 200 | }); 201 | 202 | if (updatedRows) { 203 | const livewire = window.Livewire.find(document.querySelector('[wire\\:id]').getAttribute('wire:id')); 204 | livewire.saveLayout(JSON.stringify(updatedRows)); 205 | } 206 | } 207 | }; 208 | } -------------------------------------------------------------------------------- /src/Resources/PageResource/Pages/EditPage.php: -------------------------------------------------------------------------------- 1 | [ 38 | 'content' => null, 39 | ], 40 | 'image' => [ 41 | 'url' => null, 42 | 'alt' => null, 43 | 'thumbnail' => null, 44 | ], 45 | 'video' => [ 46 | 'url' => null, 47 | ] 48 | ]; 49 | 50 | public function form(Form $form): Form 51 | { 52 | return $form 53 | ->schema([ 54 | Section::make('Page Details') 55 | ->schema([ 56 | TextInput::make('title') 57 | ->required() 58 | ->maxLength(255) 59 | ->live(onBlur: true) 60 | ->afterStateUpdated(function (string $operation, $state, callable $set) { 61 | $set('slug', Str::slug($state)); 62 | }), 63 | 64 | TextInput::make('slug') 65 | ->required() 66 | ->maxLength(255) 67 | ->unique(table: 'filamentor_pages', column: 'slug', ignorable: fn ($record) => $record) 68 | ->dehydrated() 69 | ->rules(['alpha_dash']) 70 | ->helperText('The URL-friendly name for your page (auto-generated from title)'), 71 | 72 | Textarea::make('description') 73 | ->maxLength(255) 74 | ->helperText('A brief summary of this page (optional)'), 75 | 76 | Grid::make() 77 | ->schema([ 78 | Toggle::make('is_published') 79 | ->label('Published') 80 | ->helperText('Make this page visible to users') 81 | ->default(false), 82 | ]), 83 | ]), 84 | 85 | Section::make('SEO Information') 86 | ->schema([ 87 | TextInput::make('meta_title') 88 | ->label('Meta Title') 89 | ->helperText('Overrides the default title tag. Recommended length: 50-60 characters') 90 | ->maxLength(60), 91 | 92 | Textarea::make('meta_description') 93 | ->label('Meta Description') 94 | ->helperText('A short description of the page for search engines. Recommended length: 150-160 characters') 95 | ->maxLength(160), 96 | 97 | FileUpload::make('og_image') 98 | ->label('Social Media Image') 99 | ->image() 100 | ->directory('page-social-images') 101 | ->helperText('Used when sharing this page on social media (1200x630 pixels recommended)'), 102 | ]), 103 | ]); 104 | } 105 | 106 | /** 107 | * Prepares the form for editing a specific element type 108 | * 109 | * @param string $type The element type class name 110 | * @param array|null $content The current content of the element 111 | */ 112 | 113 | public function editElement(string $type, ?array $content = null) 114 | { 115 | try { 116 | $this->isLoading = true; 117 | 118 | // Reset element data first 119 | $this->resetElementData(); 120 | 121 | // Store the active element type 122 | $this->activeElementType = $type; 123 | 124 | // Initialize and populate element data structure based on type 125 | if (str_contains($type, 'Text')) { 126 | $this->elementData['text'] = [ 127 | 'content' => $content['text'] ?? null, 128 | ]; 129 | } elseif (str_contains($type, 'Image')) { 130 | $this->elementData['image'] = [ 131 | 'url' => $content['url'] ?? null, 132 | 'alt' => $content['alt'] ?? '', 133 | 'thumbnail' => $content['thumbnail'] ?? null 134 | ]; 135 | } elseif (str_contains($type, 'Video')) { 136 | $this->elementData['video'] = [ 137 | 'url' => $content['url'] ?? null 138 | ]; 139 | } 140 | } catch (\Exception $e) { 141 | // Log the error 142 | \Log::error('Error initializing element editor', [ 143 | 'exception' => $e->getMessage(), 144 | 'type' => $type, 145 | ]); 146 | } finally { 147 | $this->isLoading = false; 148 | 149 | // Get the element form regardless of any errors 150 | $this->getElementForm(); 151 | } 152 | } 153 | 154 | /** 155 | * Generates a Filament form for editing the currently active element 156 | * 157 | * @return \Filament\Forms\Form The generated form instance 158 | */ 159 | 160 | public function getElementForm(): Form 161 | { 162 | try { 163 | // Check if we have an active element type 164 | if (empty($this->activeElementType)) { 165 | Log::warning('Attempted to build form with no active element type'); 166 | return Form::make($this)->schema([]); 167 | } 168 | 169 | // Get the element registry service 170 | $registry = app(ElementRegistry::class); 171 | 172 | // Get the element for the active type 173 | $element = $registry->getElement($this->activeElementType); 174 | 175 | // Build the schema based on the element's settings 176 | if ($element instanceof ElementInterface) { 177 | return Form::make($this) 178 | ->schema($this->buildElementFormSchema($element)); 179 | } 180 | 181 | // Log that we couldn't find a valid element 182 | Log::warning('Element not found or invalid', [ 183 | 'type' => $this->activeElementType 184 | ]); 185 | 186 | // Return empty form if element doesn't exist 187 | return Form::make($this)->schema([]); 188 | } catch (\Exception $e) { 189 | // Log any exceptions 190 | Log::error('Error building element form', [ 191 | 'exception' => $e->getMessage(), 192 | 'type' => $this->activeElementType ?? 'unknown' 193 | ]); 194 | 195 | // Return an empty form in case of exception 196 | return Form::make($this)->schema([]); 197 | } 198 | } 199 | 200 | /** 201 | * Processes and uploads media files for elements 202 | * Creates both original and thumbnail versions of images 203 | * 204 | * @return array|null Image data array or null if no media uploaded 205 | */ 206 | 207 | public function uploadMedia(): ?array 208 | { 209 | try { 210 | if (empty($this->media)) { 211 | return null; 212 | } 213 | 214 | // Get the first file from the media collection 215 | $file = collect($this->media)->first(); 216 | if (!$file) { 217 | return null; 218 | } 219 | 220 | // Ensure we have a valid file 221 | if (!$file->isValid()) { 222 | throw new \Exception('Uploaded file is invalid'); 223 | } 224 | 225 | // Store the original file 226 | $path = $file->store('elements', 'public'); 227 | if (!$path) { 228 | throw new \Exception('Failed to store uploaded file'); 229 | } 230 | 231 | // Create thumbnails directory if it doesn't exist 232 | $thumbnailDir = 'elements/thumbnails'; 233 | if (!Storage::disk('public')->exists($thumbnailDir)) { 234 | Storage::disk('public')->makeDirectory($thumbnailDir); 235 | } 236 | 237 | // Generate and store thumbnail 238 | $thumbnailPath = $thumbnailDir . '/' . basename($path); 239 | 240 | try { 241 | $manager = new ImageManager(new Driver()); 242 | $image = $manager->read($file->getRealPath()); 243 | $image->cover(100, 100) 244 | ->save(storage_path('app/public/' . $thumbnailPath)); 245 | } catch (\Exception $e) { 246 | // Log thumbnail generation error but continue with upload 247 | Log::warning('Failed to generate thumbnail', [ 248 | 'error' => $e->getMessage(), 249 | 'file' => $path 250 | ]); 251 | // Use original as thumbnail if generation fails 252 | $thumbnailPath = $path; 253 | } 254 | 255 | // Generate URLs for the uploaded files 256 | $url = Storage::disk('public')->url($path); 257 | $thumbnailUrl = Storage::disk('public')->url($thumbnailPath); 258 | 259 | // Update element data with new image information 260 | $this->elementData['image'] = [ 261 | 'url' => $url, 262 | 'thumbnail' => $thumbnailUrl, 263 | 'alt' => $this->elementData['image']['alt'] ?? '', 264 | ]; 265 | 266 | return $this->elementData['image']; 267 | } catch (\Exception $e) { 268 | Log::error('Media upload failed', [ 269 | 'error' => $e->getMessage() 270 | ]); 271 | return null; 272 | } 273 | } 274 | 275 | /** 276 | * Saves content for the currently active element 277 | * Updates the appropriate data structure based on element type 278 | * 279 | * @param mixed $content The content to save 280 | * @return array|null The updated element data or null if unsuccessful 281 | */ 282 | 283 | public function saveElementContent($content): ?array 284 | { 285 | try { 286 | if (empty($this->activeElementType)) { 287 | Log::warning('Attempted to save content with no active element type'); 288 | return null; 289 | } 290 | 291 | $result = null; 292 | 293 | // Using if-elseif instead of switch with str_contains which isn't proper switch-case usage 294 | if (str_contains($this->activeElementType, 'Text')) { 295 | $this->elementData['text']['content'] = $content; 296 | $result = ['text' => $content]; 297 | } elseif (str_contains($this->activeElementType, 'Video')) { 298 | $this->elementData['video']['url'] = $content; 299 | $result = ['url' => $content]; 300 | } elseif (str_contains($this->activeElementType, 'Image')) { 301 | $this->elementData['image']['alt'] = $content; 302 | $result = $this->elementData['image']; 303 | } else { 304 | Log::warning('Unknown element type for content saving', [ 305 | 'type' => $this->activeElementType 306 | ]); 307 | } 308 | 309 | // Clean up the media property after saving 310 | $this->media = null; 311 | 312 | return $result; 313 | } catch (\Exception $e) { 314 | Log::error('Failed to save element content', [ 315 | 'error' => $e->getMessage(), 316 | 'type' => $this->activeElementType ?? 'unknown' 317 | ]); 318 | 319 | // Clean up even if there's an error 320 | $this->media = null; 321 | 322 | return null; 323 | } 324 | } 325 | 326 | /** 327 | * Resets all element data to default values 328 | * Clears any uploaded media and resets element data structures for all types 329 | */ 330 | 331 | protected function resetElementData(): void 332 | { 333 | // Clear any uploaded media files 334 | $this->media = null; 335 | 336 | // Reset all element data structures to their default states 337 | $this->elementData = [ 338 | 'text' => [ 339 | 'content' => null, 340 | ], 341 | 'image' => [ 342 | 'url' => null, 343 | 'alt' => null, 344 | 'thumbnail' => null, 345 | ], 346 | 'video' => [ 347 | 'url' => null, 348 | ], 349 | ]; 350 | } 351 | 352 | /** 353 | * Builds a form schema based on an element's settings 354 | * Converts element settings into appropriate Filament form components 355 | * 356 | * @param ElementInterface $element The element to build the form for 357 | * @return array The form schema array 358 | */ 359 | protected function buildElementFormSchema(ElementInterface $element): array 360 | { 361 | try { 362 | // Get settings from the element 363 | $settings = $element->getSettings(); 364 | 365 | // Validate settings is an array 366 | if (!is_array($settings)) { 367 | Log::warning('Element settings is not an array', [ 368 | 'type' => $this->activeElementType, 369 | 'settings_type' => gettype($settings) 370 | ]); 371 | return []; 372 | } 373 | 374 | // Transform each setting into a form component 375 | return collect($settings)->map(function ($setting, $key) { 376 | // Validate setting structure 377 | if (!isset($setting['type']) || !isset($setting['label'])) { 378 | return null; // Skip invalid settings 379 | } 380 | 381 | // Match setting type to appropriate form component 382 | return match ($setting['type']) { 383 | 'file' => $this->buildFileUploadComponent($setting), 384 | 'textarea' => RichEditor::make('elementData.text.content') 385 | ->label($setting['label']), 386 | 'text' => $this->buildTextInputComponent($setting), 387 | default => null // Skip unknown setting types 388 | }; 389 | }) 390 | ->filter() // Remove null values (invalid or unknown settings) 391 | ->toArray(); 392 | } catch (\Exception $e) { 393 | Log::error('Error building element form schema', [ 394 | 'error' => $e->getMessage(), 395 | 'type' => $this->activeElementType ?? 'unknown' 396 | ]); 397 | return []; 398 | } 399 | } 400 | 401 | /** 402 | * Builds a file upload component for image elements 403 | * 404 | * @param array $setting The setting configuration 405 | * @return FileUpload The configured FileUpload component 406 | */ 407 | protected function buildFileUploadComponent(array $setting): FileUpload 408 | { 409 | return FileUpload::make('media') 410 | ->image() 411 | ->imageEditor() 412 | ->directory('elements') 413 | ->label($setting['label']) 414 | ->live() 415 | ->maxFiles(1) 416 | ->disk('public') 417 | ->helperText(function() { 418 | if (str_contains($this->activeElementType, 'Image') && !empty($this->elementData['image']['url'])) { 419 | // Create a basic preview with proper HTML 420 | return new \Illuminate\Support\HtmlString( 421 | '
422 |

Current image:

423 |
424 | Current image 427 |
428 |
' 429 | ); 430 | } 431 | return ''; 432 | }); 433 | } 434 | 435 | /** 436 | * Builds a text input component based on element type 437 | * 438 | * @param array $setting The setting configuration 439 | * @return TextInput The configured TextInput component 440 | */ 441 | protected function buildTextInputComponent(array $setting): TextInput 442 | { 443 | // Determine the field name based on element type 444 | $fieldName = str_contains($this->activeElementType, 'Video') 445 | ? 'elementData.video.url' 446 | : 'elementData.image.alt'; 447 | 448 | return TextInput::make($fieldName)->label($setting['label']); 449 | } 450 | 451 | /** 452 | * Saves the page layout structure to the database 453 | * 454 | * @param string $layout JSON string containing the layout structure 455 | * @return array Response with success status and message 456 | */ 457 | public function saveLayout(string $layout): array 458 | { 459 | try { 460 | // Validate layout is valid JSON 461 | json_decode($layout); 462 | if (json_last_error() !== JSON_ERROR_NONE) { 463 | throw new \InvalidArgumentException('Invalid JSON layout data provided'); 464 | } 465 | 466 | // Update and save the record 467 | $this->record->layout = $layout; 468 | $this->record->save(); 469 | $this->record->refresh(); 470 | 471 | return [ 472 | 'success' => true, 473 | 'message' => 'Layout saved successfully' 474 | ]; 475 | } catch (\Exception $e) { 476 | Log::error('Failed to save layout', [ 477 | 'error' => $e->getMessage(), 478 | 'record_id' => $this->record->id ?? null 479 | ]); 480 | 481 | return [ 482 | 'success' => false, 483 | 'message' => 'Failed to save layout: ' . $e->getMessage() 484 | ]; 485 | } 486 | } 487 | 488 | /** 489 | * Reorders columns within a specific row 490 | * 491 | * @param int|string $rowId The ID of the row to update 492 | * @param string $columns JSON string containing the updated columns 493 | * @return void 494 | */ 495 | public function reorderColumns($rowId, string $columns): void 496 | { 497 | try { 498 | // Get current layout 499 | $layout = json_decode($this->record->layout, true); 500 | if (json_last_error() !== JSON_ERROR_NONE) { 501 | throw new \InvalidArgumentException('Invalid layout JSON in record'); 502 | } 503 | 504 | // Decode columns data 505 | $columnsData = json_decode($columns, true); 506 | if (json_last_error() !== JSON_ERROR_NONE) { 507 | throw new \InvalidArgumentException('Invalid columns JSON provided'); 508 | } 509 | 510 | // Validate layout is an array 511 | if (!is_array($layout)) { 512 | throw new \InvalidArgumentException('Layout is not a valid array'); 513 | } 514 | 515 | // Find and update the specific row's columns 516 | $rowFound = false; 517 | foreach ($layout as &$row) { 518 | if (isset($row['id']) && $row['id'] == $rowId) { 519 | $row['columns'] = $columnsData; 520 | $rowFound = true; 521 | break; 522 | } 523 | } 524 | 525 | if (!$rowFound) { 526 | Log::warning('Row not found during column reordering', [ 527 | 'row_id' => $rowId 528 | ]); 529 | } 530 | 531 | // Save the entire layout 532 | $this->record->layout = json_encode($layout); 533 | $this->record->save(); 534 | $this->record->refresh(); 535 | } catch (\Exception $e) { 536 | Log::error('Failed to reorder columns', [ 537 | 'error' => $e->getMessage(), 538 | 'row_id' => $rowId, 539 | 'record_id' => $this->record->id ?? null 540 | ]); 541 | } 542 | } 543 | 544 | } 545 | -------------------------------------------------------------------------------- /dist/filamentor.umd.cjs: -------------------------------------------------------------------------------- 1 | (function(h){typeof define=="function"&&define.amd?define(h):h()})(function(){"use strict";var h=document.createElement("style");h.textContent=`.filamentor-border{border:1px solid #e2e2e2}.dark .filamentor-border{border:1px solid #3a3a3a}.filamentor-spacing-input{border-color:#e5e7eb}.dark .filamentor-spacing-input{border-color:#3a3a3a}input.filamentor-spacing-input:focus{border-color:#ababac;outline:none}.dark .filamentor-spacing-input:focus{border-color:#5a5a5a;outline:none}.filamentor-spacing-input-first{border-top-left-radius:10px!important;border-bottom-left-radius:10px!important}.filamentor-spacing-input-middle{border-left:0}.filamentor-spacing-input-last{border-left:0;border-top-right-radius:10px!important;border-bottom-right-radius:10px!important}.filamentor-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:1rem}.filamentor-element-card{background-color:#fff;border:1px solid #e2e2e2}.dark .filamentor-element-card{background-color:#1f2937;border:1px solid #3a3a3a}.filamentor-element-card:hover{background-color:#f3f4f6;border-color:#d1d5db}.dark .filamentor-element-card:hover{background-color:#374151;border-color:#4b5563}.sortable-ghost{opacity:.5;background:#e5e7eb}.sortable-chosen{opacity:1}.column-item{transition:transform .15s ease}.column-handle{cursor:grab}.column-handle:active{cursor:grabbing}.dragging{opacity:.5;border:2px dashed #707070}.dark .dragging{opacity:.5;border:2px dashed #d3d3d3}.drop-target{border:2px solid #707070;transform:translateY(2px);transition:all .2s ease}.dark .drop-target{border:2px solid #d3d3d3;transform:translateY(2px);transition:all .2s ease}.column-droppable{border:2px dashed #e5e7eb}.dragging-column{opacity:.5;border:2px dashed #707070!important}.drop-target-column{border:2px solid #707070!important}.dragging-column~.row-drop-indicator{display:none!important}.filamentor-btn-hover{border-radius:.25rem}.filamentor-btn-hover:hover{background-color:#f3f4f6}.dark .filamentor-btn-hover:hover{background-color:#374151}.column-item-wrapper.drop-before{outline:2px solid #3b82f6;outline-offset:-2px;border-left:3px solid #3b82f6!important}.column-item-wrapper.drop-after{outline:2px solid #3b82f6;outline-offset:-2px;border-right:3px solid #3b82f6!important}.columns-container.drop-inside{background-color:#eff6ff;outline:2px dashed #60a5fa;min-height:100px}.dragging-column{opacity:.5;border:2px dashed #707070!important;background-color:#f9fafb}.dark .dragging-column{background-color:#374151} 2 | /*$vite$:1*/`,document.head.appendChild(h),document.addEventListener("alpine:init",()=>{Alpine.store("rows",{items:[],init(){},setRows(o){this.items=o},getColumn(o,e){var t;return((t=this.items[o])==null?void 0:t.columns[e])||{}},getColumns(o){var e;return((e=this.items[o])==null?void 0:e.columns)||[]},reorderRows(o){if(!o||typeof o.newIndex>"u"||typeof o.oldIndex>"u")return this.items;const e=o.newIndex,t=o.oldIndex;let r=[...this.items];const n=r.splice(t,1)[0];return r.splice(e,0,n),r.forEach((s,i)=>{s.order=i}),this.items=r,this.items},reorderColumns({rowId:o,oldIndex:e,newIndex:t}){const r=[...this.items],n=r.find(s=>s.id===o);if(n&&Array.isArray(n.columns)){const[s]=n.columns.splice(e,1);n.columns.splice(t,0,s),n.columns.forEach((i,a)=>i.order=a)}return this.items=r,this.items},moveColumnAndReorder({sourceRowId:o,draggedColumnId:e,targetRowId:t,newIndexInTargetRow:r}){const n=[...this.items],s=n.find(c=>c.id===o),i=n.find(c=>c.id===t);if(!s||!i)return console.error("Source or Target Row not found for moving column",{sourceRowId:o,targetRowId:t}),this.items;Array.isArray(s.columns)||(s.columns=[]),Array.isArray(i.columns)||(i.columns=[]);const a=s.columns.findIndex(c=>c.id===e);if(a===-1)return console.error("Dragged column not found in source row",{draggedColumnId:e,sourceRowId:o}),this.items;const[d]=s.columns.splice(a,1),l=Math.max(0,Math.min(r,i.columns.length));return i.columns.splice(l,0,d),s.columns.forEach((c,u)=>c.order=u),i.columns.forEach((c,u)=>c.order=u),this.items=n,this.items}})});function p(o){return{handleDragStart(e,t){e.dataTransfer.setData("text/plain",t.id.toString()),e.target.classList.add("dragging")},handleDragOver(e){e.preventDefault();const t=e.target.closest(".bg-gray-50");t&&!t.classList.contains("dragging")&&(document.querySelectorAll(".drop-target").forEach(r=>r.classList.remove("drop-target")),t.classList.add("drop-target"))},handleDragEnd(e){e.target.classList.remove("dragging"),document.querySelectorAll(".drop-target").forEach(t=>t.classList.remove("drop-target"))},handleDrop(e,t){e.preventDefault(),document.querySelectorAll(".dragging, .drop-target").forEach(l=>l.classList.remove("dragging","drop-target"));const r=e.dataTransfer.getData("text/plain"),n=Alpine.store("rows").items,s=n.findIndex(l=>l.id.toString()===r),i=n.findIndex(l=>l.id===t.id);if(s===-1||i===-1)return;const a=Alpine.store("rows").reorderRows({newIndex:i,oldIndex:s});window.Livewire.find(document.querySelector("[wire\\:id]").getAttribute("wire:id")).saveLayout(JSON.stringify(a))},handleColumnDragStart(e,t,r){e.stopPropagation(),e.dataTransfer.setData("text/plain",JSON.stringify({columnId:t.id,rowId:r.id,type:"column"})),e.target.classList.add("dragging-column"),document.body.classList.add("dragging-active-column")},handleColumnDragEnd(e){e.stopPropagation(),document.querySelectorAll(".dragging-column").forEach(t=>t.classList.remove("dragging-column")),document.querySelectorAll(".drop-before, .drop-after, .drop-inside").forEach(t=>t.classList.remove("drop-before","drop-after","drop-inside")),document.body.classList.remove("dragging-active-column")},handleColumnItemDragOver(e,t,r){e.preventDefault(),e.stopPropagation();const n=e.dataTransfer.types.includes("text/plain")?e.dataTransfer.getData("text/plain"):null;if(n)try{if(JSON.parse(n).type!=="column")return}catch{return}document.querySelectorAll(".drop-before, .drop-after, .drop-inside").forEach(i=>i.classList.remove("drop-before","drop-after","drop-inside"));const s=e.currentTarget;if(s&&!s.classList.contains("dragging-column")){const i=s.getBoundingClientRect();e.clientX>i.left+i.width/2?s.classList.add("drop-after"):s.classList.add("drop-before")}},handleColumnItemDragLeave(e){e.stopPropagation();const t=e.currentTarget;t&&t.classList.remove("drop-before","drop-after")},handleColumnContainerDragOver(e,t){e.preventDefault(),e.stopPropagation();const r=e.dataTransfer.types.includes("text/plain")?e.dataTransfer.getData("text/plain"):null;if(r)try{if(JSON.parse(r).type!=="column")return}catch{return}else return;document.querySelectorAll(".drop-before, .drop-after, .drop-inside").forEach(a=>a.classList.remove("drop-before","drop-after","drop-inside"));const n=e.currentTarget,s=t.columns.length===0,i=e.target===n||e.target.closest(".column-empty-state");(s||i)&&n.classList.add("drop-inside")},handleColumnContainerDragLeave(e){e.stopPropagation(),e.currentTarget.classList.remove("drop-inside")},handleColumnDropOnItem(e,t,r){e.preventDefault(),e.stopPropagation();const n=JSON.parse(e.dataTransfer.getData("text/plain"));if(n.type!=="column")return;const s=n.rowId,i=n.columnId,a=r.id,d=e.currentTarget;let l;const c=Alpine.store("rows").items.find(m=>m.id===a);if(!c)return;const u=c.columns.findIndex(m=>m.id===t.id);if(u===-1)return;d.classList.contains("drop-after")?l=u+1:l=u,d.classList.remove("drop-before","drop-after"),document.querySelectorAll(".dragging-column").forEach(m=>m.classList.remove("dragging-column")),document.body.classList.remove("dragging-active-column");const f=Alpine.store("rows").moveColumnAndReorder({sourceRowId:s,draggedColumnId:i,targetRowId:a,newIndexInTargetRow:l});f&&window.Livewire.find(document.querySelector("[wire\\:id]").getAttribute("wire:id")).saveLayout(JSON.stringify(f))},handleColumnDropInContainer(e,t){e.preventDefault(),e.stopPropagation();const r=JSON.parse(e.dataTransfer.getData("text/plain"));if(r.type!=="column")return;const n=r.rowId,s=r.columnId,i=t.id;e.currentTarget.classList.remove("drop-inside"),document.querySelectorAll(".dragging-column").forEach(u=>u.classList.remove("dragging-column")),document.body.classList.remove("dragging-active-column");const d=Alpine.store("rows").items.find(u=>u.id===i);if(!d)return;const l=d.columns.length,c=Alpine.store("rows").moveColumnAndReorder({sourceRowId:n,draggedColumnId:s,targetRowId:i,newIndexInTargetRow:l});c&&window.Livewire.find(document.querySelector("[wire\\:id]").getAttribute("wire:id")).saveLayout(JSON.stringify(c))}}}window.addEventListener("alpine:init",()=>{Alpine.data("filamentor",()=>({showSettings:!1,activeRow:null,activeColumn:null,activeColumnIndex:null,activeElement:null,activeElementIndex:null,rowToDelete:null,columnToDeleteRowId:null,columnToDeleteIndex:null,elementData:{text:{content:null},image:{url:null,alt:null,thumbnail:null},video:{url:null}},...p(),init(){try{if(!this.$refs.canvasData){console.warn("Canvas data reference not found");return}const o=this.$refs.canvasData.value;if(o)try{const e=JSON.parse(o);if(!Array.isArray(e)){console.error("Parsed layout is not an array");return}const t=e.sort((r,n)=>{const s=r.order!==void 0?r.order:0,i=n.order!==void 0?n.order:0;return s-i});Alpine.store("rows").setRows(t)}catch(e){console.error("Failed to parse layout JSON:",e),Alpine.store("rows").setRows([])}}catch(o){console.error("Error initializing builder:",o),Alpine.store("rows").setRows([])}},openRowSettings(o){try{if(!o||!o.id){console.error("Invalid row provided to openRowSettings");return}if(this.activeRow=Alpine.store("rows").items.find(e=>e.id===o.id),!this.activeRow){console.error(`Row with id ${o.id} not found`);return}this.activeRow.padding=this.activeRow.padding||{top:0,right:0,bottom:0,left:0},this.activeRow.margin=this.activeRow.margin||{top:0,right:0,bottom:0,left:0},this.activeRow.customClasses=this.activeRow.customClasses||"",this.showSettings=!0}catch(e){console.error("Error opening row settings:",e),this.activeRow=null,this.showSettings=!1}},saveRowSettings(){try{if(!this.activeRow){console.warn("No active row to save");return}if(!this.activeRow.id){console.error("Active row missing ID property");return}const o=Alpine.store("rows").items.findIndex(n=>n.id===this.activeRow.id);if(o===-1){console.error(`Row with id ${this.activeRow.id} not found in rows store`);return}const e=this.activeRow.padding||{},t=this.activeRow.margin||{},r={...this.activeRow,padding:{top:this.safeParseNumber(e.top),right:this.safeParseNumber(e.right),bottom:this.safeParseNumber(e.bottom),left:this.safeParseNumber(e.left)},margin:{top:this.safeParseNumber(t.top),right:this.safeParseNumber(t.right),bottom:this.safeParseNumber(t.bottom),left:this.safeParseNumber(t.left)}};Alpine.store("rows").items[o]=r;try{const n=JSON.stringify(Alpine.store("rows").items);if(!this.$refs.canvasData){console.error("Canvas data reference not found");return}this.$refs.canvasData.value=n,this.$wire.saveLayout(n).then(s=>{s&&s.success?console.log("Layout saved successfully"):console.warn("Layout save returned unexpected result",s)}).catch(s=>{console.error("Error saving layout:",s)})}catch(n){console.error("Error stringifying layout data:",n)}}catch(o){console.error("Error in saveRowSettings:",o)}},addRow(){try{const o=Date.now(),e={id:o,order:Alpine.store("rows").items.length,padding:{top:0,right:0,bottom:0,left:0},margin:{top:0,right:0,bottom:0,left:0},customClasses:"",columns:[{id:o+1,width:"w-full",padding:{top:0,right:0,bottom:0,left:0},margin:{top:0,right:0,bottom:0,left:0},customClasses:"",elements:[],order:0}]};if(!Alpine.store("rows")||!Array.isArray(Alpine.store("rows").items)){console.error("Rows store not properly initialized");return}Alpine.store("rows").items.push(e),this.updateCanvasData();try{const t=JSON.stringify(Alpine.store("rows").items);this.$wire.saveLayout(t).then(r=>{r&&r.success?console.log("Row added and layout saved successfully"):console.warn("Layout saved but returned unexpected result",r)}).catch(r=>{console.error("Error saving layout after adding row:",r)})}catch(t){console.error("Error stringifying layout after adding row:",t)}}catch(o){console.error("Error adding new row:",o),this.updateCanvasData()}},deleteRow(o){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided for deletion");return}if(!Array.isArray(o.columns)){console.warn("Row has no columns array, proceeding with deletion"),this.performRowDeletion(o);return}o.columns.some(t=>t.elements&&Array.isArray(t.elements)&&t.elements.length>0)?(this.rowToDelete=o,this.$dispatch("open-modal",{id:"confirm-row-deletion"})):this.performRowDeletion(o)}catch(e){console.error("Error during row deletion process:",e),this.rowToDelete=null}},confirmRowDeletion(){try{if(!this.rowToDelete||!this.rowToDelete.id){console.error("No valid row to delete"),this.$dispatch("close-modal",{id:"confirm-row-deletion"});return}this.performRowDeletion(this.rowToDelete),this.$dispatch("close-modal",{id:"confirm-row-deletion"}),this.rowToDelete=null}catch(o){console.error("Error during row deletion confirmation:",o),this.$dispatch("close-modal",{id:"confirm-row-deletion"}),this.rowToDelete=null}},performRowDeletion(o){try{if(!o||!o.id){console.error("Invalid row provided to performRowDeletion");return}if(!Alpine.store("rows")||!Array.isArray(Alpine.store("rows").items)){console.error("Rows store not properly initialized");return}const e=Alpine.store("rows").items.findIndex(t=>t.id===o.id);if(e>-1){Alpine.store("rows").items.splice(e,1),Alpine.store("rows").items=Alpine.store("rows").items.map((t,r)=>({...t,order:r}));try{const t=JSON.stringify(Alpine.store("rows").items);this.updateCanvasData(),this.$wire.saveLayout(t).then(r=>{r&&r.success?console.log("Row deleted and layout saved successfully"):console.warn("Layout saved after deletion but returned unexpected result",r)}).catch(r=>{console.error("Error saving layout after row deletion:",r)})}catch(t){console.error("Error stringifying layout after row deletion:",t)}}else console.warn(`Row with id ${o.id} not found in rows store`)}catch(e){console.error("Error performing row deletion:",e),this.updateCanvasData()}},openColumnSettings(o,e){try{if(!o||!o.id){console.error("Invalid row provided to openColumnSettings");return}if(!e||!e.id){console.error("Invalid column provided to openColumnSettings");return}this.activeRow=o,this.activeColumn=e,this.activeColumn.padding=this.activeColumn.padding||{top:0,right:0,bottom:0,left:0},this.activeColumn.margin=this.activeColumn.margin||{top:0,right:0,bottom:0,left:0},this.activeColumn.customClasses=this.activeColumn.customClasses||"",typeof this.activeColumn.padding=="object"&&(this.activeColumn.padding.top=this.safeParseNumber(this.activeColumn.padding.top),this.activeColumn.padding.right=this.safeParseNumber(this.activeColumn.padding.right),this.activeColumn.padding.bottom=this.safeParseNumber(this.activeColumn.padding.bottom),this.activeColumn.padding.left=this.safeParseNumber(this.activeColumn.padding.left)),typeof this.activeColumn.margin=="object"&&(this.activeColumn.margin.top=this.safeParseNumber(this.activeColumn.margin.top),this.activeColumn.margin.right=this.safeParseNumber(this.activeColumn.margin.right),this.activeColumn.margin.bottom=this.safeParseNumber(this.activeColumn.margin.bottom),this.activeColumn.margin.left=this.safeParseNumber(this.activeColumn.margin.left))}catch(t){console.error("Error opening column settings:",t),this.activeRow=null,this.activeColumn=null}},saveColumnSettings(){try{if(!this.activeColumn||!this.activeColumn.id){console.error("No valid column to save settings for"),this.$dispatch("close-modal",{id:"column-settings-modal"});return}if(!this.activeRow||!this.activeRow.id){console.error("No valid parent row for column settings"),this.$dispatch("close-modal",{id:"column-settings-modal"});return}const o=Alpine.store("rows").items,e=o.findIndex(r=>r.id===this.activeRow.id);if(e===-1){console.error(`Row with id ${this.activeRow.id} not found in rows store`),this.$dispatch("close-modal",{id:"column-settings-modal"});return}const t=o[e].columns.findIndex(r=>r.id===this.activeColumn.id);if(t===-1){console.error(`Column with id ${this.activeColumn.id} not found in row`),this.$dispatch("close-modal",{id:"column-settings-modal"});return}o[e].columns[t]={...this.activeColumn,padding:{top:this.safeParseNumber(this.activeColumn.padding.top),right:this.safeParseNumber(this.activeColumn.padding.right),bottom:this.safeParseNumber(this.activeColumn.padding.bottom),left:this.safeParseNumber(this.activeColumn.padding.left)},margin:{top:this.safeParseNumber(this.activeColumn.margin.top),right:this.safeParseNumber(this.activeColumn.margin.right),bottom:this.safeParseNumber(this.activeColumn.margin.bottom),left:this.safeParseNumber(this.activeColumn.margin.left)}};try{const r=JSON.stringify(o);this.updateCanvasData(),this.$wire.saveLayout(r).then(n=>{n&&n.success?console.log("Column settings saved successfully"):console.warn("Layout saved but returned unexpected result",n)}).catch(n=>{console.error("Error saving layout after column settings update:",n)}),this.$dispatch("close-modal",{id:"column-settings-modal"})}catch(r){console.error("Error stringifying layout after column settings update:",r),this.$dispatch("close-modal",{id:"column-settings-modal"})}}catch(o){console.error("Error saving column settings:",o),this.$dispatch("close-modal",{id:"column-settings-modal"})}},addColumn(o){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided to addColumn");return}Array.isArray(o.columns)||(console.warn("Row has no columns array, initializing empty array"),o.columns=[]);const t={id:Date.now(),elements:[],order:o.columns.length,width:"w-full",padding:{top:0,right:0,bottom:0,left:0},margin:{top:0,right:0,bottom:0,left:0},customClasses:""},r=[...o.columns,t];o.columns=r,this.$nextTick(()=>{try{const n=Alpine.store("rows").items;if(!n||!Array.isArray(n)){console.error("Rows store not properly initialized");return}if(n.findIndex(a=>a.id===o.id)===-1){console.error(`Row with id ${o.id} not found in rows store`);return}this.updateCanvasData();const i=JSON.stringify(n);this.$wire.saveLayout(i).then(a=>{a&&a.success?console.log("Column added and layout saved successfully"):console.warn("Layout saved but returned unexpected result",a)}).catch(a=>{console.error("Error saving layout after adding column:",a)})}catch(n){console.error("Error processing or saving layout after adding column:",n)}})}catch(e){console.error("Error adding new column:",e)}},updateColumns(o){try{if(!this.activeRow||typeof this.activeRow!="object"){console.error("No active row to update columns for");return}Array.isArray(this.activeRow.columns)||(console.warn("Active row has no columns array, initializing empty array"),this.activeRow.columns=[]);const e=parseInt(o);if(isNaN(e)||e<1){console.error(`Invalid column count: ${o}`);return}const t=this.activeRow.columns;if(e>t.length)try{const r=e-t.length,n=Date.now();for(let i=0;i{i.order=a});const s=Alpine.store("rows").items;if(!s||!Array.isArray(s)){console.error("Rows store not properly initialized");return}this.updateCanvasData();try{const i=JSON.stringify(s);this.$wire.saveLayout(i).then(a=>{a&&a.success?console.log("Columns added and layout saved successfully"):console.warn("Layout saved but returned unexpected result",a)}).catch(a=>{console.error("Error saving layout after adding columns:",a)})}catch(i){console.error("Error stringifying layout after adding columns:",i)}}catch(r){console.error("Error adding columns:",r)}else if(es.elements&&Array.isArray(s.elements)&&s.elements.length>0)?this.$dispatch("open-modal",{id:"confirm-column-reduction"}):this.confirmColumnReduction()}catch(r){console.error("Error preparing column reduction:",r)}else console.log("Column count unchanged")}catch(e){console.error("Error updating columns:",e)}},deleteColumn(o,e){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided to deleteColumn");return}if(e===void 0||isNaN(parseInt(e))){console.error("Invalid column index provided to deleteColumn");return}const t=Alpine.store("rows").items;if(!t||!Array.isArray(t)){console.error("Rows store not properly initialized");return}const r=t.findIndex(i=>i.id===o.id);if(r===-1){console.error(`Row with id ${o.id} not found in rows store`);return}if(!Array.isArray(t[r].columns)){console.error(`Row with id ${o.id} has no valid columns array`);return}if(e<0||e>=t[r].columns.length){console.error(`Column index ${e} out of bounds for row with id ${o.id}`);return}const n=t[r].columns[e];n&&n.elements&&Array.isArray(n.elements)&&n.elements.length>0?(this.columnToDeleteRowId=o.id,this.columnToDeleteIndex=e,this.$dispatch("open-modal",{id:"confirm-column-deletion"})):this.performColumnDeletion(o.id,e)}catch(t){console.error("Error during column deletion process:",t),this.columnToDeleteRowId=null,this.columnToDeleteIndex=null}},confirmColumnDeletion(){try{if(this.columnToDeleteRowId===null||this.columnToDeleteIndex===null){console.error("No valid column to delete"),this.$dispatch("close-modal",{id:"confirm-column-deletion"});return}this.performColumnDeletion(this.columnToDeleteRowId,this.columnToDeleteIndex),this.$dispatch("close-modal",{id:"confirm-column-deletion"}),this.columnToDeleteRowId=null,this.columnToDeleteIndex=null}catch(o){console.error("Error during column deletion confirmation:",o),this.$dispatch("close-modal",{id:"confirm-column-deletion"}),this.columnToDeleteRowId=null,this.columnToDeleteIndex=null}},performColumnDeletion(o,e){try{if(o==null){console.error("Invalid rowId provided to performColumnDeletion");return}if(e==null||isNaN(parseInt(e))){console.error("Invalid columnIndex provided to performColumnDeletion");return}const t=Alpine.store("rows").items;if(!t||!Array.isArray(t)){console.error("Rows store not properly initialized");return}const r=t.findIndex(n=>n.id===o);if(r===-1){console.error(`Row with id ${o} not found in rows store`);return}if(!Array.isArray(t[r].columns)){console.error(`Row with id ${o} has no valid columns array`);return}if(e<0||e>=t[r].columns.length){console.error(`Column index ${e} out of bounds for row with id ${o}`);return}if(t[r].columns.length===1){const n=Date.now();t[r].columns=[{id:t[r].columns[0].id,elements:[],order:0,width:"w-full",padding:{top:0,right:0,bottom:0,left:0},margin:{top:0,right:0,bottom:0,left:0},customClasses:""}],console.log("Column content cleared instead of deletion, as it was the last column in the row")}else t[r].columns.splice(e,1),t[r].columns=t[r].columns.map((n,s)=>({...n,order:s}));this.updateCanvasData();try{const n=JSON.stringify(t);this.$wire.saveLayout(n).then(s=>{s&&s.success?console.log("Layout updated and saved successfully"):console.warn("Layout saved but returned unexpected result",s)}).catch(s=>{console.error("Error saving layout after column operation:",s)})}catch(n){console.error("Error stringifying layout after column operation:",n)}}catch(t){console.error("Error performing column deletion:",t),this.updateCanvasData()}},setActiveColumn(o,e){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided to setActiveColumn");return}if(e==null||isNaN(parseInt(e))){console.error("Invalid column index provided to setActiveColumn");return}const t=Alpine.store("rows").items;if(!t||!Array.isArray(t)){console.error("Rows store not properly initialized");return}const r=t.find(s=>s.id===o.id);if(!r){console.error(`Row with id ${o.id} not found in rows store`);return}if(e<0||e>=r.columns.length){console.error(`Column index ${e} out of bounds for row with id ${o.id}`);return}const n=r.columns[e];if(n.elements&&Array.isArray(n.elements)&&n.elements.length>0){console.warn("Column already has an element. Only one element per column is allowed.");return}this.activeRow=r,this.activeColumnIndex=e,this.$dispatch("open-modal",{id:"element-picker-modal"})}catch(t){console.error("Error setting active column:",t),this.activeRow=null,this.activeColumnIndex=null}},addElement(o){try{if(!o||typeof o!="string"){console.error("Invalid element type provided to addElement");return}if(!this.activeRow||this.activeColumnIndex===null){console.error("No active row or column to add element to"),this.$dispatch("close-modal",{id:"element-picker-modal"});return}const e=Alpine.store("rows").items;if(!e||!Array.isArray(e)){console.error("Rows store not properly initialized"),this.$dispatch("close-modal",{id:"element-picker-modal"});return}const t=e.findIndex(a=>a.id===this.activeRow.id);if(t===-1){console.error(`Row with id ${this.activeRow.id} not found in rows store`),this.$dispatch("close-modal",{id:"element-picker-modal"});return}const r=e[t];if(!Array.isArray(r.columns)){console.error(`Row with id ${r.id} has no valid columns array`),this.$dispatch("close-modal",{id:"element-picker-modal"});return}if(this.activeColumnIndex<0||this.activeColumnIndex>=r.columns.length){console.error(`Column index ${this.activeColumnIndex} out of bounds for row with id ${r.id}`),this.$dispatch("close-modal",{id:"element-picker-modal"});return}const n=r.columns[this.activeColumnIndex];if(!Array.isArray(n.elements))n.elements=[];else if(n.elements.length>0){console.warn("Column already has an element. Only one element per column is allowed."),this.$dispatch("close-modal",{id:"element-picker-modal"});return}const s=o.replace(/Filamentor/,"\\Filamentor\\").replace(/Elements/,"Elements\\");let i={};s.includes("Text")?i={text:""}:s.includes("Image")?i={url:null,alt:"",thumbnail:null}:s.includes("Video")&&(i={url:""}),e[t].columns[this.activeColumnIndex].elements.push({id:Date.now(),type:s,content:i}),this.updateCanvasData();try{const a=JSON.stringify(e);this.$wire.saveLayout(a).then(d=>{d&&d.success?console.log("Element added and layout saved successfully"):console.warn("Layout saved but returned unexpected result",d)}).catch(d=>{console.error("Error saving layout after adding element:",d)})}catch(a){console.error("Error stringifying layout after adding element:",a)}this.$dispatch("close-modal",{id:"element-picker-modal"}),this.activeRow=null,this.activeColumnIndex=null}catch(e){console.error("Error adding element:",e),this.$dispatch("close-modal",{id:"element-picker-modal"}),this.activeRow=null,this.activeColumnIndex=null}},editElement(o,e,t=0){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided to editElement");return}if(e==null||isNaN(parseInt(e))){console.error("Invalid column index provided to editElement");return}if(t==null||isNaN(parseInt(t))){console.error("Invalid element index provided to editElement");return}const r=Alpine.store("rows").items;if(!r||!Array.isArray(r)){console.error("Rows store not properly initialized");return}const n=r.find(l=>l.id===o.id);if(!n){console.error(`Row with id ${o.id} not found in rows store`);return}if(!Array.isArray(n.columns)||e>=n.columns.length){console.error(`Column index ${e} out of bounds for row with id ${o.id}`);return}const s=n.columns[e];if(!Array.isArray(s.elements)||s.elements.length===0){console.error(`No elements found in column ${e} of row with id ${o.id}`);return}if(t>=s.elements.length){console.error(`Element index ${t} out of bounds for column ${e}`);return}const i=s.elements[t];if(!i||!i.type){console.error(`Invalid element at index ${t}`);return}this.activeRow=n,this.activeColumnIndex=e,this.activeElementIndex=t,this.activeElement=i;const a=i.type;if(!a){console.error("Element has no type");return}try{this.$wire.set("elementData",{text:{content:null},image:{url:null,alt:null,thumbnail:null},video:{url:null}})}catch(l){console.error("Error resetting element data:",l);return}try{if(a.includes("Text")){const l=i.content&&typeof i.content.text<"u"?i.content.text:"";this.$wire.set("elementData.text.content",l)}else if(a.includes("Image")){const l={url:i.content&&i.content.url?i.content.url:null,alt:i.content&&i.content.alt?i.content.alt:"",thumbnail:i.content&&i.content.thumbnail?i.content.thumbnail:null};this.$wire.set("elementData.image",l)}else if(a.includes("Video")){const l=i.content&&i.content.url?i.content.url:"";this.$wire.set("elementData.video.url",l)}else console.warn(`Unknown element type: ${a}`)}catch(l){console.error("Error setting element data:",l);return}try{this.$wire.editElement(a,i.content||{},i.id).catch(l=>{console.error("Error in Livewire editElement method:",l)})}catch(l){console.error("Error calling Livewire editElement method:",l);return}const d=a.split("\\").pop()||"Unknown";this.$dispatch("open-modal",{id:"element-editor-modal",title:`Edit ${d} Element`})}catch(r){console.error("Error editing element:",r),this.activeRow=null,this.activeColumnIndex=null,this.activeElementIndex=null,this.activeElement=null}},saveElementContent(o){try{if(!this.activeElement){console.error("No active element to save content for"),this.$dispatch("close-modal",{id:"element-editor-modal"});return}if(!this.activeRow||this.activeColumnIndex===null||this.activeElementIndex===null){console.error("Missing required references for element update"),this.$dispatch("close-modal",{id:"element-editor-modal"});return}const e=Alpine.store("rows").items;if(!e||!Array.isArray(e)){console.error("Rows store not properly initialized"),this.$dispatch("close-modal",{id:"element-editor-modal"});return}const t=e.findIndex(a=>a.id===this.activeRow.id);if(t===-1){console.error(`Row with id ${this.activeRow.id} not found in rows store`),this.$dispatch("close-modal",{id:"element-editor-modal"});return}const r=e[t];if(!Array.isArray(r.columns)||this.activeColumnIndex>=r.columns.length){console.error(`Column index ${this.activeColumnIndex} out of bounds for row with id ${r.id}`),this.$dispatch("close-modal",{id:"element-editor-modal"});return}const n=r.columns[this.activeColumnIndex];if(!Array.isArray(n.elements)||this.activeElementIndex>=n.elements.length){console.error(`Element index ${this.activeElementIndex} out of bounds for column ${this.activeColumnIndex}`),this.$dispatch("close-modal",{id:"element-editor-modal"});return}const s=this.activeElement.type,i=()=>{try{const a=JSON.stringify(e);this.$wire.saveLayout(a).then(d=>{d&&d.success?console.log("Element content updated and layout saved successfully"):console.warn("Layout saved but returned unexpected result",d),this.$dispatch("close-modal",{id:"element-editor-modal"})}).catch(d=>{console.error("Error saving layout after updating element content:",d),this.$dispatch("close-modal",{id:"element-editor-modal"})})}catch(a){console.error("Error processing layout data:",a),this.$dispatch("close-modal",{id:"element-editor-modal"})}};if(s.includes("Image")){const a=this.$wire.get("elementData.image.alt")||"";this.$wire.uploadMedia().then(d=>{if(d&&d.url)e[t].columns[this.activeColumnIndex].elements[this.activeElementIndex]={...this.activeElement,content:{url:d.url,thumbnail:d.thumbnail,alt:a}};else{const l=this.activeElement.content||{};e[t].columns[this.activeColumnIndex].elements[this.activeElementIndex]={...this.activeElement,content:{url:l.url||"",thumbnail:l.thumbnail||"",alt:a}}}i()}).catch(d=>{console.error("Error during image processing:",d);try{const l=this.activeElement.content||{};e[t].columns[this.activeColumnIndex].elements[this.activeElementIndex]={...this.activeElement,content:{url:l.url||"",thumbnail:l.thumbnail||"",alt:a}},i()}catch(l){console.error("Error saving alt text after upload failure:",l),this.$dispatch("close-modal",{id:"element-editor-modal"})}})}else if(s.includes("Video"))try{const a=this.$wire.get("elementData.video.url");a||console.warn("Empty video URL provided"),e[t].columns[this.activeColumnIndex].elements[this.activeElementIndex]={...this.activeElement,content:{url:a||""}},i()}catch(a){console.error("Error updating video element:",a),this.$dispatch("close-modal",{id:"element-editor-modal"})}else if(s.includes("Text"))try{const a=this.$wire.get("elementData.text.content");e[t].columns[this.activeColumnIndex].elements[this.activeElementIndex]={...this.activeElement,content:{text:a||""}},i()}catch(a){console.error("Error updating text element:",a),this.$dispatch("close-modal",{id:"element-editor-modal"})}else console.error(`Unknown element type: ${s}`),this.$dispatch("close-modal",{id:"element-editor-modal"})}catch(e){console.error("Error saving element content:",e),this.$dispatch("close-modal",{id:"element-editor-modal"}),this.activeRow=null,this.activeColumnIndex=null,this.activeElementIndex=null,this.activeElement=null}},deleteElement(o,e,t=0){try{if(!o||typeof o!="object"||!o.id){console.error("Invalid row provided to deleteElement");return}if(e==null||isNaN(parseInt(e))){console.error("Invalid column index provided to deleteElement");return}if(t==null||isNaN(parseInt(t))){console.error("Invalid element index provided to deleteElement");return}const r=Alpine.store("rows").items;if(!r||!Array.isArray(r)){console.error("Rows store not properly initialized");return}const n=r.findIndex(i=>i.id===o.id);if(n===-1){console.error(`Row with id ${o.id} not found in rows store`);return}if(!Array.isArray(r[n].columns)||e>=r[n].columns.length){console.error(`Column index ${e} out of bounds for row with id ${o.id}`);return}const s=r[n].columns[e];if(!Array.isArray(s.elements)){console.error(`Column ${e} has no elements array`);return}if(t>=s.elements.length){console.error(`Element index ${t} out of bounds for column ${e}`);return}r[n].columns[e].elements.splice(t,1),this.updateCanvasData();try{const i=JSON.stringify(r);this.$wire.saveLayout(i).then(a=>{a&&a.success?console.log("Element deleted and layout saved successfully"):console.warn("Layout saved after element deletion but returned unexpected result",a)}).catch(a=>{console.error("Error saving layout after element deletion:",a)})}catch(i){console.error("Error stringifying layout after element deletion:",i)}}catch(r){console.error("Error deleting element:",r),this.updateCanvasData()}},updateCanvasData(){try{const o=Alpine.store("rows").items;if(!o){console.error("Rows store is not properly initialized");return}let e;try{e=JSON.stringify(o)}catch(t){console.error("Error converting layout data to JSON:",t);return}if(this.$refs.canvasData)try{this.$refs.canvasData.value=e}catch(t){console.error("Error updating canvas data reference:",t)}else console.warn("Canvas data reference not found in DOM");try{this.$wire.set("data.layout",e).catch(t=>{console.error("Error updating Livewire data.layout property:",t)})}catch(t){console.error("Error calling Livewire set method:",t)}console.log("Canvas data updated successfully")}catch(o){console.error("Unexpected error in updateCanvasData:",o)}},safeParseNumber(o){try{const e=Number(o);return isNaN(e)?0:e}catch{return 0}}}))})}); 3 | -------------------------------------------------------------------------------- /resources/views/pages/builder.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | {{ $this->form }} 4 | 5 |
6 | 7 | Save Page 8 | 9 |
10 | 11 | 12 |
16 | 17 |
18 | 24 |
25 | 26 | 28 | 29 |
30 | 31 |
32 | 33 |

No rows yet

34 |

Get started by adding your first row.

35 |
36 | 37 | 192 | 193 | 194 | 195 | 196 | Row Settings 197 | 198 | 199 | @inject('registry', 'Geosem42\Filamentor\Support\ElementRegistry') 200 | 201 | 202 |
203 |

Spacing

204 | 205 | 206 |
207 | 209 |
210 |
211 | 215 | Top 216 |
217 |
218 | 222 | Right 223 |
224 |
225 | 229 | Bottom 230 |
231 |
232 | 236 | Left 237 |
238 |
239 |
240 | 241 | 242 |
243 | 245 |
246 |
247 | 251 | Top 252 |
253 |
254 | 258 | Right 259 |
260 |
261 | 265 | Bottom 266 |
267 |
268 | 272 | Left 273 |
274 |
275 |
276 | 277 | 278 |
279 |

Custom Classes

280 | 284 |
285 | 286 | 287 | 288 | Save Changes 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | Column Settings 297 | 298 | 299 |
300 | 301 |
302 |

Spacing

303 | 304 | 305 |
306 | 308 |
309 |
310 | 314 | Top 315 |
316 |
317 | 321 | Right 322 |
323 |
324 | 328 | Bottom 329 |
330 |
331 | 335 | Left 336 |
337 |
338 |
339 | 340 | 341 |
342 | 344 |
345 |
346 | 350 | Top 351 |
352 |
353 | 357 | Right 358 |
359 |
360 | 364 | Bottom 365 |
366 |
367 | 371 | Left 372 |
373 |
374 |
375 |
376 | 377 | 378 |
379 |

Custom Classes

380 | 384 |
385 |
386 | 387 | 388 | 389 | Save Changes 390 | 391 | 392 |
393 | 394 | 395 | 396 | 397 | Add Element 398 | 399 | 400 |
401 | @foreach($registry->getElements() as $element) 402 | 408 | @endforeach 409 |
410 |
411 | 412 | 413 | 414 | 415 | Edit {{ str_contains($activeElementType ?? '', 'Text') ? 'Text' : (str_contains($activeElementType ?? '', 'Image') ? 'Image' : 'Video') }} Element 416 | 417 | 418 |
419 | 420 |
421 | 422 | 423 | 424 | 425 |
426 | 427 | 428 |
429 | {{ $this->getElementForm() }} 430 |
431 | 432 | 433 | 434 | Save Content 435 | 436 | 437 |
438 |
439 | 440 | 441 | 442 | 443 | Delete Row 444 | 445 | 446 |
447 |

This row contains elements that will be permanently deleted. Would you like to proceed?

448 |
449 | 450 | 451 |
452 | 454 | Cancel 455 | 456 | 457 | 458 | Delete 459 | 460 |
461 |
462 |
463 | 464 | 465 | 466 | 467 | Delete Column 468 | 469 | 470 |
471 |

This column contains elements that will be permanently deleted. Would you like to proceed?

472 |
473 | 474 | 475 |
476 | 478 | Cancel 479 | 480 | 481 | 482 | Delete 483 | 484 |
485 |
486 |
487 |
488 |
489 | 490 | -------------------------------------------------------------------------------- /dist/alpine-sort.js: -------------------------------------------------------------------------------- 1 | (()=>{function le(o,t){var e=Object.keys(o);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(o);t&&(n=n.filter(function(i){return Object.getOwnPropertyDescriptor(o,i).enumerable})),e.push.apply(e,n)}return e}function K(o){for(var t=1;t=0)&&(e[i]=o[i]);return e}function ke(o,t){if(o==null)return{};var e=Re(o,t),n,i;if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(o);for(i=0;i=0)&&Object.prototype.propertyIsEnumerable.call(o,n)&&(e[n]=o[n])}return e}var He="1.15.2";function $(o){if(typeof window<"u"&&window.navigator)return!!navigator.userAgent.match(o)}var q=$(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),Ot=$(/Edge/i),se=$(/firefox/i),Et=$(/safari/i)&&!$(/chrome/i)&&!$(/android/i),me=$(/iP(ad|od|hone)/i),ve=$(/chrome/i)&&$(/android/i),be={capture:!1,passive:!1};function y(o,t,e){o.addEventListener(t,e,!q&&be)}function b(o,t,e){o.removeEventListener(t,e,!q&&be)}function Xt(o,t){if(t){if(t[0]===">"&&(t=t.substring(1)),o)try{if(o.matches)return o.matches(t);if(o.msMatchesSelector)return o.msMatchesSelector(t);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(t)}catch{return!1}return!1}}function Xe(o){return o.host&&o!==document&&o.host.nodeType?o.host:o.parentNode}function G(o,t,e,n){if(o){e=e||document;do{if(t!=null&&(t[0]===">"?o.parentNode===e&&Xt(o,t):Xt(o,t))||n&&o===e)return o;if(o===e)break}while(o=Xe(o))}return null}var ue=/\s+/g;function F(o,t,e){if(o&&t)if(o.classList)o.classList[e?"add":"remove"](t);else{var n=(" "+o.className+" ").replace(ue," ").replace(" "+t+" "," ");o.className=(n+(e?" "+t:"")).replace(ue," ")}}function h(o,t,e){var n=o&&o.style;if(n){if(e===void 0)return document.defaultView&&document.defaultView.getComputedStyle?e=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(e=o.currentStyle),t===void 0?e:e[t];!(t in n)&&t.indexOf("webkit")===-1&&(t="-webkit-"+t),n[t]=e+(typeof e=="string"?"":"px")}}function ct(o,t){var e="";if(typeof o=="string")e=o;else do{var n=h(o,"transform");n&&n!=="none"&&(e=n+" "+e)}while(!t&&(o=o.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(e)}function ye(o,t,e){if(o){var n=o.getElementsByTagName(t),i=0,r=n.length;if(e)for(;i=r:a=i<=r,!a)return n;if(n===W())break;n=tt(n,!1)}return!1}function dt(o,t,e,n){for(var i=0,r=0,a=o.children;r2&&arguments[2]!==void 0?arguments[2]:{},i=n.evt,r=ke(n,ze);Tt.pluginEvent.bind(p)(t,e,K({dragEl:f,parentEl:_,ghostEl:g,rootEl:S,nextEl:at,lastDownEl:Ft,cloneEl:D,cloneHidden:J,dragStarted:bt,putSortable:T,activeSortable:p.active,originalEvent:i,oldIndex:ft,oldDraggableIndex:Dt,newIndex:R,newDraggableIndex:Q,hideGhostForTarget:Te,unhideGhostForTarget:Ae,cloneNowHidden:function(){J=!0},cloneNowShown:function(){J=!1},dispatchSortableEvent:function(l){x({sortable:e,name:l,originalEvent:i})}},r))};function x(o){je(K({putSortable:T,cloneEl:D,targetEl:f,rootEl:S,oldIndex:ft,oldDraggableIndex:Dt,newIndex:R,newDraggableIndex:Q},o))}var f,_,g,S,at,Ft,D,J,ft,R,Dt,Q,It,T,ut=!1,Yt=!1,Bt=[],it,B,zt,$t,de,he,bt,st,_t,Ct=!1,xt=!1,Rt,A,Ut=[],Jt=!1,Gt=[],Wt=typeof document<"u",Nt=me,pe=Ot||q?"cssFloat":"float",$e=Wt&&!ve&&!me&&"draggable"in document.createElement("div"),_e=function(){if(Wt){if(q)return!1;var o=document.createElement("x");return o.style.cssText="pointer-events:auto",o.style.pointerEvents==="auto"}}(),Ce=function(t,e){var n=h(t),i=parseInt(n.width)-parseInt(n.paddingLeft)-parseInt(n.paddingRight)-parseInt(n.borderLeftWidth)-parseInt(n.borderRightWidth),r=dt(t,0,e),a=dt(t,1,e),l=r&&h(r),s=a&&h(a),u=l&&parseInt(l.marginLeft)+parseInt(l.marginRight)+O(r).width,d=s&&parseInt(s.marginLeft)+parseInt(s.marginRight)+O(a).width;if(n.display==="flex")return n.flexDirection==="column"||n.flexDirection==="column-reverse"?"vertical":"horizontal";if(n.display==="grid")return n.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(r&&l.float&&l.float!=="none"){var c=l.float==="left"?"left":"right";return a&&(s.clear==="both"||s.clear===c)?"vertical":"horizontal"}return r&&(l.display==="block"||l.display==="flex"||l.display==="table"||l.display==="grid"||u>=i&&n[pe]==="none"||a&&n[pe]==="none"&&u+d>i)?"vertical":"horizontal"},Ue=function(t,e,n){var i=n?t.left:t.top,r=n?t.right:t.bottom,a=n?t.width:t.height,l=n?e.left:e.top,s=n?e.right:e.bottom,u=n?e.width:e.height;return i===l||r===s||i+a/2===l+u/2},qe=function(t,e){var n;return Bt.some(function(i){var r=i[k].options.emptyInsertThreshold;if(!(!r||oe(i))){var a=O(i),l=t>=a.left-r&&t<=a.right+r,s=e>=a.top-r&&e<=a.bottom+r;if(l&&s)return n=i}}),n},Oe=function(t){function e(r,a){return function(l,s,u,d){var c=l.options.group.name&&s.options.group.name&&l.options.group.name===s.options.group.name;if(r==null&&(a||c))return!0;if(r==null||r===!1)return!1;if(a&&r==="clone")return r;if(typeof r=="function")return e(r(l,s,u,d),a)(l,s,u,d);var m=(a?l:s).options.group.name;return r===!0||typeof r=="string"&&r===m||r.join&&r.indexOf(m)>-1}}var n={},i=t.group;(!i||Pt(i)!="object")&&(i={name:i}),n.name=i.name,n.checkPull=e(i.pull,!0),n.checkPut=e(i.put),n.revertClone=i.revertClone,t.group=n},Te=function(){!_e&&g&&h(g,"display","none")},Ae=function(){!_e&&g&&h(g,"display","")};Wt&&!ve&&document.addEventListener("click",function(o){if(Yt)return o.preventDefault(),o.stopPropagation&&o.stopPropagation(),o.stopImmediatePropagation&&o.stopImmediatePropagation(),Yt=!1,!1},!0);var rt=function(t){if(f){t=t.touches?t.touches[0]:t;var e=qe(t.clientX,t.clientY);if(e){var n={};for(var i in t)t.hasOwnProperty(i)&&(n[i]=t[i]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[k]._onDragOver(n)}}},Ve=function(t){f&&f.parentNode[k]._isOutsideThisEl(t.target)};function p(o,t){if(!(o&&o.nodeType&&o.nodeType===1))throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(o));this.el=o,this.options=t=U({},t),o[k]=this;var e={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(o.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ce(o,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(a,l){a.setData("Text",l.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:p.supportPointer!==!1&&"PointerEvent"in window&&!Et,emptyInsertThreshold:5};Tt.initializePlugins(this,o,e);for(var n in e)!(n in t)&&(t[n]=e[n]);Oe(t);for(var i in this)i.charAt(0)==="_"&&typeof this[i]=="function"&&(this[i]=this[i].bind(this));this.nativeDraggable=t.forceFallback?!1:$e,this.nativeDraggable&&(this.options.touchStartThreshold=1),t.supportPointer?y(o,"pointerdown",this._onTapStart):(y(o,"mousedown",this._onTapStart),y(o,"touchstart",this._onTapStart)),this.nativeDraggable&&(y(o,"dragover",this),y(o,"dragenter",this)),Bt.push(this.el),t.store&&t.store.get&&this.sort(t.store.get(this)||[]),U(this,Le())}p.prototype={constructor:p,_isOutsideThisEl:function(t){!this.el.contains(t)&&t!==this.el&&(st=null)},_getDirection:function(t,e){return typeof this.options.direction=="function"?this.options.direction.call(this,t,e,f):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,i=this.options,r=i.preventOnFilter,a=t.type,l=t.touches&&t.touches[0]||t.pointerType&&t.pointerType==="touch"&&t,s=(l||t).target,u=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||s,d=i.filter;if(rn(n),!f&&!(/mousedown|pointerdown/.test(a)&&t.button!==0||i.disabled)&&!u.isContentEditable&&!(!this.nativeDraggable&&Et&&s&&s.tagName.toUpperCase()==="SELECT")&&(s=G(s,i.draggable,n,!1),!(s&&s.animated)&&Ft!==s)){if(ft=H(s),Dt=H(s,i.draggable),typeof d=="function"){if(d.call(this,t,s,this)){x({sortable:e,rootEl:u,name:"filter",targetEl:s,toEl:n,fromEl:n}),N("filter",e,{evt:t}),r&&t.cancelable&&t.preventDefault();return}}else if(d&&(d=d.split(",").some(function(c){if(c=G(u,c.trim(),n,!1),c)return x({sortable:e,rootEl:c,name:"filter",targetEl:s,fromEl:n,toEl:n}),N("filter",e,{evt:t}),!0}),d)){r&&t.cancelable&&t.preventDefault();return}i.handle&&!G(u,i.handle,n,!1)||this._prepareDragStart(t,l,s)}}},_prepareDragStart:function(t,e,n){var i=this,r=i.el,a=i.options,l=r.ownerDocument,s;if(n&&!f&&n.parentNode===r){var u=O(n);if(S=r,f=n,_=f.parentNode,at=f.nextSibling,Ft=n,It=a.group,p.dragged=f,it={target:f,clientX:(e||t).clientX,clientY:(e||t).clientY},de=it.clientX-u.left,he=it.clientY-u.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,f.style["will-change"]="all",s=function(){if(N("delayEnded",i,{evt:t}),p.eventCanceled){i._onDrop();return}i._disableDelayedDragEvents(),!se&&i.nativeDraggable&&(f.draggable=!0),i._triggerDragStart(t,e),x({sortable:i,name:"choose",originalEvent:t}),F(f,a.chosenClass,!0)},a.ignore.split(",").forEach(function(d){ye(f,d.trim(),qt)}),y(l,"dragover",rt),y(l,"mousemove",rt),y(l,"touchmove",rt),y(l,"mouseup",i._onDrop),y(l,"touchend",i._onDrop),y(l,"touchcancel",i._onDrop),se&&this.nativeDraggable&&(this.options.touchStartThreshold=4,f.draggable=!0),N("delayStart",this,{evt:t}),a.delay&&(!a.delayOnTouchOnly||e)&&(!this.nativeDraggable||!(Ot||q))){if(p.eventCanceled){this._onDrop();return}y(l,"mouseup",i._disableDelayedDrag),y(l,"touchend",i._disableDelayedDrag),y(l,"touchcancel",i._disableDelayedDrag),y(l,"mousemove",i._delayedDragTouchMoveHandler),y(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&y(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(s,a.delay)}else s()}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){f&&qt(f),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;b(t,"mouseup",this._disableDelayedDrag),b(t,"touchend",this._disableDelayedDrag),b(t,"touchcancel",this._disableDelayedDrag),b(t,"mousemove",this._delayedDragTouchMoveHandler),b(t,"touchmove",this._delayedDragTouchMoveHandler),b(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||t.pointerType=="touch"&&t,!this.nativeDraggable||e?this.options.supportPointer?y(document,"pointermove",this._onTouchMove):e?y(document,"touchmove",this._onTouchMove):y(document,"mousemove",this._onTouchMove):(y(f,"dragend",this),y(S,"dragstart",this._onDragStart));try{document.selection?kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch{}},_dragStarted:function(t,e){if(ut=!1,S&&f){N("dragStarted",this,{evt:e}),this.nativeDraggable&&y(document,"dragover",Ve);var n=this.options;!t&&F(f,n.dragClass,!1),F(f,n.ghostClass,!0),p.active=this,t&&this._appendGhost(),x({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(B){this._lastX=B.clientX,this._lastY=B.clientY,Te();for(var t=document.elementFromPoint(B.clientX,B.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(B.clientX,B.clientY),t!==e);)e=t;if(f.parentNode[k]._isOutsideThisEl(t),e)do{if(e[k]){var n=void 0;if(n=e[k]._onDragOver({clientX:B.clientX,clientY:B.clientY,target:t,rootEl:e}),n&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);Ae()}},_onTouchMove:function(t){if(it){var e=this.options,n=e.fallbackTolerance,i=e.fallbackOffset,r=t.touches?t.touches[0]:t,a=g&&ct(g,!0),l=g&&a&&a.a,s=g&&a&&a.d,u=Nt&&A&&ce(A),d=(r.clientX-it.clientX+i.x)/(l||1)+(u?u[0]-Ut[0]:0)/(l||1),c=(r.clientY-it.clientY+i.y)/(s||1)+(u?u[1]-Ut[1]:0)/(s||1);if(!p.active&&!ut){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))=0&&(x({rootEl:_,name:"add",toEl:_,fromEl:S,originalEvent:t}),x({sortable:this,name:"remove",toEl:_,originalEvent:t}),x({rootEl:_,name:"sort",toEl:_,fromEl:S,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),T&&T.save()):R!==ft&&R>=0&&(x({sortable:this,name:"update",toEl:_,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),p.active&&((R==null||R===-1)&&(R=ft,Q=Dt),x({sortable:this,name:"end",toEl:_,originalEvent:t}),this.save()))),this._nulling()},_nulling:function(){N("nulling",this),S=f=_=g=at=D=Ft=J=it=B=bt=R=Q=ft=Dt=st=_t=T=It=p.dragged=p.ghost=p.clone=p.active=null,Gt.forEach(function(t){t.checked=!0}),Gt.length=zt=$t=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":f&&(this._onDragOver(t),Ze(t));break;case"selectstart":t.preventDefault();break}},toArray:function(){for(var t=[],e,n=this.el.children,i=0,r=n.length,a=this.options;ii.right+r||o.clientY>n.bottom&&o.clientX>n.left:o.clientY>i.bottom+r||o.clientX>n.right&&o.clientY>n.top}function en(o,t,e,n,i,r,a,l){var s=n?o.clientY:o.clientX,u=n?e.height:e.width,d=n?e.top:e.left,c=n?e.bottom:e.right,m=!1;if(!a){if(l&&Rtd+u*r/2:sc-Rt)return-_t}else if(s>d+u*(1-i)/2&&sc-u*r/2)?s>d+u/2?1:-1:0}function nn(o){return H(f){if(e==="config"||e==="handle"||e==="group")return;if(e==="key"||e==="item"){if([void 0,null,""].includes(i))return;t._x_sort_key=a(i);return}let u={hideGhost:!n.includes("ghost"),useHandles:!!t.querySelector("[x-sort\\:handle]"),group:cn(t,n)},d=ln(i,l),c=sn(t,n,a),m=un(t,c,u,(w,v)=>{d(w,v)});s(()=>m.destroy())})}function ln(o,t){if([void 0,null,""].includes(o))return()=>{};let e=t(o);return(n,i)=>{Alpine.dontAutoEvaluateFunctions(()=>{e(r=>{typeof r=="function"&&r(n,i)},{scope:{$key:n,$item:n,$position:i}})})}}function sn(o,t,e){return o.hasAttribute("x-sort:config")?e(o.getAttribute("x-sort:config")):{}}function un(o,t,e,n){let i,r={animation:150,handle:e.useHandles?"[x-sort\\:handle]":null,group:e.group,filter(a){return o.querySelector("[x-sort\\:item]")?!a.target.closest("[x-sort\\:item]"):!1},onSort(a){if(a.from!==a.to&&a.to!==a.target)return;let l=a.item._x_sort_key,s=a.newIndex;(l!==void 0||l!==null)&&n(l,s)},onStart(){document.body.classList.add("sorting"),i=document.querySelector(".sortable-ghost"),e.hideGhost&&i&&(i.style.opacity="0")},onEnd(){document.body.classList.remove("sorting"),e.hideGhost&&i&&(i.style.opacity="1"),i=void 0,fn(o)}};return new xe(o,{...r,...t})}function fn(o){let t=o.firstChild;for(;t.nextSibling;){if(t.textContent.trim()==="[if ENDBLOCK]>{window.Alpine.plugin(Ne)});})(); 2 | /*! Bundled license information: 3 | 4 | sortablejs/modular/sortable.esm.js: 5 | (**! 6 | * Sortable 1.15.2 7 | * @author RubaXa 8 | * @author owenm 9 | * @license MIT 10 | *) 11 | */ 12 | --------------------------------------------------------------------------------