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
+3
View File
@@ -0,0 +1,3 @@
<?php
return ['name' => 'Menus'];
@@ -0,0 +1,26 @@
<?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('menus', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('location');
$table->json('items')->nullable();
$table->boolean('enabled')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('menus');
}
};
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Modules\Menus\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use JsonException;
use Modules\Menus\Models\Menu;
final class MenuController extends Controller
{
public function index(): View
{
return view('menus::admin.index', ['records' => Menu::query()->orderBy('location')->paginate(30)]);
}
public function create(): View
{
return view('menus::admin.form', ['locations' => $this->locations()]);
}
public function store(Request $request): RedirectResponse
{
Menu::create($this->validated($request));
return redirect()->route('administration.menus.index')->with('status', 'Menu created. Every header or footer using this location has been updated.');
}
public function edit(Menu $menu): View
{
return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]);
}
public function update(Request $request, Menu $menu): RedirectResponse
{
$menu->update($this->validated($request));
return redirect()->route('administration.menus.index')->with('status', 'Menu updated everywhere it is used.');
}
public function destroy(Menu $menu): RedirectResponse
{
$menu->delete();
return back()->with('status', 'Menu deleted. Components using its location will use their safe fallback.');
}
/** @return array<string, mixed> */
private function validated(Request $request): array
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_-]+$/'],
'items_json' => ['nullable', 'string'],
'enabled' => ['nullable', 'boolean'],
]);
try {
$items = json_decode($data['items_json'] ?: '[]', true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
throw ValidationException::withMessages(['items_json' => 'The menu items could not be saved. Please reload the editor and try again.']);
}
if (! is_array($items)) {
throw ValidationException::withMessages(['items_json' => 'Menu items must be a list.']);
}
return [
'name' => $data['name'],
'location' => $data['location'],
'items' => array_values($items),
'enabled' => $request->boolean('enabled'),
];
}
/** @return array<string, string> */
private function locations(): array
{
return [
'primary' => 'Primary navigation',
'footer' => 'Footer — Explore',
'footer_account' => 'Footer — Account',
'footer_legal' => 'Footer — Legal',
'mobile' => 'Mobile navigation override',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Menus\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Menu extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'menus';
protected $fillable = ['name', 'location', 'items', 'enabled'];
protected function casts(): array
{
return [
'items' => 'array',
'enabled' => 'boolean',
];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Menus\Providers;
use Illuminate\Support\ServiceProvider;
final class MenusServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Menus', 'Config/config.php'), 'menus');
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Menus', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Menus', 'Resources/views'), 'menus');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Menus\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
parent::boot();
$this->routes(fn () => Route::middleware('web')->group(module_path('Menus', 'Routes/web.php')));
}
}
@@ -0,0 +1,57 @@
<x-layouts.app :title="isset($record) ? __('Edit menu') : __('Create menu')">
@php($initialItems = old('items_json', json_encode($record->items ?? [], JSON_UNESCAPED_SLASHES)))
<div class="mx-auto max-w-6xl space-y-6" x-data="menuEditor(@js(json_decode($initialItems ?: '[]', true) ?: []))">
<div>
<p class="text-sm font-medium text-orange-600">Reusable navigation</p>
<h1 class="text-3xl font-semibold">{{ isset($record) ? 'Edit' : 'Create' }} menu</h1>
<p class="mt-2 text-sm text-zinc-500">Edit links once. Every header and footer assigned to this menu location updates automatically.</p>
</div>
<form method="POST" action="{{ isset($record) ? route('administration.menus.update', $record) : route('administration.menus.store') }}" class="space-y-6">
@csrf
@isset($record) @method('PUT') @endisset
<section class="grid gap-5 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm md:grid-cols-2 dark:border-zinc-700 dark:bg-zinc-900">
<label class="block"><span class="text-sm font-medium">Menu name</span><input type="text" name="name" value="{{ old('name', $record->name ?? '') }}" required class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
<label class="block"><span class="text-sm font-medium">Menu location</span><select name="location" required class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($locations as $value => $label)<option value="{{ $value }}" @selected(old('location', $record->location ?? 'primary') === $value)>{{ $label }}</option>@endforeach</select></label>
<label class="flex items-center gap-3 md:col-span-2"><input type="checkbox" name="enabled" value="1" @checked(old('enabled', $record->enabled ?? true)) class="rounded"><span class="font-medium">Menu enabled</span></label>
</section>
<section class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-5 flex items-center justify-between gap-4"><div><h2 class="text-lg font-semibold">Menu items</h2><p class="text-sm text-zinc-500">Relative paths such as <code>/blog</code>, anchors and full external URLs are supported.</p></div><button type="button" @click="addItem()" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white dark:bg-white dark:text-zinc-900">Add link</button></div>
<input type="hidden" name="items_json" :value="JSON.stringify(items)">
@error('items_json')<p class="mb-4 text-sm text-red-600">{{ $message }}</p>@enderror
<div class="space-y-4">
<template x-for="(item, index) in items" :key="item._key">
<article class="rounded-xl border border-zinc-200 p-5 dark:border-zinc-700">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3"><strong x-text="item.label || `Link ${index + 1}`"></strong><div class="flex gap-2"><button type="button" @click="move(index,-1)" :disabled="index===0" class="rounded border px-3 py-1 disabled:opacity-30">↑</button><button type="button" @click="move(index,1)" :disabled="index===items.length-1" class="rounded border px-3 py-1 disabled:opacity-30">↓</button><button type="button" @click="removeItem(index)" class="rounded border border-red-400 px-3 py-1 text-red-600">Remove</button></div></div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<label><span class="text-sm font-medium">Label</span><input type="text" x-model="item.label" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
<label class="lg:col-span-2"><span class="text-sm font-medium">URL or path</span><input type="text" x-model="item.url" placeholder="/, /blog, #contact or https://…" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"></label>
<label><span class="text-sm font-medium">Open in</span><select x-model="item.target" class="mt-2 w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option value="">Same window</option><option value="_blank">New window</option></select></label>
<label class="flex items-center gap-3"><input type="checkbox" x-model="item.enabled" class="rounded"><span class="text-sm font-medium">Visible</span></label>
</div>
</article>
</template>
<div x-show="items.length===0" class="rounded-xl border border-dashed border-zinc-300 p-10 text-center text-sm text-zinc-500 dark:border-zinc-700">This menu has no links. Add the first link above.</div>
</div>
</section>
<div class="flex justify-end gap-3"><a href="{{ route('administration.menus.index') }}" class="rounded-lg border px-4 py-2">Cancel</a><flux:button type="submit" variant="primary">Save menu</flux:button></div>
</form>
</div>
@push('scripts')
<script>
function menuEditor(initialItems) {
return {
items: (initialItems || []).map(item => ({_key: crypto.randomUUID(), enabled: item.enabled !== false, label: item.label || '', url: item.url || '', target: item.target || ''})),
addItem() { this.items.push({_key: crypto.randomUUID(), enabled: true, label: 'New link', url: '/', target: ''}); },
removeItem(index) { if (confirm('Remove this menu item?')) this.items.splice(index, 1); },
move(index, direction) { const target=index+direction; if(target<0||target>=this.items.length)return; [this.items[index],this.items[target]]=[this.items[target],this.items[index]]; },
};
}
</script>
@endpush
</x-layouts.app>
@@ -0,0 +1,14 @@
<x-layouts.app :title="__('Menus')">
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-sm font-medium text-orange-600">Single source of navigation</p><h1 class="text-3xl font-semibold">Menus</h1><p class="mt-2 text-sm text-zinc-500">Reusable menus update every header and footer that references their location.</p></div><div class="flex gap-3"><a href="{{ route('administration.settings.index') }}" class="rounded-lg border px-4 py-2 text-sm">Site settings</a><a href="{{ route('administration.menus.create') }}" class="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white dark:bg-white dark:text-zinc-900">Add menu</a></div></div>
@if(session('status'))<div class="rounded-xl bg-emerald-50 p-4 text-sm text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>@endif
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse($records as $record)
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="flex items-start justify-between gap-4"><div><span class="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium dark:bg-zinc-800">{{ $record->location }}</span><h2 class="mt-3 text-lg font-semibold">{{ $record->name }}</h2><p class="mt-1 text-sm text-zinc-500">{{ count($record->items ?? []) }} links · {{ $record->enabled ? 'Enabled' : 'Disabled' }}</p></div></div><div class="mt-5 flex gap-3"><a class="rounded-lg border px-3 py-2 text-sm" href="{{ route('administration.menus.edit', $record) }}">Edit menu</a><form method="POST" action="{{ route('administration.menus.destroy', $record) }}">@csrf @method('DELETE')<button class="rounded-lg px-3 py-2 text-sm text-red-600" onclick="return confirm('Delete this menu?')">Delete</button></form></div></article>
@empty
<div class="col-span-full rounded-2xl border border-dashed border-zinc-300 p-12 text-center dark:border-zinc-700"><h2 class="font-semibold">No menus yet</h2><p class="mt-1 text-sm text-zinc-500">Run the Saasland installer or create your first reusable menu.</p></div>
@endforelse
</div>
{{ $records->links() }}
</div>
</x-layouts.app>
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Menus\Http\Controllers\MenuController;
Route::middleware(['auth', 'verified'])->prefix('administration/menus')->name('administration.menus.')->group(function (): void {
Route::get('/', [MenuController::class, 'index'])->name('index');
Route::get('/create', [MenuController::class, 'create'])->name('create');
Route::post('/', [MenuController::class, 'store'])->name('store');
Route::get('/{menu}/edit', [MenuController::class, 'edit'])->name('edit');
Route::match(['put', 'patch'], '/{menu}', [MenuController::class, 'update'])->name('update');
Route::delete('/{menu}', [MenuController::class, 'destroy'])->name('destroy');
});
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Menus\Support;
final class ModuleInfo
{
public static function name(): string
{
return 'Menus';
}
public static function nameLower(): string
{
return 'menus';
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
use Modules\Menus\Models\Menu;
it('normalises reusable menu items through the navigation service', function (): void {
Menu::query()->create(['name' => 'Primary', 'location' => 'primary', 'enabled' => true, 'items' => [['enabled' => true, 'label' => 'Home', 'url' => '/']]]);
expect(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary())->toHaveCount(1)->and(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary()[0]['label'])->toBe('Home');
});
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
it('registers the Menus administration route', function (): void {
expect(Route::has('administration.menus.index'))->toBeTrue();
});
+20
View File
@@ -0,0 +1,20 @@
{
"name": "Menus",
"alias": "menus",
"version": "1.0.0",
"description": "Menus CMS module.",
"keywords": [
"cms",
"menus"
],
"priority": 22,
"providers": [
"Modules\\Menus\\Providers\\MenusServiceProvider"
],
"aliases": {},
"files": [],
"requires": [
"Core",
"Administration"
]
}