diff --git a/app/Filament/Pages/Auth/Profile.php b/app/Filament/Pages/Auth/Profile.php index 2f58a26..1530872 100644 --- a/app/Filament/Pages/Auth/Profile.php +++ b/app/Filament/Pages/Auth/Profile.php @@ -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(); diff --git a/app/Filament/Resources/Publication/News/Schemas/NewsForm.php b/app/Filament/Resources/Publication/News/Schemas/NewsForm.php index 048a1e1..e252818 100644 --- a/app/Filament/Resources/Publication/News/Schemas/NewsForm.php +++ b/app/Filament/Resources/Publication/News/Schemas/NewsForm.php @@ -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)') diff --git a/app/Filament/Support/CheerfulNotification.php b/app/Filament/Support/CheerfulNotification.php index ecf70e0..958e7a6 100644 --- a/app/Filament/Support/CheerfulNotification.php +++ b/app/Filament/Support/CheerfulNotification.php @@ -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; } diff --git a/app/Http/Middleware/DynamicFilamentTheme.php b/app/Http/Middleware/DynamicFilamentTheme.php new file mode 100644 index 0000000..d48b62c --- /dev/null +++ b/app/Http/Middleware/DynamicFilamentTheme.php @@ -0,0 +1,97 @@ +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(" + + + + + ") + ); + } + + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 50ae6b8..300fa9b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); diff --git a/app/Models/UserSetting.php b/app/Models/UserSetting.php new file mode 100644 index 0000000..03a9621 --- /dev/null +++ b/app/Models/UserSetting.php @@ -0,0 +1,24 @@ + UxStyle::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 0000000..aa0367e --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,52 @@ +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 + { + // + } +} diff --git a/app/Providers/Filament/DashboardPanelProvider.php b/app/Providers/Filament/DashboardPanelProvider.php index c0eb66f..8d9b275 100644 --- a/app/Providers/Filament/DashboardPanelProvider.php +++ b/app/Providers/Filament/DashboardPanelProvider.php @@ -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); } } diff --git a/database/migrations/2026_03_26_144130_create_user_settings_table.php b/database/migrations/2026_03_26_144130_create_user_settings_table.php new file mode 100644 index 0000000..51835a9 --- /dev/null +++ b/database/migrations/2026_03_26_144130_create_user_settings_table.php @@ -0,0 +1,61 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_26_151805_add_font_to_user_settings_table.php b/database/migrations/2026_03_26_151805_add_font_to_user_settings_table.php new file mode 100644 index 0000000..50e6f18 --- /dev/null +++ b/database/migrations/2026_03_26_151805_add_font_to_user_settings_table.php @@ -0,0 +1,28 @@ +string('font')->default('Inter')->after('primary_color'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_settings', function (Blueprint $table) { + $table->dropColumn('font'); + }); + } +}; diff --git a/database/migrations/2026_03_26_152053_add_ui_preferences_to_user_settings_table.php b/database/migrations/2026_03_26_152053_add_ui_preferences_to_user_settings_table.php new file mode 100644 index 0000000..94f3ccb --- /dev/null +++ b/database/migrations/2026_03_26_152053_add_ui_preferences_to_user_settings_table.php @@ -0,0 +1,29 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_03_26_152631_add_top_navigation_to_user_settings_table.php b/database/migrations/2026_03_26_152631_add_top_navigation_to_user_settings_table.php new file mode 100644 index 0000000..ed84a43 --- /dev/null +++ b/database/migrations/2026_03_26_152631_add_top_navigation_to_user_settings_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +};