Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/Http/Controllers/Settings/AppearanceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace App\Http\Controllers\Settings;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;

class AppearanceController extends Controller
Expand All @@ -11,4 +14,15 @@ public function edit(): View
{
return view('settings.appearance');
}

public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'theme_preference' => ['required', Rule::in(['light', 'dark', 'system'])],
]);

$request->user()->update($validated);

return back();
}
}
1 change: 1 addition & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'theme_preference',
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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('users', function (Blueprint $table) {
$table->string('theme_preference')->nullable()->default('system')->after('password');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('theme_preference');
});
}
};
6 changes: 5 additions & 1 deletion resources/views/components/layouts/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@
document.addEventListener("DOMContentLoaded", () => setButtons(appearance))
}
}
window.setAppearance(window.localStorage.getItem('appearance') || 'system')
window.setAppearance(
"{{ auth()->user()->theme_preference ?? '' }}" ||
window.localStorage.getItem('appearance') ||
'system'
)
</script>

@vite(['resources/css/app.css', 'resources/js/app.js'])
Expand Down
75 changes: 74 additions & 1 deletion resources/views/components/layouts/app/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,81 @@ class="p-2 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:
<div class="ml-4 font-semibold text-xl text-blue-600 dark:text-blue-400">{{ config('app.name') }}</div>
</div>

<!-- Right side: Search, notifications, profile -->
<!-- Right side: Theme toggle, Search, notifications, profile -->
<div class="flex items-center space-x-4">
<!-- Theme Toggle -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="p-2 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none transition-colors duration-200">
<!-- Sun icon for light mode -->
<svg x-show="localStorage.theme !== 'dark'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 9H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 5a7 7 0 100 14 7 7 0 000-14z" />
</svg>
<!-- Moon icon for dark mode -->
<svg x-show="localStorage.theme === 'dark'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>

<div x-show="open" @click.away="open = false" x-transition
class="absolute border border-gray-200 dark:border-gray-700 right-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50">
<form id="header-appearance-form" action="{{ route('settings.appearance.update') }}" method="POST"
class="hidden">
@csrf
@method('PUT')
<input type="hidden" name="theme_preference" id="header_theme_preference">
</form>

