From 921f7cbf93bb43d22b4a6a6529c092182f551eb1 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 9 Jul 2026 16:07:20 -0600 Subject: [PATCH] Import Export controls for themes --- src/app/Http/Controllers/ThemeController.php | 100 ++++++++++++++++-- src/composer.json | 3 +- .../views/admin/theme/edit.blade.php | 21 +++- src/routes/web.php | 2 + 4 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/app/Http/Controllers/ThemeController.php b/src/app/Http/Controllers/ThemeController.php index 04b9cad..1525c31 100644 --- a/src/app/Http/Controllers/ThemeController.php +++ b/src/app/Http/Controllers/ThemeController.php @@ -6,14 +6,29 @@ use App\Enums\NavGradientDirection; use App\Models\Theme; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\View\View; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; class ThemeController extends Controller { private const HEX = 'regex:/^#[0-9a-fA-F]{6}$/'; + /** Fields included in a theme export/import (logo intentionally excluded). */ + private const EXPORTABLE = [ + 'primary_color', + 'secondary_color', + 'tertiary_color', + 'nav_gradient_from', + 'nav_gradient_mid', + 'nav_gradient_to', + 'nav_gradient_direction', + ]; + public function edit(): View { return view('admin.theme.edit', ['theme' => Theme::current()]); @@ -22,19 +37,10 @@ class ThemeController extends Controller public function update(Request $request): RedirectResponse { $data = $request->validate([ - 'primary_color' => ['required', self::HEX], - 'secondary_color' => ['required', self::HEX], - 'tertiary_color' => ['required', self::HEX], - 'nav_gradient_from' => ['required', self::HEX], - 'nav_gradient_mid' => ['nullable', self::HEX], - 'nav_gradient_to' => ['nullable', self::HEX, 'required_unless:nav_gradient_direction,none'], - 'nav_gradient_direction' => ['required', Rule::enum(NavGradientDirection::class)], + ...$this->colorRules(), 'logo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,webp', 'max:2048'], 'remove_logo' => ['nullable', 'boolean'], - ], [ - 'nav_gradient_to.required_unless' => 'A gradient needs an end color — or choose "Solid (no gradient)".', - '*.regex' => 'Colors must be a 6-digit hex value like #2563eb.', - ]); + ], $this->colorMessages()); $theme = Theme::current(); @@ -57,6 +63,57 @@ class ThemeController extends Controller ->with('status', 'Theme updated.'); } + public function export(): Response + { + $theme = Theme::current(); + + $settings = collect(self::EXPORTABLE) + ->mapWithKeys(fn (string $key) => [ + $key => $theme->{$key} instanceof \BackedEnum ? $theme->{$key}->value : $theme->{$key}, + ]) + ->all(); + + return response(Yaml::dump($settings), 200, [ + 'Content-Type' => 'application/yaml', + 'Content-Disposition' => 'attachment; filename="theme.yml"', + ]); + } + + public function import(Request $request): RedirectResponse + { + $request->validate([ + 'theme_file' => ['required', 'file', 'extensions:yml,yaml', 'max:64'], + ]); + + try { + $settings = Yaml::parse($request->file('theme_file')->getContent()); + } catch (ParseException $e) { + return back()->withErrors(['theme_file' => 'That file is not valid YAML: ' . $e->getMessage()]); + } + + if (! is_array($settings)) { + return back()->withErrors(['theme_file' => 'The file must contain theme settings as key: value pairs.']); + } + + $validator = Validator::make( + Arr::only($settings, self::EXPORTABLE), + $this->colorRules(), + $this->colorMessages() + ); + + if ($validator->fails()) { + return back()->withErrors([ + 'theme_file' => 'The file is not a valid theme export: ' . $validator->errors()->first(), + ]); + } + + Theme::current()->fill($validator->validated())->save(); + + return redirect() + ->route('admin.theme.edit') + ->with('status', 'Theme imported.'); + } + public function reset(): RedirectResponse { $theme = Theme::current(); @@ -68,6 +125,27 @@ class ThemeController extends Controller ->with('status', 'Theme reset to defaults.'); } + private function colorRules(): array + { + return [ + 'primary_color' => ['required', self::HEX], + 'secondary_color' => ['required', self::HEX], + 'tertiary_color' => ['required', self::HEX], + 'nav_gradient_from' => ['required', self::HEX], + 'nav_gradient_mid' => ['nullable', self::HEX], + 'nav_gradient_to' => ['nullable', self::HEX, 'required_unless:nav_gradient_direction,none'], + 'nav_gradient_direction' => ['required', Rule::enum(NavGradientDirection::class)], + ]; + } + + private function colorMessages(): array + { + return [ + 'nav_gradient_to.required_unless' => 'A gradient needs an end color — or choose "Solid (no gradient)".', + '*.regex' => 'Colors must be a 6-digit hex value like #2563eb.', + ]; + } + private function deleteLogoFile(Theme $theme): void { if ($theme->logo_path && is_file(public_path($theme->logo_path))) { diff --git a/src/composer.json b/src/composer.json index bf5bb0a..d28ad3b 100644 --- a/src/composer.json +++ b/src/composer.json @@ -6,7 +6,8 @@ "require": { "php": "^8.4", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.0" + "laravel/sanctum": "^4.0", + "symfony/yaml": "^8.1" }, "autoload": { "psr-4": { diff --git a/src/resources/views/admin/theme/edit.blade.php b/src/resources/views/admin/theme/edit.blade.php index 7d83541..6ab46f2 100644 --- a/src/resources/views/admin/theme/edit.blade.php +++ b/src/resources/views/admin/theme/edit.blade.php @@ -157,7 +157,26 @@ -
+

Export & import

+

Move these color and gradient settings between sites as a YAML file. The logo is not included.

+
+ Download theme (.yml) + + @csrf + + + +
+ @error('theme_file') +

{{ $message }}

+ @enderror + + +
@csrf diff --git a/src/routes/web.php b/src/routes/web.php index 172e591..cd63e8b 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -115,4 +115,6 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun Route::get('/theme', [ThemeController::class, 'edit'])->name('theme.edit'); Route::put('/theme', [ThemeController::class, 'update'])->name('theme.update'); Route::post('/theme/reset', [ThemeController::class, 'reset'])->name('theme.reset'); + Route::get('/theme/export', [ThemeController::class, 'export'])->name('theme.export'); + Route::post('/theme/import', [ThemeController::class, 'import'])->name('theme.import'); });