Import Export controls for themes
This commit is contained in:
parent
10bc170362
commit
921f7cbf93
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue