Add OS dependant dark/light mode switch

This commit is contained in:
DariusIII
2025-10-16 21:23:44 +02:00
parent a1ea6b4101
commit 5508d39d42
6 changed files with 313 additions and 108 deletions
+9 -6
View File
@@ -142,9 +142,12 @@ class ProfileController extends BasePageController
'None',
);
// Update dark mode preference
if ($request->has('dark_mode')) {
User::where('id', $userid)->update(['dark_mode' => (bool) $request->input('dark_mode')]);
// Update theme preference
if ($request->has('theme_preference')) {
$themeValue = $request->input('theme_preference');
if (in_array($themeValue, ['light', 'dark', 'system'])) {
User::where('id', $userid)->update(['theme_preference' => $themeValue]);
}
}
// Handle Console permission
@@ -317,16 +320,16 @@ class ProfileController extends BasePageController
}
$validator = Validator::make($request->all(), [
'dark_mode' => ['required', 'boolean'],
'theme_preference' => ['required', 'in:light,dark,system'],
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid input'], 400);
}
$user->dark_mode = $request->input('dark_mode');
$user->theme_preference = $request->input('theme_preference');
$user->save();
return response()->json(['success' => true, 'dark_mode' => $user->dark_mode]);
return response()->json(['success' => true, 'theme_preference' => $user->theme_preference]);
}
}
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// First, add the new theme_preference column
Schema::table('users', function (Blueprint $table) {
$table->string('theme_preference', 10)->default('light')->after('dark_mode');
});
// Migrate existing dark_mode values to theme_preference
DB::table('users')->where('dark_mode', true)->update(['theme_preference' => 'dark']);
DB::table('users')->where('dark_mode', false)->update(['theme_preference' => 'light']);
// Drop the old dark_mode column
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('dark_mode');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Add back the dark_mode column
Schema::table('users', function (Blueprint $table) {
$table->boolean('dark_mode')->default(false)->after('notes');
});
// Migrate theme_preference back to dark_mode
DB::table('users')->where('theme_preference', 'dark')->update(['dark_mode' => true]);
DB::table('users')->whereIn('theme_preference', ['light', 'system'])->update(['dark_mode' => false]);
// Drop theme_preference column
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('theme_preference');
});
}
};
+99 -42
View File
@@ -19,14 +19,24 @@
(function() {
@auth
// Use user's database preference when authenticated
const userDarkMode = {{ auth()->user()->dark_mode ? 'true' : 'false' }};
if (userDarkMode) {
const userThemePreference = '{{ auth()->user()->theme_preference ?? 'light' }}';
if (userThemePreference === 'system') {
// Use OS preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} else if (userThemePreference === 'dark') {
document.documentElement.classList.add('dark');
}
@else
// Fallback to localStorage for non-authenticated users
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
if (theme === 'system') {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} else if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@endauth
@@ -91,54 +101,101 @@
</div>
<!-- Dark Mode Toggle -->
<button id="theme-toggle" class="fixed bottom-4 left-4 z-50 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 p-3 rounded-full shadow-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" title="Toggle dark mode">
<i class="fas fa-moon dark-mode-icon hidden dark:inline"></i>
<i class="fas fa-sun light-mode-icon inline dark:hidden"></i>
<button id="theme-toggle" class="fixed bottom-4 left-4 z-50 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 p-3 rounded-full shadow-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" title="Toggle theme">
<i class="fas fa-moon theme-icon-dark hidden dark:inline"></i>
<i class="fas fa-sun theme-icon-light inline dark:hidden"></i>
</button>
<!-- Scripts -->
@stack('scripts')
<script>
// Dark mode toggle
const themeToggle = document.getElementById('theme-toggle');
// Theme management with system preference support
(function() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
themeToggle?.addEventListener('click', function() {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
function applyTheme(themePreference) {
const html = document.documentElement;
if (isDark) {
html.classList.remove('dark');
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ dark_mode: false })
});
@else
localStorage.setItem('theme', 'light');
@endauth
} else {
html.classList.add('dark');
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ dark_mode: true })
});
@else
localStorage.setItem('theme', 'dark');
@endauth
if (themePreference === 'system') {
if (mediaQuery.matches) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
} else if (themePreference === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
}
});
// Listen for OS theme changes
mediaQuery.addEventListener('change', () => {
@auth
const userThemePreference = '{{ auth()->user()->theme_preference ?? 'light' }}';
if (userThemePreference === 'system') {
applyTheme('system');
}
@else
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'system') {
applyTheme('system');
}
@endauth
});
// Dark mode toggle - cycles through light -> dark -> system
const themeToggle = document.getElementById('theme-toggle');
@auth
let currentTheme = '{{ auth()->user()->theme_preference ?? 'light' }}';
@else
let currentTheme = localStorage.getItem('theme') || 'light';
@endauth
themeToggle?.addEventListener('click', function() {
let nextTheme;
// Cycle through: light -> dark -> system -> light
if (currentTheme === 'light') {
nextTheme = 'dark';
} else if (currentTheme === 'dark') {
nextTheme = 'system';
} else {
nextTheme = 'light';
}
applyTheme(nextTheme);
// Update button title
const titles = {
'light': 'Theme: Light',
'dark': 'Theme: Dark',
'system': 'Theme: System (Auto)'
};
this.setAttribute('title', titles[nextTheme]);
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ theme_preference: nextTheme })
}).then(response => response.json())
.then(data => {
if (data.success) {
currentTheme = nextTheme;
}
});
@else
localStorage.setItem('theme', nextTheme);
currentTheme = nextTheme;
@endauth
});
})();
</script>
</body>
</html>
+103 -46
View File
@@ -20,14 +20,24 @@
(function() {
@auth
// Use user's database preference when authenticated
const userDarkMode = {{ auth()->user()->dark_mode ? 'true' : 'false' }};
if (userDarkMode) {
const userThemePreference = '{{ auth()->user()->theme_preference ?? 'light' }}';
if (userThemePreference === 'system') {
// Use OS preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} else if (userThemePreference === 'dark') {
document.documentElement.classList.add('dark');
}
@else
// Fallback to localStorage for non-authenticated users
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
if (theme === 'system') {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} else if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@endauth
@@ -102,9 +112,9 @@
</button>
<!-- Dark Mode Toggle -->
<button id="theme-toggle" class="fixed bottom-4 left-4 z-50 bg-gray-200 dark:bg-gray-700 dark:bg-gray-700 text-gray-800 dark:text-gray-200 dark:text-gray-200 p-3 rounded-full shadow-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" title="Toggle dark mode">
<i class="fas fa-moon dark-mode-icon hidden dark:inline"></i>
<i class="fas fa-sun light-mode-icon inline dark:hidden"></i>
<button id="theme-toggle" class="fixed bottom-4 left-4 z-50 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 p-3 rounded-full shadow-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200" title="Toggle theme">
<i class="fas fa-moon theme-icon-dark hidden dark:inline"></i>
<i class="fas fa-sun theme-icon-light inline dark:hidden"></i>
</button>
<!-- Toast Notification Container -->
@@ -119,50 +129,97 @@
@stack('scripts')
<script>
// Dark mode toggle
const themeToggle = document.getElementById('theme-toggle');
// Theme management with system preference support
(function() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
themeToggle?.addEventListener('click', function() {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
function applyTheme(themePreference) {
const html = document.documentElement;
if (isDark) {
html.classList.remove('dark');
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ dark_mode: false })
});
@else
localStorage.setItem('theme', 'light');
@endauth
} else {
html.classList.add('dark');
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ dark_mode: true })
});
@else
localStorage.setItem('theme', 'dark');
@endauth
if (themePreference === 'system') {
if (mediaQuery.matches) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
} else if (themePreference === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
}
});
// Mobile sidebar toggle
document.getElementById('mobile-sidebar-toggle')?.addEventListener('click', function() {
document.getElementById('sidebar')?.classList.toggle('hidden');
});
// Listen for OS theme changes
mediaQuery.addEventListener('change', () => {
@auth
const userThemePreference = '{{ auth()->user()->theme_preference ?? 'light' }}';
if (userThemePreference === 'system') {
applyTheme('system');
}
@else
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'system') {
applyTheme('system');
}
@endauth
});
// Dark mode toggle - cycles through light -> dark -> system
const themeToggle = document.getElementById('theme-toggle');
@auth
let currentTheme = '{{ auth()->user()->theme_preference ?? 'light' }}';
@else
let currentTheme = localStorage.getItem('theme') || 'light';
@endauth
themeToggle?.addEventListener('click', function() {
let nextTheme;
// Cycle through: light -> dark -> system -> light
if (currentTheme === 'light') {
nextTheme = 'dark';
} else if (currentTheme === 'dark') {
nextTheme = 'system';
} else {
nextTheme = 'light';
}
applyTheme(nextTheme);
// Update button title
const titles = {
'light': 'Theme: Light',
'dark': 'Theme: Dark',
'system': 'Theme: System (Auto)'
};
this.setAttribute('title', titles[nextTheme]);
@auth
// Save to backend for authenticated users
fetch('{{ route('profile.update-theme') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ theme_preference: nextTheme })
}).then(response => response.json())
.then(data => {
if (data.success) {
currentTheme = nextTheme;
}
});
@else
localStorage.setItem('theme', nextTheme);
currentTheme = nextTheme;
@endauth
});
// Mobile sidebar toggle
document.getElementById('mobile-sidebar-toggle')?.addEventListener('click', function() {
document.getElementById('sidebar')?.classList.toggle('hidden');
});
})();
</script>
</body>
</html>
+43 -13
View File
@@ -67,8 +67,8 @@
<i class="fas fa-palette mr-2 text-blue-600 dark:text-blue-400"></i>Theme Preference
</h3>
<div class="space-y-3">
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition {{ $user->dark_mode ? 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700' : 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' }}">
<input type="radio" name="dark_mode" value="0" {{ !$user->dark_mode ? 'checked' : '' }}
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition {{ ($user->theme_preference ?? 'light') === 'light' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700' }}">
<input type="radio" name="theme_preference" value="light" {{ ($user->theme_preference ?? 'light') === 'light' ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 focus:ring-blue-500 border-gray-300">
<div class="ml-3 flex-1">
<div class="flex items-center">
@@ -80,8 +80,8 @@
</div>
</div>
</label>
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition {{ $user->dark_mode ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700' }}">
<input type="radio" name="dark_mode" value="1" {{ $user->dark_mode ? 'checked' : '' }}
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition {{ ($user->theme_preference ?? 'light') === 'dark' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700' }}">
<input type="radio" name="theme_preference" value="dark" {{ ($user->theme_preference ?? 'light') === 'dark' ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 focus:ring-blue-500 border-gray-300">
<div class="ml-3 flex-1">
<div class="flex items-center">
@@ -93,6 +93,19 @@
</div>
</div>
</label>
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition {{ ($user->theme_preference ?? 'light') === 'system' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700' }}">
<input type="radio" name="theme_preference" value="system" {{ ($user->theme_preference ?? 'light') === 'system' ? 'checked' : '' }}
class="w-4 h-4 text-blue-600 focus:ring-blue-500 border-gray-300">
<div class="ml-3 flex-1">
<div class="flex items-center">
<i class="fas fa-desktop text-blue-500 text-xl mr-3"></i>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-gray-100">System (Auto)</span>
<span class="block text-xs text-gray-600 dark:text-gray-400">Match your operating system's theme</span>
</div>
</div>
</div>
</label>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>Your theme preference will be applied across all devices and browsers when you're logged in.
@@ -318,27 +331,36 @@
<script>
// Theme preference instant preview
document.addEventListener('DOMContentLoaded', function() {
const themeRadios = document.querySelectorAll('input[name="dark_mode"]');
const themeRadios = document.querySelectorAll('input[name="theme_preference"]');
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
themeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const html = document.documentElement;
const isDarkMode = this.value === '1';
function applyTheme(themeValue) {
const html = document.documentElement;
if (isDarkMode) {
if (themeValue === 'system') {
// Use OS preference
if (mediaQuery.matches) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
} else if (themeValue === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
}
// Update the visual selection of the radio button containers
themeRadios.forEach(radio => {
radio.addEventListener('change', function() {
applyTheme(this.value);
updateThemeSelection();
});
});
function updateThemeSelection() {
const selectedValue = document.querySelector('input[name="dark_mode"]:checked').value;
const labels = document.querySelectorAll('input[name="dark_mode"]').forEach((radio, index) => {
const selectedValue = document.querySelector('input[name="theme_preference"]:checked').value;
document.querySelectorAll('input[name="theme_preference"]').forEach((radio) => {
const label = radio.closest('label');
const isSelected = radio.value === selectedValue;
@@ -351,6 +373,14 @@
}
});
}
// Listen for OS theme changes when system mode is selected
mediaQuery.addEventListener('change', (e) => {
const selectedTheme = document.querySelector('input[name="theme_preference"]:checked').value;
if (selectedTheme === 'system') {
applyTheme('system');
}
});
});
</script>
@endpush
+9 -1
View File
@@ -147,11 +147,19 @@
<i class="fa fa-palette mr-2"></i>Theme Mode
</h3>
<div class="flex items-center">
@if($user->dark_mode)
@php
$themePreference = $user->theme_preference ?? 'light';
@endphp
@if($themePreference === 'dark')
<div class="flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 rounded-lg">
<i class="fas fa-moon text-indigo-600 dark:text-indigo-400 text-lg mr-2"></i>
<span class="font-medium">Dark Mode</span>
</div>
@elseif($themePreference === 'system')
<div class="flex items-center px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-lg">
<i class="fas fa-desktop text-blue-600 dark:text-blue-400 text-lg mr-2"></i>
<span class="font-medium">System (Auto)</span>
</div>
@else
<div class="flex items-center px-4 py-2 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded-lg">
<i class="fas fa-sun text-yellow-600 dark:text-yellow-400 text-lg mr-2"></i>