style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
@@ -1,12 +1,38 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class ContentComponent implements BuilderComponent
{
public function type(): string { return 'content'; }
public function label(): string { return 'Rich Content'; }
public function category(): string { return 'Content'; }
public function schema(): array { return ['content'=>['type'=>'textarea','required'=>true]]; }
public function defaults(): array { return ['content'=>'<p>Add your content here.</p>']; }
public function render(array $properties, array $context=[]): string { return (string)($properties['content']??''); }
public function type(): string
{
return 'content';
}
public function label(): string
{
return 'Rich Content';
}
public function category(): string
{
return 'Content';
}
public function schema(): array
{
return ['content' => ['type' => 'textarea', 'required' => true]];
}
public function defaults(): array
{
return ['content' => '<p>Add your content here.</p>'];
}
public function render(array $properties, array $context = []): string
{
return (string) ($properties['content'] ?? '');
}
}
@@ -1,12 +1,40 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class HeadingComponent implements BuilderComponent
{
public function type(): string { return 'heading'; }
public function label(): string { return 'Heading'; }
public function category(): string { return 'Typography'; }
public function schema(): array { return ['text'=>['type'=>'text','required'=>true],'level'=>['type'=>'select','options'=>[1,2,3,4,5,6]]]; }
public function defaults(): array { return ['text'=>'Heading','level'=>2]; }
public function render(array $properties, array $context=[]): string { $level=max(1,min(6,(int)($properties['level']??2))); return '<h'.$level.'>'.e($properties['text']??'').'</h'.$level.'>'; }
public function type(): string
{
return 'heading';
}
public function label(): string
{
return 'Heading';
}
public function category(): string
{
return 'Typography';
}
public function schema(): array
{
return ['text' => ['type' => 'text', 'required' => true], 'level' => ['type' => 'select', 'options' => [1, 2, 3, 4, 5, 6]]];
}
public function defaults(): array
{
return ['text' => 'Heading', 'level' => 2];
}
public function render(array $properties, array $context = []): string
{
$level = max(1, min(6, (int) ($properties['level'] ?? 2)));
return '<h'.$level.'>'.e($properties['text'] ?? '').'</h'.$level.'>';
}
}
+32 -6
View File
@@ -1,12 +1,38 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class HeroComponent implements BuilderComponent
{
public function type(): string { return 'hero'; }
public function label(): string { return 'Hero'; }
public function category(): string { return 'Content'; }
public function schema(): array { return ['heading'=>['type'=>'text','required'=>true],'content'=>['type'=>'textarea'],'button_label'=>['type'=>'text'],'button_url'=>['type'=>'text']]; }
public function defaults(): array { return ['heading'=>'A compelling headline','content'=>'Add supporting content here.','button_label'=>'Learn more','button_url'=>'#']; }
public function render(array $properties, array $context=[]): string { return view('builder::components.hero',['properties'=>$properties])->render(); }
public function type(): string
{
return 'hero';
}
public function label(): string
{
return 'Hero';
}
public function category(): string
{
return 'Content';
}
public function schema(): array
{
return ['heading' => ['type' => 'text', 'required' => true], 'content' => ['type' => 'textarea'], 'button_label' => ['type' => 'text'], 'button_url' => ['type' => 'text']];
}
public function defaults(): array
{
return ['heading' => 'A compelling headline', 'content' => 'Add supporting content here.', 'button_label' => 'Learn more', 'button_url' => '#'];
}
public function render(array $properties, array $context = []): string
{
return view('builder::components.hero', ['properties' => $properties])->render();
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
<?php
return [
'max_document_size_kb' => (int) env('BUILDER_MAX_DOCUMENT_SIZE_KB', 512),
'autosave_seconds' => (int) env('BUILDER_AUTOSAVE_SECONDS', 30),
'preview_route' => env('BUILDER_PREVIEW_ROUTE', 'builder.preview'),
'allowed_types' => ['page','form','dashboard','report','menu','email','query','workflow'],
'allowed_types' => ['page', 'form', 'dashboard', 'report', 'menu', 'email', 'query', 'workflow'],
];
@@ -1,11 +1,18 @@
<?php
namespace Modules\Builder\Contracts;
interface BuilderComponent
{
public function type(): string;
public function label(): string;
public function category(): string;
public function schema(): array;
public function defaults(): array;
public function render(array $properties, array $context = []): string;
}
@@ -1,31 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('builder_documents', function (Blueprint $table) {
$table->id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('type',30)->index(); $table->string('name'); $table->string('slug')->nullable();
$table->string('status',20)->default('draft')->index(); $table->unsignedInteger('version')->default(1);
$table->json('document')->nullable(); $table->json('settings')->nullable();
$table->nullableMorphs('buildable'); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->id();
$table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
$table->string('type', 30)->index();
$table->string('name');
$table->string('slug')->nullable();
$table->string('status', 20)->default('draft')->index();
$table->unsignedInteger('version')->default(1);
$table->json('document')->nullable();
$table->json('settings')->nullable();
$table->nullableMorphs('buildable');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('published_at')->nullable(); $table->timestamps();
$table->unique(['site_id','type','slug'],'builder_document_site_type_slug_unique');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->unique(['site_id', 'type', 'slug'], 'builder_document_site_type_slug_unique');
});
Schema::create('builder_document_versions', function (Blueprint $table) {
$table->id(); $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete();
$table->unsignedInteger('version'); $table->json('document'); $table->json('settings')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
$table->unique(['builder_document_id','version']);
$table->id();
$table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete();
$table->unsignedInteger('version');
$table->json('document');
$table->json('settings')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['builder_document_id', 'version']);
});
Schema::create('builder_templates', function (Blueprint $table) {
$table->id(); $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->unique();
$table->text('description')->nullable(); $table->json('document'); $table->json('settings')->nullable();
$table->boolean('is_system')->default(false); $table->boolean('is_active')->default(true)->index(); $table->timestamps();
$table->id();
$table->string('type', 30)->index();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->json('document');
$table->json('settings')->nullable();
$table->boolean('is_system')->default(false);
$table->boolean('is_active')->default(true)->index();
$table->timestamps();
});
}
public function down(): void { Schema::dropIfExists('builder_templates'); Schema::dropIfExists('builder_document_versions'); Schema::dropIfExists('builder_documents'); }
public function down(): void
{
Schema::dropIfExists('builder_templates');
Schema::dropIfExists('builder_document_versions');
Schema::dropIfExists('builder_documents');
}
};
@@ -1,13 +1,27 @@
<?php
namespace Modules\Builder\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Modules\Builder\Models\BuilderDocument;
use Modules\Builder\Services\BuilderRegistry;
use Modules\Builder\Services\BuilderRenderer;
final class BuilderApiController extends Controller
{
public function components(BuilderRegistry $registry): JsonResponse { return response()->json(['data'=>$registry->components(),'widgets'=>$registry->widgets(),'fields'=>$registry->fields()]); }
public function show(BuilderDocument $document): JsonResponse { return response()->json(['data'=>$document]); }
public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse { return response()->json(['html'=>$renderer->render($document->document ?? [])]); }
public function components(BuilderRegistry $registry): JsonResponse
{
return response()->json(['data' => $registry->components(), 'widgets' => $registry->widgets(), 'fields' => $registry->fields()]);
}
public function show(BuilderDocument $document): JsonResponse
{
return response()->json(['data' => $document]);
}
public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse
{
return response()->json(['html' => $renderer->render($document->document ?? [])]);
}
}
@@ -1,5 +1,7 @@
<?php
namespace Modules\Builder\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -8,22 +10,52 @@ use Illuminate\View\View;
use Modules\Builder\Models\BuilderDocument;
use Modules\Builder\Services\BuilderDocumentService;
use Modules\Builder\Services\BuilderRegistry;
final class BuilderController extends Controller
{
public function index(Request $request): View
{
$type=$request->string('type')->toString();
$documents=BuilderDocument::query()->when($type,fn($q)=>$q->where('type',$type))->latest()->paginate(20)->withQueryString();
return view('builder::index',compact('documents','type'));
$type = $request->string('type')->toString();
$documents = BuilderDocument::query()->when($type, fn ($q) => $q->where('type', $type))->latest()->paginate(20)->withQueryString();
return view('builder::index', compact('documents', 'type'));
}
public function create(BuilderRegistry $registry): View { return view('builder::editor',['document'=>new BuilderDocument(['type'=>request('type','page'),'document'=>['nodes'=>[]]]),'components'=>$registry->components()]); }
public function edit(BuilderDocument $document, BuilderRegistry $registry): View { return view('builder::editor',['document'=>$document,'components'=>$registry->components()]); }
public function store(Request $request, BuilderDocumentService $service): RedirectResponse { $document=$service->save(new BuilderDocument(),$this->validated($request),$request->user()?->id); return redirect()->route('administration.builder.edit',$document)->with('status','Builder document created.'); }
public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse { $service->save($document,$this->validated($request),$request->user()?->id); return back()->with('status','Builder document saved.'); }
public function destroy(BuilderDocument $document): RedirectResponse { $document->delete(); return redirect()->route('administration.builder.index')->with('status','Builder document deleted.'); }
public function create(BuilderRegistry $registry): View
{
return view('builder::editor', ['document' => new BuilderDocument(['type' => request('type', 'page'), 'document' => ['nodes' => []]]), 'components' => $registry->components()]);
}
public function edit(BuilderDocument $document, BuilderRegistry $registry): View
{
return view('builder::editor', ['document' => $document, 'components' => $registry->components()]);
}
public function store(Request $request, BuilderDocumentService $service): RedirectResponse
{
$document = $service->save(new BuilderDocument, $this->validated($request), $request->user()?->id);
return redirect()->route('administration.builder.edit', $document)->with('status', 'Builder document created.');
}
public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse
{
$service->save($document, $this->validated($request), $request->user()?->id);
return back()->with('status', 'Builder document saved.');
}
public function destroy(BuilderDocument $document): RedirectResponse
{
$document->delete();
return redirect()->route('administration.builder.index')->with('status', 'Builder document deleted.');
}
private function validated(Request $request): array
{
$data=$request->validate(['type'=>['required','in:'.implode(',',config('builder.allowed_types'))],'name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255'],'status'=>['required','in:draft,published,archived'],'document_json'=>['required','json'],'settings_json'=>['nullable','json']]);
return ['type'=>$data['type'],'name'=>$data['name'],'slug'=>$data['slug'] ?: Str::slug($data['name']),'status'=>$data['status'],'document'=>json_decode($data['document_json'],true,512,JSON_THROW_ON_ERROR),'settings'=>json_decode($data['settings_json'] ?: '{}',true,512,JSON_THROW_ON_ERROR),'published_at'=>$data['status']==='published'?now():null];
$data = $request->validate(['type' => ['required', 'in:'.implode(',', config('builder.allowed_types'))], 'name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,published,archived'], 'document_json' => ['required', 'json'], 'settings_json' => ['nullable', 'json']]);
return ['type' => $data['type'], 'name' => $data['name'], 'slug' => $data['slug'] ?: Str::slug($data['name']), 'status' => $data['status'], 'document' => json_decode($data['document_json'], true, 512, JSON_THROW_ON_ERROR), 'settings' => json_decode($data['settings_json'] ?: '{}', true, 512, JSON_THROW_ON_ERROR), 'published_at' => $data['status'] === 'published' ? now() : null];
}
}
+11 -3
View File
@@ -1,10 +1,18 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class BuilderDocument extends Model
{
protected $fillable=['site_id','type','name','slug','status','version','document','settings','buildable_type','buildable_id','created_by','updated_by','published_at'];
protected $casts=['document'=>'array','settings'=>'array','published_at'=>'datetime'];
public function versions(): HasMany { return $this->hasMany(BuilderDocumentVersion::class); }
protected $fillable = ['site_id', 'type', 'name', 'slug', 'status', 'version', 'document', 'settings', 'buildable_type', 'buildable_id', 'created_by', 'updated_by', 'published_at'];
protected $casts = ['document' => 'array', 'settings' => 'array', 'published_at' => 'datetime'];
public function versions(): HasMany
{
return $this->hasMany(BuilderDocumentVersion::class);
}
}
@@ -1,8 +1,12 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
final class BuilderDocumentVersion extends Model
{
protected $fillable=['builder_document_id','version','document','settings','created_by'];
protected $casts=['document'=>'array','settings'=>'array'];
protected $fillable = ['builder_document_id', 'version', 'document', 'settings', 'created_by'];
protected $casts = ['document' => 'array', 'settings' => 'array'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
final class BuilderTemplate extends Model
{
protected $fillable=['type','name','slug','description','document','settings','is_system','is_active'];
protected $casts=['document'=>'array','settings'=>'array','is_system'=>'boolean','is_active'=>'boolean'];
protected $fillable = ['type', 'name', 'slug', 'description', 'document', 'settings', 'is_system', 'is_active'];
protected $casts = ['document' => 'array', 'settings' => 'array', 'is_system' => 'boolean', 'is_active' => 'boolean'];
}
@@ -1,7 +1,10 @@
<?php
namespace Modules\Builder\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Builder\Services\BuilderRegistry;
final class BuilderServiceProvider extends ServiceProvider
{
public function register(): void
@@ -11,6 +14,7 @@ final class BuilderServiceProvider extends ServiceProvider
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Builder', 'Config/config.php'), 'builder');
}
public function boot(BuilderRegistry $registry): void
{
$this->loadMigrationsFrom(module_path('Builder', 'Database/Migrations'));
@@ -1,7 +1,10 @@
<?php
namespace Modules\Builder\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
+5 -3
View File
@@ -1,9 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Middleware\AuthenticateApiToken;
use Modules\Builder\Http\Controllers\BuilderApiController;
Route::middleware(AuthenticateApiToken::class)->prefix('builder')->name('api.builder.')->group(function () {
Route::get('/components',[BuilderApiController::class,'components'])->name('components');
Route::get('/documents/{document}',[BuilderApiController::class,'show'])->name('show');
Route::get('/documents/{document}/preview',[BuilderApiController::class,'preview'])->name('preview');
Route::get('/components', [BuilderApiController::class, 'components'])->name('components');
Route::get('/documents/{document}', [BuilderApiController::class, 'show'])->name('show');
Route::get('/documents/{document}/preview', [BuilderApiController::class, 'preview'])->name('preview');
});
+9 -7
View File
@@ -1,11 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Builder\Http\Controllers\BuilderController;
Route::middleware(['auth','permission:builder.view'])->prefix('administration/builder')->name('administration.builder.')->group(function () {
Route::get('/',[BuilderController::class,'index'])->name('index');
Route::get('/create',[BuilderController::class,'create'])->middleware('permission:builder.manage')->name('create');
Route::post('/',[BuilderController::class,'store'])->middleware('permission:builder.manage')->name('store');
Route::get('/{document}/edit',[BuilderController::class,'edit'])->middleware('permission:builder.manage')->name('edit');
Route::put('/{document}',[BuilderController::class,'update'])->middleware('permission:builder.manage')->name('update');
Route::delete('/{document}',[BuilderController::class,'destroy'])->middleware('permission:builder.manage')->name('destroy');
Route::middleware(['auth', 'permission:builder.view'])->prefix('administration/builder')->name('administration.builder.')->group(function () {
Route::get('/', [BuilderController::class, 'index'])->name('index');
Route::get('/create', [BuilderController::class, 'create'])->middleware('permission:builder.manage')->name('create');
Route::post('/', [BuilderController::class, 'store'])->middleware('permission:builder.manage')->name('store');
Route::get('/{document}/edit', [BuilderController::class, 'edit'])->middleware('permission:builder.manage')->name('edit');
Route::put('/{document}', [BuilderController::class, 'update'])->middleware('permission:builder.manage')->name('update');
Route::delete('/{document}', [BuilderController::class, 'destroy'])->middleware('permission:builder.manage')->name('destroy');
});
@@ -1,16 +1,28 @@
<?php
namespace Modules\Builder\Services;
use Illuminate\Support\Facades\DB;
use Modules\Builder\Models\BuilderDocument;
final class BuilderDocumentService
{
public function save(BuilderDocument $document, array $payload, ?int $userId=null): BuilderDocument
public function save(BuilderDocument $document, array $payload, ?int $userId = null): BuilderDocument
{
return DB::transaction(function () use ($document,$payload,$userId) {
if ($document->exists && $document->isDirty()) $document->versions()->create(['version'=>$document->version,'document'=>$document->getOriginal('document') ?: [],'settings'=>$document->getOriginal('settings') ?: [],'created_by'=>$userId]);
$document->fill($payload); $document->updated_by=$userId;
if (!$document->exists) $document->created_by=$userId; else $document->version++;
$document->save(); return $document->refresh();
return DB::transaction(function () use ($document, $payload, $userId) {
if ($document->exists && $document->isDirty()) {
$document->versions()->create(['version' => $document->version, 'document' => $document->getOriginal('document') ?: [], 'settings' => $document->getOriginal('settings') ?: [], 'created_by' => $userId]);
}
$document->fill($payload);
$document->updated_by = $userId;
if (! $document->exists) {
$document->created_by = $userId;
} else {
$document->version++;
}
$document->save();
return $document->refresh();
});
}
}
+44 -12
View File
@@ -1,24 +1,56 @@
<?php
namespace Modules\Builder\Services;
use InvalidArgumentException;
use Modules\Builder\Contracts\BuilderComponent;
use Modules\Builder\Components\ContentComponent;
use Modules\Builder\Components\HeadingComponent;
use Modules\Builder\Components\HeroComponent;
use Modules\Builder\Contracts\BuilderComponent;
final class BuilderRegistry
{
private array $components=[];
private array $widgets=[];
private array $fields=[];
private array $components = [];
private array $widgets = [];
private array $fields = [];
public function registerDefaults(): void
{
foreach ([HeroComponent::class, HeadingComponent::class, ContentComponent::class] as $component) $this->registerComponent(new $component());
foreach (['metric','chart','table','activity','calendar'] as $widget) $this->widgets[$widget]=['type'=>$widget,'label'=>str($widget)->headline()->toString()];
foreach (['text','textarea','email','number','select','checkbox','date','file'] as $field) $this->fields[$field]=['type'=>$field,'label'=>str($field)->headline()->toString()];
foreach ([HeroComponent::class, HeadingComponent::class, ContentComponent::class] as $component) {
$this->registerComponent(new $component);
}
foreach (['metric', 'chart', 'table', 'activity', 'calendar'] as $widget) {
$this->widgets[$widget] = ['type' => $widget, 'label' => str($widget)->headline()->toString()];
}
foreach (['text', 'textarea', 'email', 'number', 'select', 'checkbox', 'date', 'file'] as $field) {
$this->fields[$field] = ['type' => $field, 'label' => str($field)->headline()->toString()];
}
}
public function registerComponent(BuilderComponent $component): void
{
$this->components[$component->type()] = $component;
}
public function component(string $type): BuilderComponent
{
return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type].");
}
public function components(): array
{
return array_map(fn (BuilderComponent $c) => ['type' => $c->type(), 'label' => $c->label(), 'category' => $c->category(), 'schema' => $c->schema(), 'defaults' => $c->defaults()], array_values($this->components));
}
public function widgets(): array
{
return array_values($this->widgets);
}
public function fields(): array
{
return array_values($this->fields);
}
public function registerComponent(BuilderComponent $component): void { $this->components[$component->type()]=$component; }
public function component(string $type): BuilderComponent { return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type]."); }
public function components(): array { return array_map(fn(BuilderComponent $c)=>['type'=>$c->type(),'label'=>$c->label(),'category'=>$c->category(),'schema'=>$c->schema(),'defaults'=>$c->defaults()],array_values($this->components)); }
public function widgets(): array { return array_values($this->widgets); }
public function fields(): array { return array_values($this->fields); }
}
+10 -4
View File
@@ -1,13 +1,19 @@
<?php
namespace Modules\Builder\Services;
final class BuilderRenderer
{
public function __construct(private BuilderRegistry $registry) {}
public function render(array $document, array $context=[]): string
public function render(array $document, array $context = []): string
{
return collect($document['nodes'] ?? [])->map(function(array $node) use ($context) {
if (($node['kind'] ?? 'component') === 'container') return '<div class="builder-container">'.$this->render($node,$context).'</div>';
return $this->registry->component((string)($node['type'] ?? 'content'))->render($node['properties'] ?? [],$context);
return collect($document['nodes'] ?? [])->map(function (array $node) use ($context) {
if (($node['kind'] ?? 'component') === 'container') {
return '<div class="builder-container">'.$this->render($node, $context).'</div>';
}
return $this->registry->component((string) ($node['type'] ?? 'content'))->render($node['properties'] ?? [], $context);
})->implode("\n");
}
}
@@ -1,13 +1,16 @@
<?php
namespace Modules\Builder\Tests\Feature;
use Modules\Builder\Services\BuilderRegistry;
use Tests\TestCase;
final class BuilderModuleTest extends TestCase
{
public function test_builder_module_registers_components(): void
{
$components=app(BuilderRegistry::class)->components();
$components = app(BuilderRegistry::class)->components();
$this->assertNotEmpty($components);
$this->assertContains('hero',array_column($components,'type'));
$this->assertContains('hero', array_column($components, 'type'));
}
}