structure changes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user