mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Make dark mode per user
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user