structure changes

This commit is contained in:
Mark Gregory
2026-07-27 14:17:30 +01:00
parent bdb5e7825d
commit df5d6077cb
13 changed files with 468 additions and 277 deletions
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Schema;
use Modules\Content\Models\ContentDocument;
use Modules\Content\Models\ContentType;
use Modules\Content\Models\PageContent;
use Modules\Menus\Models\Menu;
use Modules\Settings\Services\SettingsRepository;
final class InstallSaaslandContent extends Command
{
@@ -26,6 +28,8 @@ final class InstallSaaslandContent extends Command
}
DB::transaction(function (): void {
$this->installSharedSettings();
$this->installMenus();
if ($this->option('force')) {
PageContent::query()->where('page_key', 'home')->delete();
}
@@ -69,6 +73,55 @@ final class InstallSaaslandContent extends Command
return self::SUCCESS;
}
private function installSharedSettings(): void
{
$settings = app(SettingsRepository::class);
$legacy = [];
if (Schema::hasTable('stylist_theme_settings')) {
$stored = DB::table('stylist_theme_settings')->where('theme_key', 'saasland')->value('settings');
$legacy = is_string($stored) ? (json_decode($stored, true) ?: []) : [];
}
$defaults = [
'site_name' => $legacy['site_title'] ?? config('app.name'),
'site_description' => $legacy['site_description'] ?? 'Modern software for modern companies.',
'company_name' => $legacy['site_title'] ?? config('app.name'),
'site_url' => url('/'),
'logo_url' => $legacy['logo_url'] ?? '',
'facebook_url' => $legacy['facebook_url'] ?? '',
'x_url' => $legacy['x_url'] ?? '',
'linkedin_url' => $legacy['linkedin_url'] ?? '',
'dashboard_label' => 'Dashboard',
'login_label' => 'Log in',
'registration_label' => 'Get started',
];
foreach ($defaults as $key => $value) {
if ($settings->get($key, null, 'site') === null) {
$settings->put($key, $value, 'site', 'string', true);
}
}
}
private function installMenus(): void
{
if (! Schema::hasTable('menus')) {
return;
}
$menus = [
'primary' => ['Primary navigation', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]],
'footer' => ['Footer — Explore', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]],
'footer_account' => ['Footer — Account', [['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'], ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile']]],
'footer_legal' => ['Footer — Legal', []],
];
foreach ($menus as $location => [$name, $items]) {
Menu::query()->firstOrCreate(['location' => $location], ['name' => $name, 'items' => $items, 'enabled' => true]);
}
}
private function mergeMissingDefaults(array $current, array $defaults): array
{
foreach ($defaults as $key => $defaultValue) {
@@ -113,44 +166,15 @@ final class InstallSaaslandContent extends Command
'title' => 'Main website header',
'type' => $type('Site header', 'site-header', ['header'], [
$field('enabled', 'Show header', 'boolean', ['default' => true]),
$field('brand_text', 'Brand text'),
$field('brand_url', 'Brand URL', 'url'),
$field('logo_url', 'Logo URL', 'url'),
$field('menu_location', 'Navigation menu location', 'text', ['help' => 'Usually primary. Edit links from Administration → Menus.']),
$field('show_navigation', 'Show navigation', 'boolean', ['default' => true]),
$field('links', 'Navigation links', 'repeater', ['fields' => [
$field('enabled', 'Show link', 'boolean', ['default' => true]),
$field('label', 'Label'),
$field('url', 'URL', 'url'),
]]),
$field('show_dashboard', 'Show dashboard link', 'boolean', ['default' => true]),
$field('dashboard_label', 'Dashboard label'),
$field('show_user_name', 'Show signed-in user', 'boolean', ['default' => true]),
$field('show_login', 'Show login', 'boolean', ['default' => true]),
$field('login_label', 'Login label'),
$field('show_registration', 'Show registration', 'boolean', ['default' => true]),
$field('registration_label', 'Registration label'),
$field('show_mobile_menu', 'Show mobile menu', 'boolean', ['default' => true]),
]),
'data' => [
'enabled' => true,
'brand_text' => config('app.name'),
'brand_url' => '/',
'logo_url' => '',
'show_navigation' => true,
'links' => [
['enabled' => true, 'label' => 'Home', 'url' => '/'],
['enabled' => true, 'label' => 'Blog', 'url' => '/blog'],
['enabled' => true, 'label' => 'News', 'url' => '/news'],
],
'show_dashboard' => true,
'dashboard_label' => 'Dashboard',
'show_user_name' => true,
'show_login' => true,
'login_label' => 'Log in',
'show_registration' => true,
'registration_label' => 'Get started',
'show_mobile_menu' => true,
],
'data' => ['enabled' => true, 'menu_location' => 'primary', 'show_navigation' => true, 'show_dashboard' => true, 'show_user_name' => true, 'show_login' => true, 'show_registration' => true, 'show_mobile_menu' => true],
],
'hero' => [
'title' => 'Homepage hero',
@@ -204,46 +228,17 @@ final class InstallSaaslandContent extends Command
'title' => 'Main website footer',
'type' => $type('Site footer', 'site-footer', ['footer'], [
$field('enabled', 'Show footer', 'boolean', ['default' => true]),
$field('brand_text', 'Brand text'),
$field('brand_url', 'Brand URL', 'url'),
$field('description', 'Description', 'textarea'),
$field('columns', 'Footer columns', 'repeater', ['fields' => [
$field('enabled', 'Show column', 'boolean', ['default' => true]),
$field('heading', 'Heading'),
$field('links', 'Links', 'repeater', ['fields' => [
$field('enabled', 'Show link', 'boolean', ['default' => true]),
$field('label', 'Label'),
$field('url', 'URL', 'url'),
]]),
]]),
$field('copyright', 'Copyright text'),
$field('explore_heading', 'Explore column heading'),
$field('explore_menu_location', 'Explore menu location'),
$field('account_heading', 'Account column heading'),
$field('account_menu_location', 'Account menu location'),
$field('legal_heading', 'Legal column heading'),
$field('legal_menu_location', 'Legal menu location'),
$field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]),
$field('back_to_top_label', 'Back-to-top label'),
]),
'data' => [
'enabled' => true,
'brand_text' => config('app.name'),
'brand_url' => '/',
'description' => 'Software and services built for modern organisations.',
'columns' => [
['enabled' => true, 'heading' => 'Explore', 'links' => [
['enabled' => true, 'label' => 'Home', 'url' => '/'],
['enabled' => true, 'label' => 'Blog', 'url' => '/blog'],
['enabled' => true, 'label' => 'News', 'url' => '/news'],
]],
['enabled' => true, 'heading' => 'Account', 'links' => [
['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'],
['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile'],
]],
['enabled' => true, 'heading' => 'Connect', 'links' => [
['enabled' => true, 'label' => 'Contact', 'url' => '/contact'],
]],
],
'copyright' => '© '.date('Y').' '.config('app.name').'. All rights reserved.',
'show_back_to_top' => true,
'back_to_top_label' => 'Back to top ↑',
],
'data' => ['enabled' => true, 'explore_heading' => 'Explore', 'explore_menu_location' => 'footer', 'account_heading' => 'Account', 'account_menu_location' => 'footer_account', 'legal_heading' => 'Legal', 'legal_menu_location' => 'footer_legal', 'show_back_to_top' => true, 'back_to_top_label' => 'Back to top ↑'],
],
];
];
}
}
@@ -5,60 +5,86 @@ 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()->latest()->paginate(20)]);
return view('menus::admin.index', ['records' => Menu::query()->orderBy('location')->paginate(30)]);
}
public function create(): View
{
return view('menus::admin.form');
return view('menus::admin.form', ['locations' => $this->locations()]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate(['name' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'items' => ['nullable', 'array'],
'enabled' => ['sometimes', 'boolean']]);
if ($request->filled('items_json')) {
$data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
}
$data['enabled'] = $request->boolean('enabled');
Menu::create($data);
Menu::create($this->validated($request));
return redirect()->route('administration.menus.index')->with('status', 'Menu created.');
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]);
return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]);
}
public function update(Request $request, Menu $menu): RedirectResponse
{
$data = $request->validate(['name' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'items' => ['nullable', 'array'],
'enabled' => ['sometimes', 'boolean']]);
if ($request->filled('items_json')) {
$data['items'] = json_decode($request->string('items_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
}
$data['enabled'] = $request->boolean('enabled');
$menu->update($data);
$menu->update($this->validated($request));
return redirect()->route('administration.menus.index')->with('status', 'Menu updated.');
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.');
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',
];
}
}
@@ -1,8 +1,57 @@
<x-layouts.app :title="isset($record) ? __('Edit Menu') : __('Create Menu')">
<div class="mx-auto max-w-4xl space-y-6"><div><h1 class="text-2xl font-semibold">{{ isset($record) ? 'Edit' : 'Create' }} Menu</h1></div><form method="POST" action="{{ isset($record) ? route('administration.menus.update', $record) : route('administration.menus.store') }}" class="space-y-5 rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">@csrf @isset($record) @method('PUT') @endisset
<div><label class="mb-1 block text-sm font-medium">Name</label><input type="text" name="name" value="{{ old('name', $record->name ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('name')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
<div><label class="mb-1 block text-sm font-medium">Location</label><input type="text" name="location" value="{{ old('location', $record->location ?? '') }}" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">@error('location')<p class="text-sm text-red-600">{{ $message }}</p>@enderror</div>
<div><label class="mb-1 block text-sm font-medium">Items (JSON)</label><textarea name="items_json" rows="6" class="w-full rounded-lg border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">{{ old('items_json', isset($record) ? json_encode($record->items, JSON_PRETTY_PRINT) : '') }}</textarea></div>
<label class="flex items-center gap-2"><input type="checkbox" name="enabled" value="1" @checked(old('enabled', $record->enabled ?? true))><span>Enabled</span></label>
<div class="flex gap-3"><button class="rounded-lg bg-zinc-900 px-4 py-2 text-white dark:bg-white dark:text-zinc-900">Save</button><a href="{{ route('administration.menus.index') }}" class="rounded-lg border px-4 py-2">Cancel</a></div></form></div>
<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>
@@ -1,5 +1,14 @@
<x-layouts.app :title="__('Menus')">
<div class="space-y-6"><div class="flex items-center justify-between"><div><h1 class="text-2xl font-semibold">Menus</h1><p class="text-sm text-zinc-500">Manage menus content.</p></div><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 new</a></div>
@if(session('status'))<div class="rounded-lg bg-emerald-50 p-3 text-emerald-800">{{ session('status') }}</div>@endif
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-800"><tr><th class="px-4 py-3 text-left">Name</th><th class="px-4 py-3 text-left">Location</th><th class="px-4 py-3 text-left">Items</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-200 dark:divide-zinc-800">@forelse($records as $record)<tr><td class="px-4 py-3">{{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}</td><td class="px-4 py-3">{{ is_array($record->location) ? json_encode($record->location) : Str::limit((string) $record->location, 70) }}</td><td class="px-4 py-3">{{ is_array($record->items) ? json_encode($record->items) : Str::limit((string) $record->items, 70) }}</td><td class="px-4 py-3 text-right"><a class="mr-3 underline" href="{{ route('administration.menus.edit', $record) }}">Edit</a><form class="inline" method="POST" action="{{ route('administration.menus.destroy', $record) }}">@csrf @method('DELETE')<button class="text-red-600" onclick="return confirm('Delete this record?')">Delete</button></form></td></tr>@empty<tr><td colspan="4" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>@endforelse</tbody></table></div>{{ $records->links() }}</div>
<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,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');
});
@@ -3,25 +3,79 @@
namespace Modules\Settings\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Modules\Settings\Models\Setting;
use Illuminate\View\View;
use Modules\Settings\Services\SettingsRepository;
final class SettingController extends Controller
{
public function index()
public function index(SettingsRepository $settings): View
{
$settings = Setting::query()->orderBy('group')->orderBy('key')->get();
return view('settings::index', compact('settings'));
return view('settings::index', [
'values' => $settings->site(),
'sections' => $this->sections(),
]);
}
public function update(Request $request)
public function update(Request $request, SettingsRepository $settings): RedirectResponse
{
$data = $request->validate(['settings' => ['array'], 'settings.*' => ['nullable', 'string', 'max:5000']]);
foreach ($data['settings'] ?? [] as $id => $value) {
Setting::whereKey($id)->update(['value' => $value]);
$rules = [];
foreach ($this->sections() as $fields) {
foreach ($fields as $key => $definition) {
$rules["settings.$key"] = $definition['rules'];
}
}
return back()->with('status', 'Settings saved.');
$validated = $request->validate($rules);
foreach ($validated['settings'] ?? [] as $key => $value) {
$settings->put($key, $value, 'site', 'string', true);
}
return back()->with('status', 'Site settings saved. The frontend has been updated everywhere.');
}
/** @return array<string, array<string, array<string, mixed>>> */
private function sections(): array
{
$text = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:255']];
$url = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:2048'], 'placeholder' => '/path or https://example.com'];
$textarea = ['type' => 'textarea', 'rules' => ['nullable', 'string', 'max:5000']];
return [
'General' => [
'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'],
'tagline' => $text + ['label' => 'Tagline'],
'site_description' => $textarea + ['label' => 'Site description'],
'company_name' => $text + ['label' => 'Legal company name'],
'site_url' => $url + ['label' => 'Canonical site URL'],
'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'],
],
'Branding' => [
'logo_url' => $url + ['label' => 'Primary logo'],
'logo_dark_url' => $url + ['label' => 'Dark-mode logo'],
'favicon_url' => $url + ['label' => 'Favicon'],
],
'Contact' => [
'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'],
'contact_phone' => $text + ['label' => 'Public telephone'],
'address' => $textarea + ['label' => 'Address'],
],
'Social' => [
'facebook_url' => $url + ['label' => 'Facebook'],
'x_url' => $url + ['label' => 'X / Twitter'],
'linkedin_url' => $url + ['label' => 'LinkedIn'],
'instagram_url' => $url + ['label' => 'Instagram'],
],
'Account navigation' => [
'dashboard_label' => $text + ['label' => 'Dashboard label'],
'login_label' => $text + ['label' => 'Login label'],
'registration_label' => $text + ['label' => 'Registration label'],
],
'SEO defaults' => [
'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'],
'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'],
],
];
}
}
@@ -1 +1,49 @@
<x-layouts.app :title="__('Settings')"><div class="max-w-4xl space-y-6"><div><h1 class="text-2xl font-semibold">Platform settings</h1><p class="mt-1 text-sm text-zinc-500">Shared configuration stored by the Settings module.</p></div>@if(session('status'))<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800">{{ session('status') }}</div>@endif<form method="POST" action="{{ route('administration.settings.update') }}" class="space-y-4">@csrf @method('PUT')@forelse($settings->groupBy('group') as $group=>$items)<section class="rounded-xl border border-zinc-200 bg-white p-5 dark:border-zinc-700 dark:bg-zinc-900"><h2 class="mb-4 font-semibold capitalize">{{ $group }}</h2><div class="space-y-4">@foreach($items as $setting)<flux:input name="settings[{{ $setting->id }}]" :label="str($setting->key)->replace('_',' ')->title()" :value="$setting->value"/>@endforeach</div></section>@empty<div class="rounded-xl border border-dashed border-zinc-300 p-10 text-center text-sm text-zinc-500">No settings have been registered yet. SEO defaults can be configured from the SEO screen.</div>@endforelse<div class="flex justify-end"><flux:button type="submit" variant="primary">Save settings</flux:button></div></form></div></x-layouts.app>
<x-layouts.app :title="__('Site settings')">
<div class="mx-auto max-w-6xl 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 truth</p>
<h1 class="text-3xl font-semibold">Site settings</h1>
<p class="mt-2 max-w-3xl text-sm text-zinc-500">
Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer.
</p>
</div>
<a href="{{ route('administration.menus.index') }}" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium dark:border-zinc-700">Manage menus</a>
</div>
@if (session('status'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>
@endif
<form method="POST" action="{{ route('administration.settings.update') }}" class="space-y-6">
@csrf
@method('PUT')
@foreach ($sections as $section => $fields)
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 class="text-lg font-semibold">{{ $section }}</h2>
</div>
<div class="grid gap-5 p-6 md:grid-cols-2">
@foreach ($fields as $key => $field)
<label class="block {{ ($field['type'] ?? '') === 'textarea' ? 'md:col-span-2' : '' }}">
<span class="text-sm font-medium">{{ $field['label'] }}</span>
@if (! empty($field['help']))<span class="mt-1 block text-xs text-zinc-500">{{ $field['help'] }}</span>@endif
@if (($field['type'] ?? 'text') === 'textarea')
<textarea name="settings[{{ $key }}]" rows="4" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">{{ old("settings.$key", $values[$key] ?? '') }}</textarea>
@else
<input type="{{ $field['type'] ?? 'text' }}" name="settings[{{ $key }}]" value="{{ old("settings.$key", $values[$key] ?? '') }}" placeholder="{{ $field['placeholder'] ?? '' }}" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
@endif
@error("settings.$key")<span class="mt-1 block text-sm text-red-600">{{ $message }}</span>@enderror
</label>
@endforeach
</div>
</section>
@endforeach
<div class="sticky bottom-4 flex justify-end rounded-2xl border border-zinc-200 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95">
<flux:button type="submit" variant="primary">Save site settings</flux:button>
</div>
</form>
</div>
</x-layouts.app>
@@ -3,20 +3,127 @@
namespace Modules\Settings\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Modules\Settings\Models\Setting;
final class SettingsRepository
{
public function get(string $key, mixed $default = null, string $group = 'general'): mixed
{
return Cache::rememberForever("settings.$group.$key", fn () => Setting::query()->where(compact('group', 'key'))->value('value') ?? $default);
if (! $this->tableExists()) {
return $default;
}
$setting = Cache::rememberForever(
"settings.$group.$key",
fn (): ?Setting => Setting::query()
->where([
'group' => $group,
'key' => $key,
])
->first()
);
return $setting ? $this->decode($setting->value, $setting->type) : $default;
}
/** @return array<string, mixed> */
public function group(string $group): array
{
if (! $this->tableExists()) {
return [];
}
return Cache::rememberForever("settings.group.$group", function () use ($group): array {
return Setting::query()
->where('group', $group)
->get()
->mapWithKeys(fn (Setting $setting): array => [
$setting->key => $this->decode($setting->value, $setting->type),
])
->all();
});
}
/** @return array<string, mixed> */
public function site(): array
{
$defaults = [
'site_name' => config('app.name', 'Content Manager'),
'tagline' => '',
'site_description' => 'Modern software for modern companies.',
'company_name' => config('app.name', 'Content Manager'),
'site_url' => url('/'),
'logo_url' => '',
'logo_dark_url' => '',
'favicon_url' => '/favicon.ico',
'contact_email' => '',
'contact_phone' => '',
'address' => '',
'facebook_url' => '',
'x_url' => '',
'linkedin_url' => '',
'instagram_url' => '',
'copyright' => '',
'dashboard_label' => 'Dashboard',
'login_label' => 'Log in',
'registration_label' => 'Get started',
'default_meta_title' => '',
'default_meta_description' => '',
];
return array_replace($defaults, $this->group('site'));
}
public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting
{
$setting = Setting::query()->updateOrCreate(compact('group', 'key'), ['value' => is_scalar($value) || $value === null ? (string) $value : json_encode($value, JSON_THROW_ON_ERROR), 'type' => $type, 'is_public' => $public]);
$setting = Setting::query()->updateOrCreate(
compact('group', 'key'),
[
'value' => $this->encode($value, $type),
'type' => $type,
'is_public' => $public,
]
);
Cache::forget("settings.$group.$key");
Cache::forget("settings.group.$group");
return $setting;
}
private function tableExists(): bool
{
try {
return Schema::hasTable((new Setting())->getTable());
} catch (\Throwable) {
return false;
}
}
private function encode(mixed $value, string $type): ?string
{
if ($value === null) {
return null;
}
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0',
'integer' => (string) (int) $value,
'float' => (string) (float) $value,
'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR),
default => (string) $value,
};
}
private function decode(?string $value, string $type): mixed
{
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL),
'integer' => (int) $value,
'float' => (float) $value,
'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [],
default => $value,
};
}
}
@@ -0,0 +1,9 @@
<?php
use Modules\Settings\Services\SettingsRepository;
it('stores and reads typed site settings from one repository', function (): void {
$repository = app(SettingsRepository::class);
$repository->put('site_name', 'Unified CMS', 'site', 'string', true);
expect($repository->site()['site_name'])->toBe('Unified CMS');
});
@@ -20,6 +20,8 @@ use Modules\Stylist\Services\ThemeSettings;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Stylist;
use Modules\Settings\Services\SettingsRepository;
final class StylistServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Stylist';
@@ -71,16 +73,18 @@ final class StylistServiceProvider extends ServiceProvider
View::composer('stylist-theme-saasland::*', function ($view): void {
$theme = $this->app->make(Loader::class)->find('saasland');
$settings = $this->app->make(ThemeSettings::class)->get($theme);
$siteSettings = $this->app->make(SettingsRepository::class)->site();
$navigation = $this->app->make(FrontendNavigation::class);
$homepage = $this->app->make(FrontendContent::class)->homepage($settings);
$view->with([
'siteTitle' => filled($settings['site_title'] ?? null) ? $settings['site_title'] : config('app.name', 'Saasland'),
'siteDescription' => filled($settings['site_description'] ?? null) ? $settings['site_description'] : 'Modern software for modern companies.',
'logoUrl' => $settings['logo_url'] ?? null,
'facebookUrl' => $settings['facebook_url'] ?? null,
'xUrl' => $settings['x_url'] ?? null,
'linkedinUrl' => $settings['linkedin_url'] ?? null,
'siteSettings' => $siteSettings,
'siteTitle' => $siteSettings['site_name'],
'siteDescription' => $siteSettings['site_description'],
'logoUrl' => $siteSettings['logo_url'] ?: null,
'facebookUrl' => $siteSettings['facebook_url'] ?: null,
'xUrl' => $siteSettings['x_url'] ?: null,
'linkedinUrl' => $siteSettings['linkedin_url'] ?: null,
'primaryNavigation' => $navigation->primary(),
'footerNavigation' => $navigation->footer(),
'themeSettings' => $settings,
+15 -60
View File
@@ -12,33 +12,27 @@ final class FrontendNavigation
/** @return array<int, array<string, mixed>> */
public function primary(): array
{
return $this->forLocations(['primary', 'header', 'main'], $this->fallbackPrimary());
return $this->location('primary', $this->fallbackPrimary());
}
/** @return array<int, array<string, mixed>> */
public function footer(): array
{
return $this->forLocations(['footer'], $this->fallbackFooter());
return $this->location('footer', $this->fallbackPrimary());
}
/** @param array<int, string> $locations
* @param array<int, array<string, mixed>> $fallback
/** @param array<int, array<string, mixed>> $fallback
* @return array<int, array<string, mixed>>
*/
private function forLocations(array $locations, array $fallback): array
public function location(string $location, array $fallback = []): array
{
try {
if (! Schema::hasTable('menus')) {
return $fallback;
}
$menu = Menu::query()
->where('enabled', true)
->whereIn('location', $locations)
->orderByRaw('CASE location '.collect($locations)->map(fn (string $location, int $index) => "WHEN ? THEN {$index}")->implode(' ').' ELSE 999 END', $locations)
->first();
if (! $menu || ! is_array($menu->items) || $menu->items === []) {
$menu = Menu::query()->where('enabled', true)->where('location', $location)->first();
if (! $menu || ! is_array($menu->items)) {
return $fallback;
}
@@ -56,66 +50,27 @@ final class FrontendNavigation
private function normaliseItems(array $items): array
{
return collect($items)
->filter(fn (mixed $item): bool => is_array($item) && ($item['enabled'] ?? true) !== false)
->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL))
->map(function (array $item): ?array {
$label = trim((string) ($item['label'] ?? $item['title'] ?? $item['name'] ?? ''));
if ($label === '') {
return null;
}
$url = $this->resolveUrl($item);
if ($url === null) {
return null;
}
$label = trim((string) ($item['label'] ?? ''));
$url = trim((string) ($item['url'] ?? ''));
if ($label === '' || $url === '') return null;
return [
'label' => $label,
'url' => $url,
'target' => ($item['target'] ?? null) === '_blank' ? '_blank' : null,
'children' => $this->normaliseItems((array) ($item['children'] ?? $item['items'] ?? [])),
'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null,
'children' => $this->normaliseItems((array) ($item['children'] ?? [])),
];
})
->filter()
->values()
->all();
}
/** @param array<string, mixed> $item */
private function resolveUrl(array $item): ?string
{
$routeName = trim((string) ($item['route'] ?? $item['route_name'] ?? ''));
if ($routeName !== '' && Route::has($routeName)) {
return route($routeName, (array) ($item['parameters'] ?? []));
}
$pageSlug = trim((string) ($item['page_slug'] ?? $item['slug'] ?? ''));
if ($pageSlug !== '' && Route::has('pages.public.show')) {
return route('pages.public.show', $pageSlug);
}
$url = trim((string) ($item['url'] ?? $item['href'] ?? ''));
return $url !== '' ? $url : null;
})->filter()->values()->all();
}
/** @return array<int, array<string, mixed>> */
private function fallbackPrimary(): array
{
$items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]];
if (Route::has('blog.public.index')) {
$items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
}
if (Route::has('news.public.index')) {
$items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
}
if (Route::has('blog.public.index')) $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
if (Route::has('news.public.index')) $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
return $items;
}
/** @return array<int, array<string, mixed>> */
private function fallbackFooter(): array
{
return $this->fallbackPrimary();
}
}
@@ -1,43 +1,17 @@
@php
$footerContent = $footerContent ?? [];
$columns = collect($footerContent['columns'] ?? [])->filter(
fn (array $column): bool => filter_var($column['enabled'] ?? true, FILTER_VALIDATE_BOOL)
);
$navigation = app(\Modules\Stylist\Support\FrontendNavigation::class);
$columns = collect([
['heading' => $footerContent['explore_heading'] ?? 'Explore', 'items' => $navigation->location($footerContent['explore_menu_location'] ?? 'footer', $footerNavigation ?? [])],
['heading' => $footerContent['account_heading'] ?? 'Account', 'items' => $navigation->location($footerContent['account_menu_location'] ?? 'footer_account')],
['heading' => $footerContent['legal_heading'] ?? 'Legal', 'items' => $navigation->location($footerContent['legal_menu_location'] ?? 'footer_legal')],
])->filter(fn(array $column) => $column['items'] !== []);
$copyright = $siteSettings['copyright'] ?: ('© '.date('Y').' '.$siteSettings['company_name'].'. All rights reserved.');
@endphp
<style>
.footer.cms-editable-footer .footer-grid{display:grid;grid-template-columns:minmax(280px,1.5fr) repeat(3,minmax(160px,1fr));gap:3rem;padding-block:4rem 2rem;align-items:start;}
.cms-editable-footer .footer-brand{max-width:36rem}.cms-editable-footer .footer-brand p{max-width:34rem;line-height:1.7;}
.cms-editable-footer .footer-column h3{margin:0 0 1rem;font-size:1rem}.cms-editable-footer .footer-column ul{display:grid;gap:.75rem;list-style:none;margin:0;padding:0;}
.cms-editable-footer .footer-column a{display:inline-flex;text-decoration:none}.cms-editable-footer .copyright{padding-block:1.5rem 2rem;}
@media(max-width:900px){.footer.cms-editable-footer .footer-grid{grid-template-columns:1fr;gap:2rem;padding-block:3rem 1.5rem}}
</style>
<footer class="footer cms-editable-footer">
<div class="wrap footer-grid">
<div class="footer-brand">
<a class="logo" href="{{ $footerContent['brand_url'] ?? url('/') }}">{{ $footerContent['brand_text'] ?? $siteTitle }}</a>
@if (! empty($footerContent['description'] ?? $siteDescription))
<p>{{ $footerContent['description'] ?? $siteDescription }}</p>
@endif
</div>
@foreach ($columns as $column)
<div class="footer-column">
<h3>{{ $column['heading'] ?? 'Links' }}</h3>
<ul>
@foreach (collect($column['links'] ?? [])->filter(fn (array $link): bool => filter_var($link['enabled'] ?? true, FILTER_VALIDATE_BOOL)) as $link)
<li><a href="{{ $link['url'] ?? '#' }}">{{ $link['label'] ?? 'Link' }}</a></li>
@endforeach
</ul>
</div>
@endforeach
</div>
<div class="wrap copyright">
<span>{{ $footerContent['copyright'] ?? ('© '.date('Y').' '.$siteTitle.'. All rights reserved.') }}</span>
@if (filter_var($footerContent['show_back_to_top'] ?? true, FILTER_VALIDATE_BOOL))
<a href="#top">{{ $footerContent['back_to_top_label'] ?? 'Back to top ↑' }}</a>
@endif
<div class="footer-brand"><a class="logo" href="{{ $siteSettings['site_url'] ?: url('/') }}">{{ $siteTitle }}</a><p>{{ $siteDescription }}</p>@if($siteSettings['contact_email'])<a href="mailto:{{ $siteSettings['contact_email'] }}">{{ $siteSettings['contact_email'] }}</a>@endif</div>
@foreach($columns as $column)<div class="footer-column"><h3>{{ $column['heading'] }}</h3><ul>@foreach($column['items'] as $link)<li><a href="{{ $link['url'] }}" @if($link['target']) target="{{ $link['target'] }}" rel="noopener" @endif>{{ $link['label'] }}</a></li>@endforeach</ul></div>@endforeach
</div>
<div class="wrap copyright"><span>{{ $copyright }}</span>@if(filter_var($footerContent['show_back_to_top'] ?? true,FILTER_VALIDATE_BOOL))<a href="#top">{{ $footerContent['back_to_top_label'] ?? 'Back to top ↑' }}</a>@endif</div>
</footer>
@@ -1,39 +1,19 @@
@php
$headerContent = $headerContent ?? [];
$configuredLinks = collect($headerContent['links'] ?? []);
$navigationLinks = $configuredLinks->isNotEmpty()
? $configuredLinks
: collect($primaryNavigation ?? [])->map(fn (array $item): array => [
'enabled' => true,
'label' => $item['label'] ?? 'Link',
'url' => $item['url'] ?? '#',
]);
$navigationLinks = $navigationLinks->filter(
fn (array $link): bool => filter_var($link['enabled'] ?? true, FILTER_VALIDATE_BOOL)
);
$menuLocation = $headerContent['menu_location'] ?? 'primary';
$navigationLinks = app(\Modules\Stylist\Support\FrontendNavigation::class)->location($menuLocation, $primaryNavigation ?? []);
$brandUrl = $siteSettings['site_url'] ?: url('/');
@endphp
<style>
.nav.cms-editable-header{display:grid;grid-template-columns:minmax(220px,1fr) auto minmax(220px,1fr);align-items:center;gap:2rem;width:100%;}
.cms-editable-header .desktop-nav{display:flex;align-items:center;justify-content:center;gap:2rem;}
.cms-editable-header .nav-actions{justify-self:end;display:flex;align-items:center;gap:1rem;}
.cms-editable-header .cms-brand{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@media (max-width:900px){.nav.cms-editable-header{grid-template-columns:1fr auto}.cms-editable-header .desktop-nav{display:none}.cms-editable-header .nav-actions{grid-column:2}.cms-editable-header .account-name{display:none}}
</style>
<header class="nav cms-editable-header">
<a href="{{ $headerContent['brand_url'] ?? url('/') }}" class="logo cms-brand" aria-label="{{ $headerContent['brand_text'] ?? $siteTitle }} home">
@if (! empty($headerContent['logo_url'] ?? $logoUrl))
<img src="{{ $headerContent['logo_url'] ?? $logoUrl }}" alt="{{ $headerContent['brand_text'] ?? $siteTitle }}">
@else
{{ $headerContent['brand_text'] ?? $siteTitle }}
@endif
<a href="{{ $brandUrl }}" class="logo cms-brand" aria-label="{{ $siteTitle }} home">
@if ($logoUrl)<img src="{{ $logoUrl }}" alt="{{ $siteTitle }}">@else{{ $siteTitle }}@endif
</a>
@if (filter_var($headerContent['show_navigation'] ?? true, FILTER_VALIDATE_BOOL) && $navigationLinks->isNotEmpty())
@if (filter_var($headerContent['show_navigation'] ?? true, FILTER_VALIDATE_BOOL) && $navigationLinks)
<nav class="desktop-nav" aria-label="Primary navigation">
@foreach ($navigationLinks as $link)
<a href="{{ $link['url'] ?? '#' }}">{{ $link['label'] ?? 'Link' }}</a>
<a href="{{ $link['url'] }}" @if($link['target']) target="{{ $link['target'] }}" rel="noopener" @endif>{{ $link['label'] }}</a>
@endforeach
</nav>
@endif
@@ -41,43 +21,16 @@
<div class="nav-actions">
@auth
@if (filter_var($headerContent['show_dashboard'] ?? true, FILTER_VALIDATE_BOOL) && Route::has('dashboard'))
<a class="nav-account" href="{{ route('dashboard') }}">{{ $headerContent['dashboard_label'] ?? 'Dashboard' }}</a>
<a class="nav-account" href="{{ route('dashboard') }}">{{ $siteSettings['dashboard_label'] }}</a>
@endif
@if (filter_var($headerContent['show_user_name'] ?? true, FILTER_VALIDATE_BOOL))
<div class="account-menu" x-data="{ accountOpen: false }">
<button type="button" class="account-trigger" @click="accountOpen = ! accountOpen" :aria-expanded="accountOpen">
<span>{{ str(auth()->user()->name)->substr(0, 1)->upper() }}</span>
<span class="account-name">{{ auth()->user()->name }}</span>
</button>
<div class="account-dropdown" x-show="accountOpen" @click.outside="accountOpen = false" x-cloak>
@if (Route::has('profile.edit'))<a href="{{ route('profile.edit') }}">Profile</a>@endif
@if (Route::has('settings.profile'))<a href="{{ route('settings.profile') }}">Account settings</a>@endif
@if (Route::has('logout'))
<form method="POST" action="{{ route('logout') }}">@csrf<button type="submit">Log out</button></form>
@endif
</div>
</div>
<div class="account-menu" x-data="{ accountOpen: false }"><button type="button" class="account-trigger" @click="accountOpen=!accountOpen"><span>{{ str(auth()->user()->name)->substr(0,1)->upper() }}</span><span class="account-name">{{ auth()->user()->name }}</span></button><div class="account-dropdown" x-show="accountOpen" @click.outside="accountOpen=false" x-cloak>@if(Route::has('profile.edit'))<a href="{{ route('profile.edit') }}">Profile</a>@endif @if(Route::has('logout'))<form method="POST" action="{{ route('logout') }}">@csrf<button type="submit">Log out</button></form>@endif</div></div>
@endif
@else
@if (filter_var($headerContent['show_login'] ?? true, FILTER_VALIDATE_BOOL) && Route::has('login'))
<a class="nav-login" href="{{ route('login') }}">{{ $headerContent['login_label'] ?? 'Log in' }}</a>
@endif
@if (filter_var($headerContent['show_registration'] ?? true, FILTER_VALIDATE_BOOL) && Route::has('register'))
<a class="btn primary nav-register" href="{{ route('register') }}">{{ $headerContent['registration_label'] ?? 'Get started' }}</a>
@endif
@if (filter_var($headerContent['show_login'] ?? true, FILTER_VALIDATE_BOOL) && Route::has('login'))<a class="nav-login" href="{{ route('login') }}">{{ $siteSettings['login_label'] }}</a>@endif
@if (filter_var($headerContent['show_registration'] ?? true, FILTER_VALIDATE_BOOL) && Route::has('register'))<a class="btn primary nav-register" href="{{ route('register') }}">{{ $siteSettings['registration_label'] }}</a>@endif
@endauth
@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL))
<button class="menu" type="button" aria-label="Toggle navigation" :aria-expanded="menuOpen" @click="menuOpen = ! menuOpen"><span></span><span></span><span></span></button>
@endif
@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL))<button class="menu" type="button" aria-label="Toggle navigation" :aria-expanded="menuOpen" @click="menuOpen=!menuOpen"><span></span><span></span><span></span></button>@endif
</div>
</header>
@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL))
<div class="mobile-nav" x-show="menuOpen" x-cloak>
@foreach ($navigationLinks as $link)
<a href="{{ $link['url'] ?? '#' }}">{{ $link['label'] ?? 'Link' }}</a>
@endforeach
</div>
@endif
@if (filter_var($headerContent['show_mobile_menu'] ?? true, FILTER_VALIDATE_BOOL))<div class="mobile-nav" x-show="menuOpen" x-cloak>@foreach($navigationLinks as $link)<a href="{{ $link['url'] }}">{{ $link['label'] }}</a>@endforeach</div>@endif