Import Export controls for themes

This commit is contained in:
Brian Fertig 2026-07-09 16:07:20 -06:00
parent 10bc170362
commit 921f7cbf93
4 changed files with 113 additions and 13 deletions

View File

@ -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))) {

View File

@ -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": {

View File

@ -157,7 +157,26 @@
</div>
</form>
<form method="POST" action="{{ route('admin.theme.reset') }}" class="mt-3"
{{-- Export / import --}}
<div class="mt-8 border-t border-gray-200 pt-6">
<p class="mb-1 font-semibold">Export &amp; import</p>
<p class="mb-3 text-sm text-gray-500">Move these color and gradient settings between sites as a YAML file. The logo is not included.</p>
<div class="flex flex-wrap items-center gap-3">
<a href="{{ route('admin.theme.export') }}"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 hover:bg-gray-50">Download theme (.yml)</a>
<form method="POST" action="{{ route('admin.theme.import') }}" enctype="multipart/form-data" class="flex items-center gap-2">
@csrf
<input type="file" name="theme_file" accept=".yml,.yaml" required
class="cursor-pointer text-sm text-gray-700 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-gray-200">
<button type="submit" class="cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 hover:bg-gray-50">Import</button>
</form>
</div>
@error('theme_file')
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<form method="POST" action="{{ route('admin.theme.reset') }}" class="mt-6"
onsubmit="return confirm('Reset the theme to stock defaults and delete the uploaded logo?')">
@csrf
<button type="submit" class="cursor-pointer text-sm text-red-700 hover:underline">Reset to defaults</button>

View File

@ -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');
});