move into sonardesk
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user