move into sonardesk
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Settings',
|
||||
'enabled' => true,
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('group')->default('general')->index();
|
||||
$table->string('key');
|
||||
$table->longText('value')->nullable();
|
||||
$table->string('type')->default('string');
|
||||
$table->boolean('is_public')->default(false);
|
||||
$table->timestamps();
|
||||
$table->unique(['group', 'key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
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, Loader $themes): View
|
||||
{
|
||||
return view('settings::index', [
|
||||
'values' => $settings->site(),
|
||||
'sections' => $this->sections($themes),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SettingsRepository $settings, Loader $themes): RedirectResponse
|
||||
{
|
||||
$rules = [];
|
||||
foreach ($this->sections($themes) as $fields) {
|
||||
foreach ($fields as $key => $definition) {
|
||||
$rules["settings.$key"] = $definition['rules'];
|
||||
}
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
foreach ($validated['settings'] ?? [] as $key => $value) {
|
||||
$settings->put($key, $value, 'site', 'string', true);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Site settings saved. The active frontend and backend themes have been updated.');
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, array<string, mixed>>> */
|
||||
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'],
|
||||
'site_description' => $textarea + ['label' => 'Site description'],
|
||||
'company_name' => $text + ['label' => 'Legal company name'],
|
||||
'site_url' => $url + ['label' => 'Canonical site URL'],
|
||||
'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'],
|
||||
],
|
||||
'Branding' => [
|
||||
'logo_url' => $url + ['label' => 'Primary logo'],
|
||||
'logo_dark_url' => $url + ['label' => 'Dark-mode logo'],
|
||||
'favicon_url' => $url + ['label' => 'Favicon'],
|
||||
],
|
||||
'Contact' => [
|
||||
'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'],
|
||||
'contact_phone' => $text + ['label' => 'Public telephone'],
|
||||
'address' => $textarea + ['label' => 'Address'],
|
||||
],
|
||||
'Social' => [
|
||||
'facebook_url' => $url + ['label' => 'Facebook'],
|
||||
'x_url' => $url + ['label' => 'X / Twitter'],
|
||||
'linkedin_url' => $url + ['label' => 'LinkedIn'],
|
||||
'instagram_url' => $url + ['label' => 'Instagram'],
|
||||
],
|
||||
'Account navigation' => [
|
||||
'dashboard_label' => $text + ['label' => 'Dashboard label'],
|
||||
'login_label' => $text + ['label' => 'Login label'],
|
||||
'registration_label' => $text + ['label' => 'Registration label'],
|
||||
],
|
||||
'SEO defaults' => [
|
||||
'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'],
|
||||
'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Settings\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['group', 'key', 'value', 'type', 'is_public'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_public' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Settings\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
$this->routes(function (): void {
|
||||
Route::middleware('web')->group(module_path('Settings', 'Routes/web.php'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Settings\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Settings\Support\ModuleInfo;
|
||||
|
||||
final class SettingsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
|
||||
$this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<x-layouts.app :title="__('Site settings')">
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-orange-600">Single source of truth</p>
|
||||
<h1 class="text-3xl font-semibold">Site settings</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-zinc-500">
|
||||
Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('administration.menus.index') }}" class="rounded-lg border border-zinc-300 px-4 py-2 text-sm font-medium dark:border-zinc-700">Manage menus</a>
|
||||
</div>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('administration.settings.update') }}" class="space-y-6">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@foreach ($sections as $section => $fields)
|
||||
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 class="text-lg font-semibold">{{ $section }}</h2>
|
||||
</div>
|
||||
<div class="grid gap-5 p-6 md:grid-cols-2">
|
||||
@foreach ($fields as $key => $field)
|
||||
<label class="block {{ ($field['type'] ?? '') === 'textarea' ? 'md:col-span-2' : '' }}">
|
||||
<span class="text-sm font-medium">{{ $field['label'] }}</span>
|
||||
@if (! empty($field['help']))<span class="mt-1 block text-xs text-zinc-500">{{ $field['help'] }}</span>@endif
|
||||
@if (($field['type'] ?? 'text') === 'textarea')
|
||||
<textarea name="settings[{{ $key }}]" rows="4" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">{{ old("settings.$key", $values[$key] ?? '') }}</textarea>
|
||||
@elseif (($field['type'] ?? 'text') === 'select')
|
||||
<select name="settings[{{ $key }}]" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
@foreach (($field['options'] ?? []) as $optionValue => $optionLabel)
|
||||
<option value="{{ $optionValue }}" @selected(old("settings.$key", $values[$key] ?? '') === $optionValue)>{{ $optionLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input type="{{ $field['type'] ?? 'text' }}" name="settings[{{ $key }}]" value="{{ old("settings.$key", $values[$key] ?? '') }}" placeholder="{{ $field['placeholder'] ?? '' }}" class="mt-2 w-full rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
|
||||
@endif
|
||||
@error("settings.$key")<span class="mt-1 block text-sm text-red-600">{{ $message }}</span>@enderror
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
<div class="sticky bottom-4 flex justify-end rounded-2xl border border-zinc-200 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95">
|
||||
<flux:button type="submit" variant="primary">Save site settings</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Settings\Http\Controllers\SettingController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('administration/settings')->name('administration.settings.')->group(function (): void {
|
||||
Route::get('/', [SettingController::class, 'index'])->name('index');
|
||||
Route::put('/', [SettingController::class, 'update'])->name('update');
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Settings\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Settings\Models\Setting;
|
||||
|
||||
final class SettingsRepository
|
||||
{
|
||||
public function get(string $key, mixed $default = null, string $group = 'general'): mixed
|
||||
{
|
||||
if (! $this->tableExists()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
/** @var array{value: ?string, type: string}|null $setting */
|
||||
$setting = Cache::rememberForever(
|
||||
"settings.value.v2.$group.$key",
|
||||
fn (): ?array => Setting::query()
|
||||
->where([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
])
|
||||
->first(['value', 'type'])
|
||||
?->only(['value', 'type'])
|
||||
);
|
||||
|
||||
return $setting !== null
|
||||
? $this->decode($setting['value'], $setting['type'])
|
||||
: $default;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function group(string $group): array
|
||||
{
|
||||
if (! $this->tableExists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::rememberForever("settings.group.$group", function () use ($group): array {
|
||||
return Setting::query()
|
||||
->where('group', $group)
|
||||
->get()
|
||||
->mapWithKeys(fn (Setting $setting): array => [
|
||||
$setting->key => $this->decode($setting->value, $setting->type),
|
||||
])
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function site(): array
|
||||
{
|
||||
$defaults = [
|
||||
'site_name' => config('app.name', 'Content Manager'),
|
||||
'tagline' => '',
|
||||
'site_description' => 'Modern software for modern companies.',
|
||||
'company_name' => config('app.name', 'Content Manager'),
|
||||
'site_url' => url('/'),
|
||||
'logo_url' => '',
|
||||
'logo_dark_url' => '',
|
||||
'favicon_url' => '/favicon.ico',
|
||||
'contact_email' => '',
|
||||
'contact_phone' => '',
|
||||
'address' => '',
|
||||
'facebook_url' => '',
|
||||
'x_url' => '',
|
||||
'linkedin_url' => '',
|
||||
'instagram_url' => '',
|
||||
'copyright' => '',
|
||||
'dashboard_label' => 'Dashboard',
|
||||
'login_label' => 'Log in',
|
||||
'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'));
|
||||
}
|
||||
|
||||
public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting
|
||||
{
|
||||
$setting = Setting::query()->updateOrCreate(
|
||||
compact('group', 'key'),
|
||||
[
|
||||
'value' => $this->encode($value, $type),
|
||||
'type' => $type,
|
||||
'is_public' => $public,
|
||||
]
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private function tableExists(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable((new Setting())->getTable());
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function encode(mixed $value, string $type): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0',
|
||||
'integer' => (string) (int) $value,
|
||||
'float' => (string) (float) $value,
|
||||
'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR),
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function decode(?string $value, string $type): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
'boolean' => filter_var($value, FILTER_VALIDATE_BOOL),
|
||||
'integer' => (int) $value,
|
||||
'float' => (float) $value,
|
||||
'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [],
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Settings\Support;
|
||||
|
||||
final class ModuleInfo
|
||||
{
|
||||
public static function name(): string
|
||||
{
|
||||
return 'Settings';
|
||||
}
|
||||
|
||||
public static function nameLower(): string
|
||||
{
|
||||
return 'settings';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
it('boots the Settings module', function (): void {
|
||||
expect(config('settings.name'))->toBe('Settings');
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Modules\Settings\Services\SettingsRepository;
|
||||
|
||||
it('stores and reads typed site settings from one repository', function (): void {
|
||||
$repository = app(SettingsRepository::class);
|
||||
$repository->put('site_name', 'Unified CMS', 'site', 'string', true);
|
||||
expect($repository->site()['site_name'])->toBe('Unified CMS');
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Modules\Settings\Services\SettingsRepository;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
|
||||
it('stores separate active frontend and backend themes in site settings', function (): void {
|
||||
$settings = app(SettingsRepository::class);
|
||||
|
||||
$settings->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'));
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "absdev/settings-module",
|
||||
"description": "Settings foundation module for the ABSDEV Laravel application platform.",
|
||||
"type": "laravel-module",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Settings\\": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Settings",
|
||||
"alias": "settings",
|
||||
"version": "1.0.0",
|
||||
"description": "Settings foundation module for the ABSDEV Laravel application platform.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"modules",
|
||||
"settings"
|
||||
],
|
||||
"priority": 10,
|
||||
"providers": [
|
||||
"Modules\\Settings\\Providers\\SettingsServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": [
|
||||
"Core"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user