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 App\Models\Theme;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\Yaml\Exception\ParseException;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class ThemeController extends Controller
|
class ThemeController extends Controller
|
||||||
{
|
{
|
||||||
private const HEX = 'regex:/^#[0-9a-fA-F]{6}$/';
|
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
|
public function edit(): View
|
||||||
{
|
{
|
||||||
return view('admin.theme.edit', ['theme' => Theme::current()]);
|
return view('admin.theme.edit', ['theme' => Theme::current()]);
|
||||||
|
|
@ -22,19 +37,10 @@ class ThemeController extends Controller
|
||||||
public function update(Request $request): RedirectResponse
|
public function update(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'primary_color' => ['required', self::HEX],
|
...$this->colorRules(),
|
||||||
'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)],
|
|
||||||
'logo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,webp', 'max:2048'],
|
'logo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,webp', 'max:2048'],
|
||||||
'remove_logo' => ['nullable', 'boolean'],
|
'remove_logo' => ['nullable', 'boolean'],
|
||||||
], [
|
], $this->colorMessages());
|
||||||
'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.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$theme = Theme::current();
|
$theme = Theme::current();
|
||||||
|
|
||||||
|
|
@ -57,6 +63,57 @@ class ThemeController extends Controller
|
||||||
->with('status', 'Theme updated.');
|
->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
|
public function reset(): RedirectResponse
|
||||||
{
|
{
|
||||||
$theme = Theme::current();
|
$theme = Theme::current();
|
||||||
|
|
@ -68,6 +125,27 @@ class ThemeController extends Controller
|
||||||
->with('status', 'Theme reset to defaults.');
|
->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
|
private function deleteLogoFile(Theme $theme): void
|
||||||
{
|
{
|
||||||
if ($theme->logo_path && is_file(public_path($theme->logo_path))) {
|
if ($theme->logo_path && is_file(public_path($theme->logo_path))) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0"
|
"laravel/sanctum": "^4.0",
|
||||||
|
"symfony/yaml": "^8.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,26 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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?')">
|
onsubmit="return confirm('Reset the theme to stock defaults and delete the uploaded logo?')">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="cursor-pointer text-sm text-red-700 hover:underline">Reset to defaults</button>
|
<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::get('/theme', [ThemeController::class, 'edit'])->name('theme.edit');
|
||||||
Route::put('/theme', [ThemeController::class, 'update'])->name('theme.update');
|
Route::put('/theme', [ThemeController::class, 'update'])->name('theme.update');
|
||||||
Route::post('/theme/reset', [ThemeController::class, 'reset'])->name('theme.reset');
|
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