From f8343280441023dad9f30a09b9ffbb754caab77e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 28 Jul 2026 12:43:19 +0100 Subject: [PATCH] themes how part of site settings --- .../Http/Controllers/DashboardController.php | 2 +- .../Http/Controllers/SettingController.php | 40 ++++++++++++++++--- .../Settings/Resources/views/index.blade.php | 6 +++ .../Settings/Services/SettingsRepository.php | 16 ++++++-- .../Feature/ThemeSelectionSettingsTest.php | 27 +++++++++++++ Modules/Stylist/Config/config.php | 2 + Modules/Stylist/Helpers/helpers.php | 14 +++++++ .../Stylist/Services/TenantThemeResolver.php | 27 ++++++++++++- Modules/Stylist/Theme/Stylist.php | 18 +++++++-- Modules/Stylist/Theme/Theme.php | 5 +++ resources/themes/corporate/theme.json | 3 +- routes/web.php | 2 +- 12 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 Modules/Settings/Tests/Feature/ThemeSelectionSettingsTest.php diff --git a/Modules/Administration/Http/Controllers/DashboardController.php b/Modules/Administration/Http/Controllers/DashboardController.php index 89b4e5d..fd399d5 100644 --- a/Modules/Administration/Http/Controllers/DashboardController.php +++ b/Modules/Administration/Http/Controllers/DashboardController.php @@ -15,7 +15,7 @@ final class DashboardController extends Controller { public function __invoke() { - return view('stylist-theme-admin::dashboard', [ + return stylist_backend_view(stylist()->backend()->entryView(), [ 'userCount' => User::count(), 'roleCount' => Schema::hasTable('roles') ? Role::count() : 0, 'mediaCount' => Schema::hasTable('media') ? Media::count() : 0, diff --git a/Modules/Settings/Http/Controllers/SettingController.php b/Modules/Settings/Http/Controllers/SettingController.php index c013ff9..ceb3f78 100644 --- a/Modules/Settings/Http/Controllers/SettingController.php +++ b/Modules/Settings/Http/Controllers/SettingController.php @@ -5,23 +5,25 @@ namespace Modules\Settings\Http\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\View\View; use Modules\Settings\Services\SettingsRepository; +use Modules\Stylist\Theme\Loader; final class SettingController extends Controller { - public function index(SettingsRepository $settings): View + public function index(SettingsRepository $settings, Loader $themes): View { return view('settings::index', [ 'values' => $settings->site(), - 'sections' => $this->sections(), + 'sections' => $this->sections($themes), ]); } - public function update(Request $request, SettingsRepository $settings): RedirectResponse + public function update(Request $request, SettingsRepository $settings, Loader $themes): RedirectResponse { $rules = []; - foreach ($this->sections() as $fields) { + foreach ($this->sections($themes) as $fields) { foreach ($fields as $key => $definition) { $rules["settings.$key"] = $definition['rules']; } @@ -32,17 +34,43 @@ final class SettingController extends Controller $settings->put($key, $value, 'site', 'string', true); } - return back()->with('status', 'Site settings saved. The frontend has been updated everywhere.'); + return back()->with('status', 'Site settings saved. The active frontend and backend themes have been updated.'); } /** @return array>> */ - private function sections(): array + private function sections(Loader $themes): 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']]; + $backendThemes = $themes->all() + ->filter(fn ($theme): bool => $theme->type() === 'backend') + ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()]) + ->all(); + + $frontendThemes = $themes->all() + ->filter(fn ($theme): bool => $theme->type() === 'frontend') + ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()]) + ->all(); + return [ + 'Themes' => [ + 'backend_theme' => [ + 'type' => 'select', + 'rules' => ['required', 'string', Rule::in(array_keys($backendThemes))], + 'label' => 'Backend theme', + 'help' => 'Used for the authenticated administration area.', + 'options' => $backendThemes, + ], + 'frontend_theme' => [ + 'type' => 'select', + 'rules' => ['required', 'string', Rule::in(array_keys($frontendThemes))], + 'label' => 'Frontend theme', + 'help' => 'Used for the public website.', + 'options' => $frontendThemes, + ], + ], 'General' => [ 'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'], 'tagline' => $text + ['label' => 'Tagline'], diff --git a/Modules/Settings/Resources/views/index.blade.php b/Modules/Settings/Resources/views/index.blade.php index 610b91d..fcf669e 100644 --- a/Modules/Settings/Resources/views/index.blade.php +++ b/Modules/Settings/Resources/views/index.blade.php @@ -31,6 +31,12 @@ @if (! empty($field['help'])){{ $field['help'] }}@endif @if (($field['type'] ?? 'text') === 'textarea') + @elseif (($field['type'] ?? 'text') === 'select') + @else @endif diff --git a/Modules/Settings/Services/SettingsRepository.php b/Modules/Settings/Services/SettingsRepository.php index 9f60204..4e80ad7 100644 --- a/Modules/Settings/Services/SettingsRepository.php +++ b/Modules/Settings/Services/SettingsRepository.php @@ -14,17 +14,21 @@ final class SettingsRepository return $default; } + /** @var array{value: ?string, type: string}|null $setting */ $setting = Cache::rememberForever( - "settings.$group.$key", - fn (): ?Setting => Setting::query() + "settings.value.v2.$group.$key", + fn (): ?array => Setting::query() ->where([ 'group' => $group, 'key' => $key, ]) - ->first() + ->first(['value', 'type']) + ?->only(['value', 'type']) ); - return $setting ? $this->decode($setting->value, $setting->type) : $default; + return $setting !== null + ? $this->decode($setting['value'], $setting['type']) + : $default; } /** @return array */ @@ -70,6 +74,8 @@ final class SettingsRepository 'registration_label' => 'Get started', 'default_meta_title' => '', 'default_meta_description' => '', + 'backend_theme' => config('stylist.default_backend_theme', 'admin'), + 'frontend_theme' => config('stylist.default_frontend_theme', 'saasland'), ]; return array_replace($defaults, $this->group('site')); @@ -86,7 +92,9 @@ final class SettingsRepository ] ); + // Remove both the legacy model cache and the scalar-only cache. Cache::forget("settings.$group.$key"); + Cache::forget("settings.value.v2.$group.$key"); Cache::forget("settings.group.$group"); return $setting; diff --git a/Modules/Settings/Tests/Feature/ThemeSelectionSettingsTest.php b/Modules/Settings/Tests/Feature/ThemeSelectionSettingsTest.php new file mode 100644 index 0000000..cae583a --- /dev/null +++ b/Modules/Settings/Tests/Feature/ThemeSelectionSettingsTest.php @@ -0,0 +1,27 @@ +put('frontend_theme', 'swiss', 'site', 'string', true); + $settings->put('backend_theme', 'admin', 'site', 'string', true); + + expect($settings->site()) + ->frontend_theme->toBe('swiss') + ->backend_theme->toBe('admin'); + + expect(app(Stylist::class)->frontend()->key())->toBe('swiss') + ->and(app(Stylist::class)->backend()->key())->toBe('admin'); +}); + +it('falls back to configured themes when stored theme keys are unavailable', function (): void { + $settings = app(SettingsRepository::class); + $settings->put('frontend_theme', 'missing-theme', 'site', 'string', true); + $settings->put('backend_theme', 'missing-theme', 'site', 'string', true); + + expect(app(Stylist::class)->frontend()->key())->toBe(config('stylist.default_frontend_theme')) + ->and(app(Stylist::class)->backend()->key())->toBe(config('stylist.default_backend_theme')); +}); diff --git a/Modules/Stylist/Config/config.php b/Modules/Stylist/Config/config.php index f971fa6..71b15d3 100644 --- a/Modules/Stylist/Config/config.php +++ b/Modules/Stylist/Config/config.php @@ -2,6 +2,8 @@ return [ 'default_theme' => env('STYLIST_DEFAULT_THEME', 'admin'), + 'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'admin'), + 'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'saasland'), /* | Themes are application resources, never module resources. diff --git a/Modules/Stylist/Helpers/helpers.php b/Modules/Stylist/Helpers/helpers.php index 9260a86..93c75b3 100644 --- a/Modules/Stylist/Helpers/helpers.php +++ b/Modules/Stylist/Helpers/helpers.php @@ -35,6 +35,20 @@ if (! function_exists('stylist_theme_view')) { } } +if (! function_exists('stylist_frontend_view')) { + function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View + { + return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData); + } +} + +if (! function_exists('stylist_backend_view')) { + function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View + { + return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData); + } +} + if (! function_exists('theme_asset')) { function theme_asset(string $path): string { diff --git a/Modules/Stylist/Services/TenantThemeResolver.php b/Modules/Stylist/Services/TenantThemeResolver.php index 48687db..58d4920 100644 --- a/Modules/Stylist/Services/TenantThemeResolver.php +++ b/Modules/Stylist/Services/TenantThemeResolver.php @@ -3,11 +3,15 @@ namespace Modules\Stylist\Services; use Illuminate\Support\Facades\Auth; +use Modules\Settings\Services\SettingsRepository; final class TenantThemeResolver { - public function resolve(): string + public function __construct(private readonly SettingsRepository $settings) {} + + public function resolve(?string $context = null): string { + $context ??= $this->context(); $column = config('stylist.tenant_theme_column', 'theme_key'); $user = Auth::user(); $company = $user?->company ?? null; @@ -23,7 +27,26 @@ final class TenantThemeResolver } } - return config('stylist.default_theme', 'default'); + $settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme'; + $default = $context === 'frontend' + ? config('stylist.default_frontend_theme', 'saasland') + : config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); + + return (string) $this->settings->get($settingKey, $default, 'site'); + } + + public function context(): string + { + if (app()->runningInConsole()) { + return 'backend'; + } + + $request = request(); + + return $request->routeIs('home', 'blog.public.*', 'news.public.*', 'pages.public.*') + || ! $request->user() + ? 'frontend' + : 'backend'; } public function variables(): array diff --git a/Modules/Stylist/Theme/Stylist.php b/Modules/Stylist/Theme/Stylist.php index dbc7114..82793ef 100644 --- a/Modules/Stylist/Theme/Stylist.php +++ b/Modules/Stylist/Theme/Stylist.php @@ -10,13 +10,15 @@ final class Stylist { public function __construct(private readonly Loader $loader, private readonly TenantThemeResolver $resolver, private readonly ThemeSettings $settings) {} - public function current(): Theme + public function current(?string $context = null): Theme { - $requested = $this->resolver->resolve(); + $requested = $this->resolver->resolve($context); try { return $this->loader->find($requested); } catch (ThemeNotFoundException) { - $fallback = (string) config('stylist.default_theme', 'default'); + $fallback = $context === 'frontend' + ? (string) config('stylist.default_frontend_theme', 'saasland') + : (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'admin')); if ($requested !== $fallback) { return $this->loader->find($fallback); } @@ -24,6 +26,16 @@ final class Stylist } } + public function frontend(): Theme + { + return $this->current('frontend'); + } + + public function backend(): Theme + { + return $this->current('backend'); + } + public function variables(): array { $theme = $this->current(); diff --git a/Modules/Stylist/Theme/Theme.php b/Modules/Stylist/Theme/Theme.php index f78f463..2010173 100644 --- a/Modules/Stylist/Theme/Theme.php +++ b/Modules/Stylist/Theme/Theme.php @@ -64,6 +64,11 @@ final class Theme return (string) ($this->meta['description'] ?? ''); } + public function type(): string + { + return (string) ($this->meta['type'] ?? 'frontend'); + } + public function author(): string { return (string) ($this->meta['author'] ?? ''); diff --git a/resources/themes/corporate/theme.json b/resources/themes/corporate/theme.json index ac2b44a..705f325 100644 --- a/resources/themes/corporate/theme.json +++ b/resources/themes/corporate/theme.json @@ -2,5 +2,6 @@ "name": "Corporate", "slug": "corporate", "version": "1.0.0", - "parent": "common" + "parent": "common", + "type": "backend" } diff --git a/routes/web.php b/routes/web.php index 7526173..2a00a39 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route; use Modules\Administration\Http\Controllers\DashboardController; Route::get('/', function () { - return stylist_theme_view('saasland', 'home'); + return stylist_frontend_view(stylist()->frontend()->entryView()); })->name('home'); Route::middleware(['auth', 'verified'])->group(function () {