feat: Implement user settings management with dynamic theme customization, including UX style, primary color, font, and UI preferences, enhancing user experience across the application.

This commit is contained in:
Yoga Pangestu 2026-03-26 15:35:22 +07:00
parent 0086ff50d2
commit 56ffcb5508
12 changed files with 502 additions and 37 deletions

View File

@ -5,14 +5,17 @@
use App\Enums\UxStyle;
use App\Filament\Support\CheerfulNotification;
use Filament\Auth\Pages\EditProfile;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Exceptions\Halt;
use Filament\Support\Facades\FilamentView;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
@ -24,22 +27,82 @@ public function getMaxContentWidth(): Width
return Width::FourExtraLarge;
}
protected function fillForm(): void
{
$user = $this->getUser();
$user->load('settings');
$data = $user->toArray();
$data['settings'] = $user->settings?->toArray() ?? [];
$this->form->fill($data);
}
public function save(): void
{
try {
$this->beginDatabaseTransaction();
$this->callHook('beforeValidate');
$data = $this->form->getState();
$this->callHook('afterValidate');
$data = $this->mutateFormDataBeforeSave($data);
$this->callHook('beforeSave');
$this->handleRecordUpdate($this->getUser(), $data);
$this->callHook('afterSave');
} catch (Halt $exception) {
$exception->shouldRollbackDatabaseTransaction() ?
$this->rollBackDatabaseTransaction() :
$this->commitDatabaseTransaction();
return;
} catch (\Throwable $exception) {
$this->rollBackDatabaseTransaction();
throw $exception;
}
$this->commitDatabaseTransaction();
if (request()->hasSession() && array_key_exists('password', $data)) {
request()->session()->put([
'password_hash_'.Filament::getAuthGuard() => $data['password'],
]);
}
$this->data['password'] = null;
$this->data['passwordConfirmation'] = null;
$this->getSavedNotification()?->send();
if ($redirectUrl = $this->getRedirectUrl()) {
$this->redirect($redirectUrl, navigate: FilamentView::hasSpaMode($redirectUrl));
}
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make(fn () => CheerfulNotification::getMessage('Detail Profil 👤✨', 'Profil Pengguna'))
->description(fn () => CheerfulNotification::getMessage('Yuk, kelola informasi detail profil Anda agar selalu terkini! 👤🚀', 'Kelola informasi profil dasar Anda di bawah ini.'))
->icon(fn () => CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-user' : null)
Section::make(CheerfulNotification::getMessage('Detail Profil 👤✨', 'Profil Pengguna'))
->description(CheerfulNotification::getMessage('Yuk, kelola informasi detail profil Anda agar selalu terkini! 👤🚀', 'Kelola informasi profil dasar Anda di bawah ini.'))
->icon(CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-user' : null)
->schema([
TextInput::make('name')
->label('Nama Lengkap')
->placeholder('Masukkan nama lengkap Anda')
->required()
->maxLength(100),
Grid::make(2)
->schema([
TextInput::make('name')
->label('Nama Lengkap')
->placeholder('Masukkan nama lengkap Anda')
->required()
->maxLength(100)
->columnSpanFull(),
TextInput::make('email')
->label('Alamat Surel')
->placeholder('contoh@email.com')
@ -61,19 +124,82 @@ public function form(Schema $schema): Schema
->regex('/^[a-zA-Z0-9_.-]+$/')
->unique(ignoreRecord: true)
->prefixIcon('heroicon-o-at-symbol'),
Select::make('ux_style')
->label('Gaya Bahasa Aplikasi')
->options(UxStyle::class)
->native(false)
->required()
->prefixIcon('heroicon-o-chat-bubble-bottom-center-text'),
]),
]),
Section::make(fn () => CheerfulNotification::getMessage('Keamanan Akun 🔐✨', 'Ubah Kata Sandi'))
->description(fn () => CheerfulNotification::getMessage('Ingin ganti kata sandi? Pastikan pilih yang kuat ya biar makin aman! 🔐💪', 'Silakan masukkan kata sandi baru untuk memperbarui keamanan akun Anda.'))
->icon(fn () => CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-lock-closed' : null)
Section::make(CheerfulNotification::getMessage('Pengaturan Tampilan & UX 🎨✨', 'Preferensi Tampilan'))
->description(CheerfulNotification::getMessage('Personalisasi pengalaman aplikasi Anda agar lebih nyaman dan sesuai selera! 🌈🚀', 'Sesuaikan gaya bahasa, warna tema, dan jenis huruf aplikasi Anda.'))
->icon(CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-swatch' : null)
->schema([
Grid::make(2)
->schema([
Select::make('settings.ux_style')
->label('Gaya Bahasa')
->options(UxStyle::class)
->native(false)
->required(),
Select::make('settings.primary_color')
->label('Tema Warna')
->options([
'blue' => '🔵 Biru (Default)',
'sky' => '💎 Biru Langit',
'cyan' => '🌊 Biru Tosca (Cyan)',
'emerald' => '🟢 Emerald (Hijau)',
'teal' => '🍃 Teal (Hijau Keunguan)',
'lime' => '🍋 Lime (Hijah Muda)',
'amber' => '🟡 Amber (Kuning)',
'orange' => '🟠 Orange',
'rose' => '🔴 Rose (Merah)',
'fuchsia' => '🌸 Fuchsia (Pink Cerah)',
'violet' => '🟣 Violet (Ungu)',
'indigo' => '🌌 Indigo (Ungu Gelap)',
])
->placeholder('Pilih warna tema')
->native(false),
Select::make('settings.font')
->label('Jenis Huruf (Font)')
->options([
'Inter' => 'Inter (Modern)',
'Roboto' => 'Roboto (Clean)',
'Poppins' => 'Poppins (Rounder)',
'Outfit' => 'Outfit (Premium)',
'Montserrat' => 'Montserrat (Classic)',
'Lexend' => 'Lexend (Readable)',
])
->native(false),
Select::make('settings.content_width')
->label('Lebar Konten')
->options([
'full' => '↔️ Lebar Penuh (Full)',
'centered' => '🏢 Terpusat (Centered)',
])
->native(false),
Select::make('settings.border_radius')
->label('Radius Sudut (Border)')
->options([
'none' => '📐 Tegas (Sharp)',
'md' => '📱 Modern (Default)',
'lg' => '🎉 Rounded (Cheerful)',
'xl' => '🎈 Extra Round',
])
->native(false),
Toggle::make('settings.top_navigation')
->label('Navigasi Atas (Top Nav)')
->helperText('Pindahkan menu navigasi dari samping ke bagian atas layar.')
->onIcon('heroicon-m-window')
->offIcon('heroicon-m-arrow-top-right-on-square')
->inline(false),
]),
]),
Section::make(CheerfulNotification::getMessage('Keamanan Akun 🔐✨', 'Ubah Kata Sandi'))
->description(CheerfulNotification::getMessage('Ingin ganti kata sandi? Pastikan pilih yang kuat ya biar makin aman! 🔐💪', 'Silakan masukkan kata sandi baru untuk memperbarui keamanan akun Anda.'))
->icon(CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-lock-closed' : null)
->schema([
Grid::make(2)
->schema([
@ -82,7 +208,6 @@ public function form(Schema $schema): Schema
->placeholder('********')
->password()
->revealable(filament()->arePasswordsRevealable())
->required(fn (Get $get) => filled($get('password')) || filled($get('passwordConfirmation')))
->rule(Password::default())
->showAllValidationMessages()
->dehydrated(fn ($state) => filled($state))
@ -96,7 +221,6 @@ public function form(Schema $schema): Schema
->placeholder('********')
->password()
->revealable(filament()->arePasswordsRevealable())
->required(fn (Get $get) => filled($get('password')) || filled($get('passwordConfirmation')))
->dehydrated(false)
->prefixIcon('heroicon-o-lock-closed'),
]),
@ -106,8 +230,17 @@ public function form(Schema $schema): Schema
protected function handleRecordUpdate(Model $record, array $data): Model
{
$settingsData = $data['settings'] ?? [];
unset($data['settings']);
$record->update($data);
if ($record->settings) {
$record->settings->update($settingsData);
} else {
$record->settings()->create($settingsData);
}
return $record;
}
@ -118,8 +251,14 @@ protected function afterSave(): void
CheerfulNotification::getMessage('Sip! Detail profil baru Anda sudah tersimpan dengan aman. Terus semangat ya! 💪😊', 'Perubahan pada profil Anda telah berhasil disimpan ke dalam sistem.')
)->send();
if (! filled($this->form->getState()['password'] ?? null)) {
$this->redirect($this->getUrl());
return;
}
if (filled($this->form->getState()['password'] ?? null)) {
auth()->logout();
Filament::auth()->logout();
session()->invalidate();
session()->regenerateToken();

View File

@ -27,9 +27,9 @@ public static function configure(Schema $schema): Schema
Hidden::make('author_id')
->default(auth()->id()),
Section::make(fn () => CheerfulNotification::getMessage('Konten Berita ✍️', 'Konten Berita'))
->description(fn () => CheerfulNotification::getMessage('Yuk, tuliskan judul, ringkasan, dan isi lengkap berita Anda biar makin menarik! 🚀😊', 'Silakan tuliskan judul, ringkasan, dan isi lengkap berita Anda.'))
->icon(fn () => CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-pencil-square' : null)
Section::make(CheerfulNotification::getMessage('Konten Berita ✍️', 'Konten Berita'))
->description(CheerfulNotification::getMessage('Yuk, tuliskan judul, ringkasan, dan isi lengkap berita Anda biar makin menarik! 🚀😊', 'Silakan tuliskan judul, ringkasan, dan isi lengkap berita Anda.'))
->icon(CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-pencil-square' : null)
->schema([
TextInput::make('title')
->label('Judul Berita')
@ -52,9 +52,9 @@ public static function configure(Schema $schema): Schema
->columnSpanFull(),
])->columnSpan(2),
Section::make(fn () => CheerfulNotification::getMessage('Kategori & Publikasi 🏷️', 'Kategori & Publikasi'))
->description(fn () => CheerfulNotification::getMessage('Tentukan kategori, tautan media, dan status publikasi biar berita kita gampang dicari! 🏷️✨', 'Tentukan kategori, tautan media, dan status publikasi.'))
->icon(fn () => CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-tag' : null)
Section::make(CheerfulNotification::getMessage('Kategori & Publikasi 🏷️', 'Kategori & Publikasi'))
->description(CheerfulNotification::getMessage('Tentukan kategori, tautan media, dan status publikasi biar berita kita gampang dicari! 🏷️✨', 'Tentukan kategori, tautan media, dan status publikasi.'))
->icon(CheerfulNotification::getUxStyle() === UxStyle::CHEERFUL ? 'heroicon-o-tag' : null)
->schema([
TextInput::make('link')
->label('Tautan Media (Opsional)')

View File

@ -21,7 +21,7 @@ public static function make(): Notification
public static function getUxStyle(): UxStyle
{
try {
return auth()->user()?->ux_style ?? UxStyle::CHEERFUL;
return auth()->user()?->settings?->ux_style ?? UxStyle::CHEERFUL;
} catch (\Throwable $e) {
return UxStyle::CHEERFUL;
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Filament\Facades\Filament;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Illuminate\Http\Request;
use Illuminate\Support\HtmlString;
use Symfony\Component\HttpFoundation\Response;
class DynamicFilamentTheme
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = Filament::auth()->user();
if ($user && $user->settings) {
// Register Colors
FilamentColor::register([
'primary' => match ($user->settings->primary_color) {
'blue' => Color::Blue,
'sky' => Color::Sky,
'cyan' => Color::Cyan,
'emerald' => Color::Emerald,
'teal' => Color::Teal,
'lime' => Color::Lime,
'amber' => Color::Amber,
'orange' => Color::Orange,
'rose' => Color::Rose,
'fuchsia' => Color::Fuchsia,
'violet' => Color::Violet,
'indigo' => Color::Indigo,
default => Color::Blue,
},
]);
// Register Top Navigation
$panel = Filament::getCurrentOrDefaultPanel();
if ($panel && method_exists($panel, 'topNavigation')) {
$panel->topNavigation((bool) $user->settings->top_navigation);
}
// Register Font & UI Styles
$font = $user->settings->font ?? 'Inter';
$radius = match ($user->settings->border_radius ?? 'md') {
'none' => '0px',
'md' => '0.375rem',
'lg' => '0.5rem',
'xl' => '0.75rem',
'2xl' => '1rem',
default => '0.5rem',
};
$maxWidth = match ($user->settings->content_width ?? 'full') {
'centered' => '80rem',
default => 'none',
};
FilamentView::registerRenderHook(
PanelsRenderHook::HEAD_END,
fn () => new HtmlString("
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
<link href='https://fonts.googleapis.com/css2?family={$font}:wght@400;500;600;700&display=swap' rel='stylesheet'>
<style>
:root {
--font-family: '{$font}', sans-serif;
--c-border-radius: {$radius};
}
/* Override Filament maximum width if centered */
.fi-main-ctn {
max-width: {$maxWidth} !important;
margin-left: auto !important;
margin-right: auto !important;
}
/* Apply border radius to common elements */
.fi-section, .fi-btn, .fi-input, .fi-card, .fi-modal-window {
border-radius: {$radius} !important;
}
</style>
")
);
}
return $next($request);
}
}

View File

@ -4,11 +4,12 @@
use App\Enums\IsActive;
use App\Enums\RoleEnum;
use App\Enums\UxStyle;
use App\Enums\VerificationStatus;
use App\Observers\UserObserver;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -20,6 +21,7 @@
use Spatie\Permission\Traits\HasRoles;
use Swindon\FilamentHashids\Traits\HasHashid;
#[ObservedBy(UserObserver::class)]
class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
use HasFactory, HasHashid, HasRoles, Notifiable, SoftDeletes;
@ -34,7 +36,6 @@ protected function casts(): array
'password' => 'hashed',
'is_active' => IsActive::class,
'email_verified_at' => 'timestamp',
'ux_style' => UxStyle::class,
];
}
@ -104,6 +105,11 @@ public function verificationReviews(): HasMany
return $this->hasMany(VerificationReview::class, 'reviewer_id');
}
public function settings(): HasOne
{
return $this->hasOne(UserSetting::class);
}
public function visitors(): HasMany
{
return $this->hasMany(Visitor::class);

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Enums\UxStyle;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSetting extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'ux_style' => UxStyle::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Observers;
use App\Enums\UxStyle;
use App\Models\User;
class UserObserver
{
/**
* Handle the User "created" event.
*/
public function created(User $user): void
{
$user->settings()->create([
'ux_style' => UxStyle::CHEERFUL,
'primary_color' => 'blue',
]);
}
/**
* Handle the User "updated" event.
*/
public function updated(User $user): void
{
//
}
/**
* Handle the User "deleted" event.
*/
public function deleted(User $user): void
{
//
}
/**
* Handle the User "restored" event.
*/
public function restored(User $user): void
{
//
}
/**
* Handle the User "force deleted" event.
*/
public function forceDeleted(User $user): void
{
//
}
}

View File

@ -8,8 +8,10 @@
use App\Filament\Pages\Auth\Profile;
use App\Filament\Pages\Auth\Registration;
use App\Filament\Pages\Dashboard;
use App\Http\Middleware\DynamicFilamentTheme;
use App\Settings\GeneralSettings;
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -54,8 +56,8 @@ public function panel(Panel $panel): Panel
->default()
->id('dashboard')
->path('dashboard')
->brandLogo(fn () => Storage::disk('public')->url($generalSettings->site_logo))
->favicon(fn () => Storage::disk('public')->url($generalSettings->site_icon))
->brandLogo(Storage::disk('public')->url($generalSettings->site_logo))
->favicon(Storage::disk('public')->url($generalSettings->site_icon))
->login(Login::class)
->profile(Profile::class)
->registration(Registration::class)
@ -99,6 +101,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentHashidsMiddleware::class,
DynamicFilamentTheme::class,
])
->plugins([
FilamentShieldPlugin::make()
@ -117,7 +120,7 @@ public function panel(Panel $panel): Panel
->navigationLabel('Log')
->navigationUrl('/system/logs')
->pollingTime(null)
->authorize(fn (): bool => auth()->user()->can('View:LogTable')),
->authorize(fn (): bool => Filament::auth()->user()?->can('View:LogTable') ?? false),
])
->authMiddleware([
Authenticate::class,
@ -135,8 +138,6 @@ public function panel(Panel $panel): Panel
])
->databaseNotifications()
->databaseNotificationsPolling('30s')
->brandLogo(fn () => Storage::disk('public')->url($generalSettings?->site_logo))
->favicon(fn () => Storage::disk('public')->url($generalSettings?->site_icon))
->globalSearch(false);
}
}

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->onDelete('cascade');
$table->string('ux_style')->default('cheerful');
$table->string('primary_color')->nullable(); // For future extension
$table->timestamps();
});
// Migrate existing data from users table (already has ux_style from earlier migration)
$users = DB::table('users')->get();
foreach ($users as $user) {
DB::table('user_settings')->updateOrInsert(
['user_id' => $user->id],
[
'ux_style' => $user->ux_style ?? 'cheerful',
'created_at' => now(),
'updated_at' => now(),
]
);
}
// Drop the old column from users
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('ux_style');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('ux_style')->default('cheerful')->after('email_verified_at');
});
// Revert data
$settings = DB::table('user_settings')->get();
foreach ($settings as $setting) {
DB::table('users')
->where('id', $setting->user_id)
->update(['ux_style' => $setting->ux_style]);
}
Schema::dropIfExists('user_settings');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->string('font')->default('Inter')->after('primary_color');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('font');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->string('content_width')->default('full')->after('font');
$table->string('border_radius')->default('lg')->after('content_width');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn(['content_width', 'border_radius']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->boolean('top_navigation')->default(false)->after('border_radius');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('top_navigation');
});
}
};