<button type="button" onclick="persistTheme('light')"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center {{ (auth()->user()->theme_preference ?? 'system') === 'light' ? 'bg-gray-100 text-blue-700 dark:bg-gray-700 dark:text-blue-400 font-medium' : 'text-gray-700 dark:text-gray-300' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 9H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 5a7 7 0 100 14 7 7 0 000-14z" />
</svg>
Light
</button>
<button type="button" onclick="persistTheme('dark')"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center {{ (auth()->user()->theme_preference ?? 'system') === 'dark' ? 'bg-gray-100 text-blue-700 dark:bg-gray-700 dark:text-blue-400 font-medium' : 'text-gray-700 dark:text-gray-300' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
Dark
</button>
<button type="button" onclick="persistTheme('system')"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center {{ (auth()->user()->theme_preference ?? 'system') === 'system' ? 'bg-gray-100 text-blue-700 dark:bg-gray-700 dark:text-blue-400 font-medium' : 'text-gray-700 dark:text-gray-300' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
System
</button>
</div>

<script>
window.persistTheme = function(theme) {
// Update UI immediately (client-side)
if (typeof window.setAppearance === 'function') {
window.setAppearance(theme);
}

// Set and submit form for persistence
const form = document.getElementById('header-appearance-form');
const input = document.getElementById('header_theme_preference');
if (form && input) {
input.value = theme;
form.submit();
}
}
</script>
</div>
<!-- Profile -->
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" class="flex items-center focus:outline-none">
Expand Down
65 changes: 47 additions & 18 deletions resources/views/settings/appearance.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,54 @@ class="text-blue-600 dark:text-blue-400 hover:underline">{{ __('Profile') }}</a>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div class="p-6">
<!-- Profile Form -->
<div class="mb-4">
<label for="theme"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('Theme') }}</label>
<div class="inline-flex rounded-md shadow-sm" role="group">
<button onclick="setAppearance('light')"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white">
{{ __('Light') }}
</button>
<button onclick="setAppearance('dark')"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white">
{{ __('Dark') }}
</button>
<button onclick="setAppearance('system')"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-r-md hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white">
{{ __('System') }}
</button>
<!-- Theme Form -->
<form id="appearance-form" action="{{ route('settings.appearance.update') }}" method="POST">
@csrf
@method('PUT')
<input type="hidden" name="theme_preference" id="theme_preference"
value="{{ auth()->user()->theme_preference ?? 'system' }}">

<div class="mb-4">
<label for="theme"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('Theme') }}</label>
<div class="inline-flex rounded-md shadow-sm" role="group">
<button type="button" onclick="setAppearance('light')"
class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white {{ (auth()->user()->theme_preference ?? 'system') === 'light' ? 'bg-gray-100 text-blue-700 dark:bg-gray-600 dark:text-white' : 'bg-white text-gray-900 dark:bg-gray-700 dark:text-white' }}">
{{ __('Light') }}
</button>
<button type="button" onclick="setAppearance('dark')"
class="px-4 py-2 text-sm font-medium border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white {{ (auth()->user()->theme_preference ?? 'system') === 'dark' ? 'bg-gray-100 text-blue-700 dark:bg-gray-600 dark:text-white' : 'bg-white text-gray-900 dark:bg-gray-700 dark:text-white' }}">
{{ __('Dark') }}
</button>
<button type="button" onclick="setAppearance('system')"
class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-r-md hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500 dark:focus:text-white {{ (auth()->user()->theme_preference ?? 'system') === 'system' ? 'bg-gray-100 text-blue-700 dark:bg-gray-600 dark:text-white' : 'bg-white text-gray-900 dark:bg-gray-700 dark:text-white' }}">
{{ __('System') }}
</button>
</div>
</div>
</div>
</form>

<script>
function setAppearance(theme) {
document.getElementById('theme_preference').value = theme;

// Apply immediate visual feedback
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
} else {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
}

if (theme === 'system') {
localStorage.removeItem('theme');
}

// Submit the form to persist in database
document.getElementById('appearance-form').submit();
}
</script>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Route::get('settings/password', [Settings\PasswordController::class, 'edit'])->name('settings.password.edit');
Route::put('settings/password', [Settings\PasswordController::class, 'update'])->name('settings.password.update');
Route::get('settings/appearance', [Settings\AppearanceController::class, 'edit'])->name('settings.appearance.edit');
Route::put('settings/appearance', [Settings\AppearanceController::class, 'update'])->name('settings.appearance.update');
});

require __DIR__.'/auth.php';
90 changes: 90 additions & 0 deletions tests/Feature/Settings/AppearanceUpdateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Tests\Feature\Settings;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AppearanceUpdateTest extends TestCase
{
use RefreshDatabase;

public function test_appearance_settings_screen_can_be_rendered(): void
{
$user = User::factory()->create();

$response = $this->actingAs($user)->get('/settings/appearance');

$response->assertStatus(200);
}

public function test_users_can_update_theme_to_light(): void
{
$user = User::factory()->create([
'theme_preference' => 'system',
]);

$response = $this->actingAs($user)
->from('/settings/appearance')
->put('/settings/appearance', [
'theme_preference' => 'light',
]);

$response->assertSessionHasNoErrors();
$response->assertRedirect('/settings/appearance');

$this->assertEquals('light', $user->refresh()->theme_preference);
}

public function test_users_can_update_theme_to_dark(): void
{
$user = User::factory()->create([
'theme_preference' => 'light',
]);

$response = $this->actingAs($user)->put('/settings/appearance', [
'theme_preference' => 'dark',
]);

$response->assertSessionHasNoErrors();
$this->assertEquals('dark', $user->refresh()->theme_preference);
}

public function test_users_can_update_theme_to_system(): void
{
$user = User::factory()->create([
'theme_preference' => 'dark',
]);

$response = $this->actingAs($user)->put('/settings/appearance', [
'theme_preference' => 'system',
]);

$response->assertSessionHasNoErrors();
$this->assertEquals('system', $user->refresh()->theme_preference);
}

public function test_theme_update_requires_valid_value(): void
{
$user = User::factory()->create([
'theme_preference' => 'system',
]);

$response = $this->actingAs($user)->put('/settings/appearance', [
'theme_preference' => 'invalid-theme',
]);

$response->assertSessionHasErrors('theme_preference');
$this->assertEquals('system', $user->refresh()->theme_preference);
}

public function test_guests_cannot_update_theme_preference(): void
{
$response = $this->put('/settings/appearance', [
'theme_preference' => 'dark',
]);

$response->assertRedirect('/login');
}
}