Make dark mode per user

This commit is contained in:
DariusIII
2025-10-16 12:52:38 +02:00
parent 6cc2846e33
commit 439e95f478
8 changed files with 419 additions and 34 deletions
+153
View File
@@ -0,0 +1,153 @@
# Per-User Dark Mode Implementation
## Overview
The dark mode/light mode switch is now **per-user** instead of global. Each authenticated user's theme preference is stored in the database and persists across different browsers and devices.
## What Changed
### 1. Database Migration
- **File**: `database/migrations/2025_10_16_101145_add_dark_mode_to_users_table.php`
- Added `dark_mode` boolean column to the `users` table
- Default value: `false` (light mode)
- Migration has been applied successfully
### 2. User Model
- The `dark_mode` field is now part of the User model
- Users can have their own individual theme preferences
### 3. ProfileController
- **File**: `app/Http/Controllers/ProfileController.php`
- Added `updateTheme()` method to handle theme preference updates
- Accepts POST requests with `dark_mode` boolean value
- Validates input and saves to database
- Returns JSON response with success status
### 4. Routes
- **File**: `routes/web.php`
- Added new route: `POST /profile/update-theme`
- Route name: `profile.update-theme`
- Protected by `isVerified` middleware (authenticated users only)
### 5. Main Layout (User Interface)
- **File**: `resources/views/layouts/main.blade.php`
- **Initialization**: Theme is loaded from user's database preference for authenticated users
- **Toggle Button**: Sends AJAX request to backend when clicked
- **Fallback**: Non-authenticated users still use localStorage
### 6. Admin Layout
- **File**: `resources/views/layouts/admin.blade.php`
- Same behavior as main layout
- Theme preference is synchronized across both admin and user interfaces
- Informative descriptions for each theme option
**Method 2: Profile Edit Page (Permanent Setting)**
1. Navigate to Profile → Edit Profile
2. In the "Theme Preference" section, select either:
- **Light Mode**: Bright and clean interface (sun icon)
- **Dark Mode**: Easy on the eyes, especially at night (moon icon)
3. The theme changes instantly as you select (preview)
4. Click "Save Changes" to permanently save the preference
5. Theme preference applies across all devices and browsers
**Synchronized Across:**
- Different browsers
- Different devices
- Admin and user interfaces
- All sessions
1. When page loads, the user's `dark_mode` preference from database is applied immediately
2. When user clicks the theme toggle button:
- The DOM is updated instantly (visual feedback)
- An AJAX POST request is sent to `/profile/update-theme`
- The preference is saved to the database
3. Theme preference follows the user across:
- Different browsers
- Different devices
- Admin and user interfaces
### For Non-Authenticated Users (Guests)
- Theme preference is stored in browser's localStorage
- Works the same as before (browser-specific)
## Benefits
1. **Consistency**: User's theme preference is consistent across all devices
2. **Persistence**: Theme preference survives browser cache clearing
3. **User-Specific**: Each user can have their own preference
4. **Seamless**: No page reload required when toggling theme
5. **Backward Compatible**: Guest users still have dark mode functionality
## API Endpoint
### Update Theme Preference
```
POST /profile/update-theme
Content-Type: application/json
X-CSRF-TOKEN: {token}
Request Body:
{
"dark_mode": true // or false
}
Response (Success):
{
"success": true,
"dark_mode": true
}
Response (Error):
{
"success": false,
"message": "Error message"
}
### Quick Toggle Method
```
## Database Schema
### Profile Edit Page Method
1. **Login as a user**
2. **Navigate to** Profile → Edit Profile
3. **Select a theme** in the "Theme Preference" section
4. **Observe instant preview** - theme changes immediately
5. **Click "Save Changes"** to persist the setting
6. **Refresh the page** - theme should remain
7. **Login on a different device** - same theme should apply
### Verify in Database
```sql
SELECT username, dark_mode FROM users WHERE id = {your_user_id};
```
## Testing
To test the implementation:
1. **Login as a user**
2. **Toggle dark mode** using the button in bottom-left corner
3. **Refresh the page** - theme should persist
4. **Login on a different browser** - same theme should apply
5. **Check database**:
```sql
SELECT username, dark_mode FROM users WHERE id = {your_user_id};
```
## Rollback
If you need to rollback this feature:
```bash
php artisan migrate:rollback --step=1
```
This will remove the `dark_mode` column from the users table. You'll also need to revert the layout files to use only localStorage.
## Notes
- The feature is fully implemented and ready to use
- Routes have been cached successfully
- No breaking changes to existing functionality
- Guest users retain their localStorage-based theme switching
@@ -142,6 +142,11 @@ 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')]);
}
// Handle Console permission
if ($request->has('viewconsole')) {
if (! $this->userdata->hasDirectPermission('view console')) {
@@ -299,4 +304,29 @@ class ProfileController extends BasePageController
return view('errors.503')->with('warning', 'Dont try to delete another user account!');
}
/**
* Update user's dark mode preference
*/
public function updateTheme(Request $request)
{
$user = Auth::user();
if (!$user) {
return response()->json(['success' => false, 'message' => 'User not authenticated'], 401);
}
$validator = Validator::make($request->all(), [
'dark_mode' => ['required', 'boolean'],
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid input'], 400);
}
$user->dark_mode = $request->input('dark_mode');
$user->save();
return response()->json(['success' => true, 'dark_mode' => $user->dark_mode]);
}
}
@@ -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('users', function (Blueprint $table) {
$table->boolean('dark_mode')->default(false)->after('notes');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('dark_mode');
});
}
};
+39 -6
View File
@@ -17,10 +17,19 @@
<script>
// Initialize theme before page renders to prevent flash
(function() {
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@auth
// Use user's database preference when authenticated
const userDarkMode = {{ auth()->user()->dark_mode ? 'true' : 'false' }};
if (userDarkMode) {
document.documentElement.classList.add('dark');
}
@else
// Fallback to localStorage for non-authenticated users
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@endauth
})();
</script>
</head>
@@ -100,10 +109,34 @@
if (isDark) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
@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');
localStorage.setItem('theme', '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
}
});
</script>
+39 -6
View File
@@ -18,10 +18,19 @@
<script>
// Initialize theme before page renders to prevent flash
(function() {
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@auth
// Use user's database preference when authenticated
const userDarkMode = {{ auth()->user()->dark_mode ? 'true' : 'false' }};
if (userDarkMode) {
document.documentElement.classList.add('dark');
}
@else
// Fallback to localStorage for non-authenticated users
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
@endauth
})();
</script>
</head>
@@ -119,10 +128,34 @@
if (isDark) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
@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');
localStorage.setItem('theme', '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
}
});
+79
View File
@@ -61,6 +61,44 @@
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Theme Preference -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">
<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' : '' }}
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-sun text-yellow-500 text-xl mr-3"></i>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-gray-100">Light Mode</span>
<span class="block text-xs text-gray-600 dark:text-gray-400">Bright and clean interface</span>
</div>
</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' : '' }}
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-moon text-indigo-500 text-xl mr-3"></i>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-gray-100">Dark Mode</span>
<span class="block text-xs text-gray-600 dark:text-gray-400">Easy on the eyes, especially at night</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.
</p>
</div>
<!-- View Preferences -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Cover View Preferences</h3>
@@ -276,3 +314,44 @@
</div>
@endsection
@push('scripts')
<script>
// Theme preference instant preview
document.addEventListener('DOMContentLoaded', function() {
const themeRadios = document.querySelectorAll('input[name="dark_mode"]');
themeRadios.forEach(radio => {
radio.addEventListener('change', function() {
const html = document.documentElement;
const isDarkMode = this.value === '1';
if (isDarkMode) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Update the visual selection of the radio button containers
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 label = radio.closest('label');
const isSelected = radio.value === selectedValue;
if (isSelected) {
label.classList.remove('border-gray-300', 'dark:border-gray-600', 'bg-gray-50', 'dark:bg-gray-700');
label.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20');
} else {
label.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20');
label.classList.add('border-gray-300', 'dark:border-gray-600', 'bg-gray-50', 'dark:bg-gray-700');
}
});
}
});
</script>
@endpush
+49 -22
View File
@@ -140,30 +140,57 @@
<i class="fa fa-sliders-h text-blue-600 dark:text-blue-400 mr-2"></i>
<h2 class="text-lg font-semibold">UI Preferences</h2>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Theme Preference -->
<div class="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
<i class="fa fa-palette mr-2"></i>Theme Mode
</h3>
<div class="flex items-center">
<i class="fa {{ $user->movieview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Movie Covers</span>
@if($user->dark_mode)
<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>
@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>
<span class="font-medium">Light Mode</span>
</div>
@endif
</div>
<div class="flex items-center">
<i class="fa {{ $user->musicview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Music Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->consoleview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Console Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->gameview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Game Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->bookview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Book Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->xxxview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>XXX Covers</span>
</div>
<!-- Cover Preferences -->
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
<i class="fa fa-images mr-2"></i>Cover Display
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center">
<i class="fa {{ $user->movieview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Movie Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->musicview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Music Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->consoleview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Console Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->gameview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Game Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->bookview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>Book Covers</span>
</div>
<div class="flex items-center">
<i class="fa {{ $user->xxxview ? 'fa-check-square text-green-600' : 'fa-square text-gray-400' }} mr-2"></i>
<span>XXX Covers</span>
</div>
</div>
</div>
</div>
+1
View File
@@ -142,6 +142,7 @@ Route::middleware('isVerified')->group(function () {
Route::post('contact-us', [ContactUsController::class, 'contact']);
Route::match(['GET', 'POST'], 'profileedit', [ProfileController::class, 'edit'])->name('profileedit');
Route::match(['GET', 'POST'], 'profile_delete', [ProfileController::class, 'destroy'])->name('profile_delete');
Route::post('profile/update-theme', [ProfileController::class, 'updateTheme'])->name('profile.update-theme');
Route::match(['GET', 'POST'], 'search', [SearchController::class, 'search'])->name('search');
Route::match(['GET', 'POST'], 'mymovies', [MyMoviesController::class, 'show'])->name('mymovies');
Route::match(['GET', 'POST'], 'myshows', [MyShowsController::class, 'show'])->name('myshows');