move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+38
View File
@@ -0,0 +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'] ?? '');
}
}
+40
View File
@@ -0,0 +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.'>';
}
}
+38
View File
@@ -0,0 +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();
}
}
+8
View File
@@ -0,0 +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'],
];
+18
View File
@@ -0,0 +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;
}
@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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->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');
});
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']);
});
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();
});
}
public function down(): void
{
Schema::dropIfExists('builder_templates');
Schema::dropIfExists('builder_document_versions');
Schema::dropIfExists('builder_documents');
}
};
@@ -0,0 +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 ?? [])]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Builder\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
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'));
}
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];
}
}
+18
View File
@@ -0,0 +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);
}
}
+12
View File
@@ -0,0 +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'];
}
+12
View File
@@ -0,0 +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'];
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Builder\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Builder\Services\BuilderRegistry;
final class BuilderServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(BuilderRegistry::class);
$this->app->alias(BuilderRegistry::class, 'builder.registry');
$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'));
$this->loadViewsFrom(module_path('Builder', 'Resources/views'), 'builder');
$registry->registerDefaults();
}
}
@@ -0,0 +1,15 @@
<?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
{
Route::middleware('web')->group(module_path('Builder', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Builder', 'Routes/api.php'));
}
}
@@ -0,0 +1 @@
<section class="py-5 text-center"><div class="container"><h1>{{ $properties['heading'] ?? '' }}</h1><p class="lead">{{ $properties['content'] ?? '' }}</p>@if(!empty($properties['button_label']))<a class="btn btn-primary" href="{{ $properties['button_url'] ?? '#' }}">{{ $properties['button_label'] }}</a>@endif</div></section>
+10
View File
@@ -0,0 +1,10 @@
<x-layouts.app :title="__('Builder Editor')">
<div class="flex h-full w-full flex-1 flex-col gap-5 rounded-xl">
<div><flux:heading size="xl">{{ $document->exists ? 'Edit' : 'Create' }} Builder Document</flux:heading><flux:subheading>Use the component palette and JSON document canvas. Visual drag-and-drop clients can use the component API.</flux:subheading></div>
<form method="POST" action="{{ $document->exists ? route('administration.builder.update',$document) : route('administration.builder.store') }}" class="space-y-5">@csrf @if($document->exists) @method('PUT') @endif
<div class="grid gap-4 md:grid-cols-3"><flux:input name="name" label="Name" value="{{ old('name',$document->name) }}" required/><flux:select name="type" label="Builder type">@foreach(config('builder.allowed_types') as $type)<option value="{{ $type }}" @selected(old('type',$document->type)===$type)>{{ str($type)->headline() }}</option>@endforeach</flux:select><flux:select name="status" label="Status"><option value="draft" @selected(old('status',$document->status)==='draft')>Draft</option><option value="published" @selected(old('status',$document->status)==='published')>Published</option><option value="archived" @selected(old('status',$document->status)==='archived')>Archived</option></flux:select></div>
<flux:input name="slug" label="Slug" value="{{ old('slug',$document->slug) }}"/>
<div class="grid gap-5 xl:grid-cols-[260px_1fr]"><aside class="rounded-xl border p-4"><div class="mb-3 font-semibold">Components</div>@foreach($components as $component)<div class="mb-2 rounded-lg border p-3"><div class="font-medium">{{ $component['label'] }}</div><div class="text-xs text-zinc-500">{{ $component['category'] }} · {{ $component['type'] }}</div></div>@endforeach</aside><div class="space-y-4"><flux:textarea name="document_json" label="Document JSON" rows="22" required>{{ old('document_json',json_encode($document->document ?: ['nodes'=>[]],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}</flux:textarea><flux:textarea name="settings_json" label="Settings JSON" rows="6">{{ old('settings_json',json_encode($document->settings ?: new stdClass(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}</flux:textarea></div></div>
<div class="flex gap-3"><flux:button type="submit" variant="primary">Save</flux:button><flux:button :href="route('administration.builder.index')" wire:navigate>Cancel</flux:button></div>
</form>
</div></x-layouts.app>
+7
View File
@@ -0,0 +1,7 @@
<x-layouts.app :title="__('Builder')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex items-center justify-between"><div><flux:heading size="xl">Low-Code Builder</flux:heading><flux:subheading>Pages, forms, dashboards, reports, menus, emails, queries and workflows.</flux:subheading></div>@can('builder.manage')<flux:button variant="primary" :href="route('administration.builder.create')" wire:navigate>Create</flux:button>@endcan</div>
<div class="grid gap-3 sm:grid-cols-4">@foreach(config('builder.allowed_types') as $builderType)<a href="{{ route('administration.builder.index',['type'=>$builderType]) }}" class="rounded-xl border p-4"><div class="font-semibold">{{ str($builderType)->headline() }}</div></a>@endforeach</div>
<div class="overflow-hidden rounded-xl border"><table class="w-full text-sm"><thead><tr class="border-b text-left"><th class="p-3">Name</th><th>Type</th><th>Status</th><th>Version</th><th></th></tr></thead><tbody>@forelse($documents as $document)<tr class="border-b"><td class="p-3 font-medium">{{ $document->name }}</td><td>{{ str($document->type)->headline() }}</td><td>{{ ucfirst($document->status) }}</td><td>{{ $document->version }}</td><td class="text-right p-3"><flux:button size="sm" :href="route('administration.builder.edit',$document)" wire:navigate>Edit</flux:button></td></tr>@empty<tr><td colspan="5" class="p-6 text-center text-zinc-500">No builder documents yet.</td></tr>@endforelse</tbody></table></div>
{{ $documents->links() }}
</div></x-layouts.app>
+11
View File
@@ -0,0 +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');
});
+13
View File
@@ -0,0 +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');
});
@@ -0,0 +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
{
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();
});
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Modules\Builder\Services;
use InvalidArgumentException;
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 = [];
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()];
}
}
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);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Builder\Services;
final class BuilderRenderer
{
public function __construct(private BuilderRegistry $registry) {}
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);
})->implode("\n");
}
}
@@ -0,0 +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();
$this->assertNotEmpty($components);
$this->assertContains('hero', array_column($components, 'type'));
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Builder","alias":"builder","version":"1.0.0","description":"Low-code visual builders for pages, forms, dashboards, reports, menus, emails, queries and workflows.","keywords":["builder","low-code","visual","components","layouts"],"priority":160,"providers":["Modules\\Builder\\Providers\\BuilderServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Pages","Forms","Menus","Workflow","Analytics","Sites"]}