Use better confirmation modals

This commit is contained in:
DariusIII
2025-10-29 15:49:59 +01:00
parent a97f9abba4
commit 1576dece02
10 changed files with 451 additions and 638 deletions
-273
View File
@@ -1,273 +0,0 @@
# Smarty 4 to Laravel Blade + TailwindCSS Migration Guide
## Overview
This document provides a comprehensive guide for migrating the NNTmux application from Smarty 4 templates to Laravel Blade with TailwindCSS.
## Changes Made
### 1. New Blade Layouts Created
- `resources/views/layouts/main.blade.php` - Main application layout
- `resources/views/layouts/admin.blade.php` - Admin panel layout
- `resources/views/layouts/app.blade.php` - Existing Laravel auth layout (retained)
### 2. New Blade Partials Created
- `resources/views/partials/header-menu.blade.php` - Top navigation menu
- `resources/views/partials/sidebar.blade.php` - Left sidebar navigation
- `resources/views/partials/footer.blade.php` - Footer content
- `resources/views/partials/admin-menu.blade.php` - Admin sidebar menu
### 3. New Blade Views Created
- `resources/views/browse/index.blade.php` - Browse releases page
- `resources/views/details/index.blade.php` - Release details page
- `resources/views/home/index.blade.php` - Home page template
### 4. Controllers Updated
The following controllers have been migrated from Smarty to Blade:
- `BasePageController.php` - Base controller (removed Smarty, added Blade support)
- `BrowseController.php` - Browse functionality
- `DetailsController.php` - Release details
## Migration Pattern
### Before (Smarty):
```php
public function index(Request $request)
{
$this->setPreferences();
// Business logic...
$this->smarty->assign('data', $data);
$this->smarty->assign('meta_title', 'Page Title');
$content = $this->smarty->fetch('template.tpl');
$this->smarty->assign(compact('content', 'meta_title'));
$this->pagerender();
}
```
### After (Blade):
```php
public function index(Request $request)
{
$this->setPreferences();
// Business logic...
$this->viewData = array_merge($this->viewData, [
'data' => $data,
'meta_title' => 'Page Title',
]);
return view('module.index', $this->viewData);
}
```
## Template Syntax Migration
### Variables
**Smarty:**
```smarty
{$variable}
{$user.name}
```
**Blade:**
```blade
{{ $variable }}
{{ $user->name }}
```
### Conditionals
**Smarty:**
```smarty
{if $condition}
Content
{elseif $other}
Other
{else}
Default
{/if}
```
**Blade:**
```blade
@if($condition)
Content
@elseif($other)
Other
@else
Default
@endif
```
### Loops
**Smarty:**
```smarty
{foreach $items as $item}
{$item.name}
{/foreach}
```
**Blade:**
```blade
@foreach($items as $item)
{{ $item->name }}
@endforeach
```
### URLs and Routes
**Smarty:**
```smarty
{{url('/browse/All')}}
{{route('series')}}
```
**Blade:**
```blade
{{ url('/browse/All') }}
{{ route('series') }}
```
### Authentication
**Smarty:**
```smarty
{if Auth::check()}
Logged in content
{/if}
```
**Blade:**
```blade
@auth
Logged in content
@endauth
@guest
Guest content
@endguest
```
### Including Partials
**Smarty:**
```smarty
{include file='sidebar.tpl'}
```
**Blade:**
```blade
@include('partials.sidebar')
```
## TailwindCSS Integration
### Styling Philosophy
- Replace Bootstrap classes with TailwindCSS utility classes
- Use responsive prefixes (sm:, md:, lg:, xl:)
- Utilize Tailwind's color palette and spacing system
### Common Class Conversions
| Bootstrap | TailwindCSS |
|-----------|-------------|
| `container` | `container mx-auto px-4` |
| `row` | `flex flex-wrap` or `grid` |
| `col-md-6` | `md:w-1/2` or `md:col-span-6` |
| `btn btn-primary` | `px-4 py-2 bg-blue-600 text-white rounded` |
| `text-center` | `text-center` |
| `mb-3` | `mb-3` (Tailwind uses same spacing scale) |
| `d-flex` | `flex` |
| `justify-content-between` | `justify-between` |
| `align-items-center` | `items-center` |
## Remaining Work
### Controllers to Update
All remaining controllers in `app/Http/Controllers/` need to be migrated:
- SearchController.php
- MovieController.php
- SeriesController.php
- ProfileController.php
- CartController.php
- Admin/* controllers
- And many more...
### Templates to Create
Convert all `.tpl` files from `resources/views/themes/` to `.blade.php`:
- Admin templates
- Search templates
- Movie/TV templates
- Profile templates
- Forum templates
- etc.
### Steps for Each Controller Migration
1. **Update the controller method:**
- Remove `$this->smarty->assign()` calls
- Replace with `$this->viewData = array_merge()`
- Change `$this->pagerender()` to `return view('view.name', $this->viewData)`
2. **Create corresponding Blade view:**
- Copy relevant HTML from `.tpl` file
- Convert Smarty syntax to Blade syntax
- Replace Bootstrap classes with TailwindCSS
- Ensure proper use of `@extends` and `@section`
3. **Test thoroughly:**
- Verify all data displays correctly
- Check responsive design
- Test user interactions
- Validate forms and AJAX calls
## Configuration Changes
### Remove Smarty Package (After Migration Complete)
Once all templates are migrated, you can remove the Smarty dependency:
```bash
composer remove smarty/smarty ytake/laravel-smarty
```
Remove from `config/app.php`:
- Smarty service provider entries
Delete:
- `config/ytake-laravel-smarty.php`
## Benefits of This Migration
1. **Better Laravel Integration** - Blade is Laravel's native templating engine
2. **Performance** - Blade compiles to plain PHP and is cached
3. **Modern UI** - TailwindCSS provides utility-first CSS framework
4. **Maintainability** - Standard Laravel conventions
5. **Developer Experience** - Better IDE support and debugging
6. **Security** - Automatic XSS protection with Blade's `{{ }}` syntax
## Testing Checklist
- [ ] Authentication works (login/logout)
- [ ] Browse pages display correctly
- [ ] Release details show all information
- [ ] Search functionality works
- [ ] Admin panel is accessible
- [ ] Forms submit properly
- [ ] AJAX requests function
- [ ] Responsive design on mobile
- [ ] All routes resolve correctly
- [ ] No JavaScript errors in console
## Notes
- The `BasePageController` now uses `$viewData` array instead of Smarty assignments
- All views should extend `layouts.main` or `layouts.admin`
- Use `@stack` and `@push` for page-specific scripts/styles
- TailwindCSS config is in `tailwind.config.js`
- Vite is configured for asset compilation
## Support
For questions or issues during migration, refer to:
- [Laravel Blade Documentation](https://laravel.com/docs/blade)
- [TailwindCSS Documentation](https://tailwindcss.com/docs)
-153
View File
@@ -1,153 +0,0 @@
# 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
@@ -108,13 +108,18 @@ class DeletedUsersController extends BasePageController
$action = $request->input('action');
$userIds = $request->input('user_ids', []);
if (! in_array($action, ['restore', 'delete'], true) || empty($userIds) || ! is_array($userIds)) {
return redirect()->route('admin.deleted.users.index')->with('error', 'Invalid bulk action request.');
// Better validation with specific error messages
if (empty($action) || ! in_array($action, ['restore', 'delete'], true)) {
return redirect()->route('admin.deleted.users.index')->with('error', 'Please select a valid action.');
}
if (! is_array($userIds)) {
return redirect()->route('admin.deleted.users.index')->with('error', 'Invalid user selection format.');
}
$userIds = array_filter(array_map('intval', $userIds));
if (empty($userIds)) {
return redirect()->route('admin.deleted.users.index')->with('error', 'No valid users selected.');
return redirect()->route('admin.deleted.users.index')->with('error', 'Please select at least one user.');
}
if ($action === 'restore') {
+1 -1
View File
@@ -256,7 +256,7 @@ class User extends Authenticatable
public static function getCount(?string $role = null, ?string $username = '', ?string $host = '', ?string $email = ''): int
{
$res = self::query()->where('email', '<>', 'sharing@nZEDb.com');
$res = self::query()->withTrashed()->where('email', '<>', 'sharing@nZEDb.com');
if (! empty($role)) {
$res->where('roles_id', $role);
+338 -167
View File
@@ -74,21 +74,49 @@ function initEventDelegation() {
}
}
// Handle confirm delete
// Handle confirm delete - using styled modal
if (e.target.hasAttribute('data-confirm-delete') || e.target.closest('[data-confirm-delete]')) {
const confirmed = confirm('Are you sure you want to delete this item?');
if (!confirmed) {
e.preventDefault();
}
e.preventDefault();
e.stopPropagation();
const element = e.target.hasAttribute('data-confirm-delete') ? e.target : e.target.closest('[data-confirm-delete]');
const form = element.closest('form');
showConfirm({
message: 'Are you sure you want to delete this item?',
type: 'danger',
confirmText: 'Delete',
onConfirm: function() {
if (form) {
form.submit();
} else if (element.href) {
window.location.href = element.href;
}
}
});
}
// Handle confirm action
// Handle confirm action - using styled modal
if (e.target.hasAttribute('data-confirm') || e.target.closest('[data-confirm]')) {
const message = e.target.getAttribute('data-confirm') || e.target.closest('[data-confirm]').getAttribute('data-confirm');
const confirmed = confirm(message);
if (!confirmed) {
e.preventDefault();
}
e.preventDefault();
e.stopPropagation();
const element = e.target.hasAttribute('data-confirm') ? e.target : e.target.closest('[data-confirm]');
const message = element.getAttribute('data-confirm');
const form = element.closest('form');
showConfirm({
message: message,
type: 'danger',
confirmText: 'Delete',
onConfirm: function() {
if (form) {
form.submit();
} else if (element.href) {
window.location.href = element.href;
}
}
});
}
// Handle download NZB buttons
@@ -207,9 +235,142 @@ function initToastNotifications() {
toast.remove();
}, 300);
}, 5000);
// Add close button listener
const closeBtn = toast.querySelector('.toast-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
toast.classList.add('removing');
setTimeout(function() {
toast.remove();
}, 300);
});
}
};
}
// Styled Confirmation Modal (replaces native confirm dialogs)
let confirmationCallback = null;
window.showConfirm = function(options) {
// Options: { message, title, details, confirmText, cancelText, type, onConfirm }
const modal = document.getElementById('confirmationModal');
if (!modal) return Promise.reject('Modal not found');
const defaults = {
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
details: '',
confirmText: 'Confirm',
cancelText: 'Cancel',
type: 'info', // 'info', 'warning', 'danger', 'success'
onConfirm: null
};
const config = { ...defaults, ...options };
// Set modal content
document.getElementById('confirmationModalTitleText').textContent = config.title;
document.getElementById('confirmationModalMessage').textContent = config.message;
document.getElementById('confirmationModalConfirmText').textContent = config.confirmText;
document.getElementById('confirmationModalCancelText').textContent = config.cancelText;
// Set icon based on type
const icon = document.getElementById('confirmationModalIcon');
const confirmBtn = document.getElementById('confirmationModalConfirmBtn');
// Remove all existing classes from icon safely (works with SVG elements)
while (icon.classList.length > 0) {
icon.classList.remove(icon.classList.item(0));
}
icon.classList.add('fas', 'mr-2');
// Remove all existing classes from button
while (confirmBtn.classList.length > 0) {
confirmBtn.classList.remove(confirmBtn.classList.item(0));
}
confirmBtn.classList.add('px-4', 'py-2', 'text-white', 'rounded-lg', 'transition', 'font-medium');
if (config.type === 'danger') {
icon.classList.add('fa-exclamation-triangle', 'text-red-600', 'dark:text-red-400');
confirmBtn.classList.add('bg-red-600', 'dark:bg-red-700', 'hover:bg-red-700', 'dark:hover:bg-red-800');
} else if (config.type === 'warning') {
icon.classList.add('fa-exclamation-circle', 'text-yellow-600', 'dark:text-yellow-400');
confirmBtn.classList.add('bg-yellow-600', 'dark:bg-yellow-700', 'hover:bg-yellow-700', 'dark:hover:bg-yellow-800');
} else if (config.type === 'success') {
icon.classList.add('fa-check-circle', 'text-green-600', 'dark:text-green-400');
confirmBtn.classList.add('bg-green-600', 'dark:bg-green-700', 'hover:bg-green-700', 'dark:hover:bg-green-800');
} else { // info
icon.classList.add('fa-info-circle', 'text-blue-600', 'dark:text-blue-400');
confirmBtn.classList.add('bg-blue-600', 'dark:bg-blue-700', 'hover:bg-blue-700', 'dark:hover:bg-blue-800');
}
// Handle details
const detailsDiv = document.getElementById('confirmationModalDetails');
const detailsText = document.getElementById('confirmationModalDetailsText');
if (config.details) {
detailsText.textContent = config.details;
detailsDiv.classList.remove('hidden');
} else {
detailsDiv.classList.add('hidden');
}
// Store callback
confirmationCallback = config.onConfirm;
// Show modal
modal.classList.remove('hidden');
modal.classList.add('flex');
// Return promise for async/await usage
return new Promise((resolve, reject) => {
confirmationCallback = function(confirmed) {
if (confirmed && config.onConfirm) {
config.onConfirm();
}
resolve(confirmed);
};
});
};
window.closeConfirmationModal = function() {
const modal = document.getElementById('confirmationModal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (confirmationCallback) {
confirmationCallback(false);
confirmationCallback = null;
}
};
window.confirmConfirmationModal = function() {
const modal = document.getElementById('confirmationModal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (confirmationCallback) {
confirmationCallback(true);
confirmationCallback = null;
}
};
// Enhanced confirm function (backward compatible but with styled modal)
window.confirmStyled = function(message, title = 'Confirm', type = 'info') {
return new Promise((resolve) => {
showConfirm({
message: message,
title: title,
type: type,
onConfirm: () => resolve(true)
}).then(result => {
if (!result) resolve(false);
});
});
};
// NFO Modal
function initNfoModal() {
window.openNfoModal = function(guid) {
@@ -989,63 +1150,6 @@ function initMediainfoAndFilelist() {
// Cart and Multi-select functionality
function initCartFunctionality() {
// Global showConfirmation function for cart page
window.showConfirmation = function(message, onConfirm) {
const modal = document.createElement('div');
modal.className = 'confirmation-modal';
modal.innerHTML = `
<div class="confirmation-modal-content">
<div class="confirmation-modal-header">
<div class="confirmation-modal-icon">
<i class="fa fa-exclamation-triangle"></i>
</div>
<h3 class="confirmation-modal-title">Confirm Deletion</h3>
</div>
<div class="confirmation-modal-body">
${message}
</div>
<div class="confirmation-modal-footer">
<button class="btn-cancel">Cancel</button>
<button class="btn-confirm">Delete</button>
</div>
</div>
`;
document.body.appendChild(modal);
const cancelBtn = modal.querySelector('.btn-cancel');
const confirmBtn = modal.querySelector('.btn-confirm');
function closeModal() {
modal.style.animation = 'fadeOut 0.2s ease-out';
setTimeout(() => modal.remove(), 200);
}
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
confirmBtn.addEventListener('click', function() {
closeModal();
onConfirm();
});
// Focus on cancel button by default
setTimeout(() => cancelBtn.focus(), 100);
// ESC key to close
const escHandler = function(e) {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
};
// Cart page specific functionality
const checkAll = document.getElementById('check-all');
const checkboxes = document.querySelectorAll('.cart-checkbox');
@@ -1121,9 +1225,12 @@ function initCartFunctionality() {
return;
}
showConfirmation(
`Are you sure you want to delete <strong>${selected.length}</strong> item(s) from your cart?`,
function() {
showConfirm({
title: 'Delete from Cart',
message: `Are you sure you want to delete ${selected.length} item${selected.length > 1 ? 's' : ''} from your cart?`,
type: 'danger',
confirmText: 'Delete',
onConfirm: function() {
// Delete via AJAX
fetch('/cart/delete/' + selected.join(','), {
method: 'POST',
@@ -1155,7 +1262,7 @@ function initCartFunctionality() {
}
});
}
);
});
});
});
@@ -1168,9 +1275,12 @@ function initCartFunctionality() {
const releaseName = this.getAttribute('data-release-name');
const deleteUrl = this.getAttribute('data-delete-url');
showConfirmation(
`Are you sure you want to remove <strong>${releaseName}</strong> from your cart?`,
function() {
showConfirm({
title: 'Remove from Cart',
message: `Are you sure you want to remove "${releaseName}" from your cart?`,
type: 'warning',
confirmText: 'Remove',
onConfirm: function() {
if (typeof showToast === 'function') {
showToast('Removing item from cart...', 'info');
}
@@ -1179,7 +1289,7 @@ function initCartFunctionality() {
window.location.href = deleteUrl;
}, 500);
}
);
});
});
});
}
@@ -1288,10 +1398,15 @@ function initCartFunctionality() {
return;
}
const confirmMessage = `Are you sure you want to delete <strong>${selected.length}</strong> release${selected.length > 1 ? 's' : ''}? This action cannot be undone.`;
const confirmMessage = `Are you sure you want to delete ${selected.length} release${selected.length > 1 ? 's' : ''}? This action cannot be undone.`;
// Use the custom confirmation modal
showConfirmation(confirmMessage, function() {
// Use styled confirmation modal
showConfirm({
title: 'Delete Releases',
message: confirmMessage,
type: 'danger',
confirmText: 'Delete',
onConfirm: function() {
console.log('User confirmed deletion');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
@@ -1385,7 +1500,8 @@ function initCartFunctionality() {
setTimeout(() => window.location.reload(), 1500);
}
});
});
}
});
});
} else {
console.log('Delete button not found on page');
@@ -3691,97 +3807,110 @@ function ajax_backfill_status(id, status) {
}
function ajax_group_reset(id) {
if (!confirm('Are you sure you want to reset this group?')) {
return;
}
showConfirm({
title: 'Reset Group',
message: 'Are you sure you want to reset this group?',
type: 'warning',
confirmText: 'Reset',
onConfirm: function() {
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
if (!ajaxUrl || !csrfToken) return;
if (!ajaxUrl || !csrfToken) return;
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'reset_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'reset_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
})
.catch(error => console.error('Error resetting group:', error));
}
})
.catch(error => console.error('Error resetting group:', error));
});
}
function confirmGroupDelete(id) {
if (!confirm('Are you sure you want to delete this group?')) {
return;
}
showConfirm({
title: 'Delete Group',
message: 'Are you sure you want to delete this group?',
type: 'danger',
confirmText: 'Delete',
onConfirm: function() {
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
if (!ajaxUrl || !csrfToken) return;
if (!ajaxUrl || !csrfToken) return;
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'delete_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const row = document.getElementById('grouprow-' + id);
if (row) {
row.classList.add('fade-out');
setTimeout(() => row.remove(), 300);
}
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'delete_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
const row = document.getElementById('grouprow-' + id);
if (row) {
row.classList.add('fade-out');
setTimeout(() => row.remove(), 300);
}
}
})
.catch(error => console.error('Error deleting group:', error));
}
})
.catch(error => console.error('Error deleting group:', error));
});
}
function confirmGroupPurge(id) {
if (!confirm('Are you sure you want to purge this group? This will delete all releases and binaries!')) {
return;
}
showConfirm({
title: 'Purge Group',
message: 'Are you sure you want to purge this group?',
details: 'This will delete all releases and binaries!',
type: 'danger',
confirmText: 'Purge',
onConfirm: function() {
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
const ajaxUrl = document.querySelector('[data-ajax-url]')?.dataset.ajaxUrl;
const csrfToken = document.querySelector('[data-csrf-token]')?.dataset.csrfToken;
if (!ajaxUrl || !csrfToken) return;
if (!ajaxUrl || !csrfToken) return;
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'purge_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
action: 'purge_group',
group_id: id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
}
})
.catch(error => console.error('Error purging group:', error));
}
})
.catch(error => console.error('Error purging group:', error));
});
}
function ajax_group_reset_all() {
@@ -3886,6 +4015,7 @@ window.showVerifyModal = function(event, form) {
const modal = document.getElementById('verifyUserModal');
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
}
};
@@ -3893,6 +4023,7 @@ window.hideVerifyModal = function() {
const modal = document.getElementById('verifyUserModal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
window.currentVerifyForm = null;
};
@@ -4139,7 +4270,9 @@ function initAdminDeletedUsers() {
}
// Bulk action confirmation
window.confirmBulkAction = function() {
window.confirmBulkAction = function(event) {
event?.preventDefault();
const action = document.getElementById('bulkAction')?.value;
const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
const validationError = document.getElementById('validationError');
@@ -4168,21 +4301,59 @@ function initAdminDeletedUsers() {
const count = checkedBoxes.length;
const actionText = action === 'restore' ? 'restore' : 'permanently delete';
const type = action === 'restore' ? 'success' : 'danger';
const title = action === 'restore' ? 'Restore Users' : 'Delete Users';
return confirm(`Are you sure you want to ${actionText} ${count} user${count > 1 ? 's' : ''}?`);
};
// Individual action confirmations
document.querySelectorAll('.inline-form button[data-confirm]').forEach(button => {
button.addEventListener('click', function(e) {
const message = this.getAttribute('data-confirm');
if (!confirm(message)) {
e.preventDefault();
showConfirm({
title: title,
message: `Are you sure you want to ${actionText} ${count} user${count > 1 ? 's' : ''}?`,
type: type,
confirmText: action === 'restore' ? 'Restore' : 'Delete',
onConfirm: function() {
document.getElementById('bulkActionForm')?.submit();
}
});
});
return false;
};
}
// Individual user restore/delete actions for deleted users page
window.restoreUser = function(userId, username) {
showConfirm({
title: 'Restore User',
message: `Are you sure you want to restore user '${username}'?`,
type: 'success',
confirmText: 'Restore',
onConfirm: function() {
const form = document.getElementById('individualActionForm');
if (form) {
const baseUrl = window.location.origin;
form.action = `${baseUrl}/admin/deleted-users/restore/${userId}`;
form.submit();
}
}
});
};
window.permanentDeleteUser = function(userId, username) {
showConfirm({
title: 'Permanently Delete User',
message: `Are you sure you want to PERMANENTLY delete user '${username}'?`,
details: 'This action cannot be undone!',
type: 'danger',
confirmText: 'Delete Permanently',
onConfirm: function() {
const form = document.getElementById('individualActionForm');
if (form) {
const baseUrl = window.location.origin;
form.action = `${baseUrl}/admin/deleted-users/permanent-delete/${userId}`;
form.submit();
}
}
});
};
// My Movies page functionality
function initMyMovies() {
// Confirm before removing movies
+17 -20
View File
@@ -128,7 +128,7 @@
</select>
<button type="submit"
class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-md hover:bg-blue-700"
onclick="return confirmBulkAction()">
onclick="confirmBulkAction(event)">
<i class="fa fa-check mr-2"></i>Apply
</button>
</div>
@@ -202,24 +202,18 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex gap-2">
<form action="{{ url('admin/deleted-users/restore/' . $user->id) }}" method="POST" class="inline-form">
@csrf
<button type="submit"
class="text-green-600 hover:text-green-900 bg-transparent border-0 p-0 cursor-pointer"
title="Restore User"
data-confirm="Are you sure you want to restore user '{{ $user->username }}'?">
<i class="fa fa-undo"></i>
</button>
</form>
<form action="{{ url('admin/deleted-users/permanent-delete/' . $user->id) }}" method="POST" class="inline-form">
@csrf
<button type="submit"
class="text-red-600 hover:text-red-900 bg-transparent border-0 p-0 cursor-pointer"
title="Permanently Delete"
data-confirm="Are you sure you want to PERMANENTLY delete user '{{ $user->username }}'? This action cannot be undone!">
<i class="fa fa-trash-alt"></i>
</button>
</form>
<button type="button"
class="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 bg-transparent border-0 p-0 cursor-pointer"
title="Restore User"
onclick="restoreUser({{ $user->id }}, '{{ addslashes($user->username) }}')">
<i class="fa fa-undo"></i>
</button>
<button type="button"
class="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 bg-transparent border-0 p-0 cursor-pointer"
title="Permanently Delete"
onclick="permanentDeleteUser({{ $user->id }}, '{{ addslashes($user->username) }}')">
<i class="fa fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
@@ -243,6 +237,9 @@
</div>
</div>
{{-- Scripts moved to resources/js/csp-safe.js --}}
<!-- Hidden form for individual actions -->
<form id="individualActionForm" method="POST" style="display: none;">
@csrf
</form>
@endsection
+43 -21
View File
@@ -106,12 +106,12 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role Expiry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Host</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Country</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Verified</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Bad User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
@@ -124,6 +124,21 @@
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $user->username }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ $user->email }}</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($user->deleted_at)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200" title="Soft Deleted on {{ \Carbon\Carbon::parse($user->deleted_at)->format('M j, Y g:i A') }}">
<i class="fa fa-trash mr-1"></i>Deleted
</span>
@elseif(isset($user->apiaccess) && $user->apiaccess == 0)
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" title="API Access Disabled">
<i class="fa fa-ban mr-1"></i>Disabled
</span>
@else
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fa fa-check-circle mr-1"></i>Active
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
{{ $user->roles->first()->name ?? 'N/A' }}
@@ -235,29 +250,36 @@
</div>
<!-- Verify User Confirmation Modal -->
<div id="verifyUserModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="mt-3 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 dark:bg-green-900">
<i class="fa fa-check-circle text-green-600 dark:text-green-400 text-2xl"></i>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100 mt-4">Verify User</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to manually verify this user? This will mark their email as verified.
</p>
</div>
<div class="flex gap-3 px-4 py-3">
<button onclick="hideVerifyModal()"
class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-base font-medium rounded-md shadow-sm hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300">
Cancel
</button>
<button onclick="submitVerifyForm()"
class="flex-1 px-4 py-2 bg-green-600 dark:bg-green-700 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-700 dark:hover:bg-green-800 focus:outline-none focus:ring-2 focus:ring-green-500">
Verify
<div id="verifyUserModal" class="fixed inset-0 bg-gray-900 dark:bg-black bg-opacity-50 dark:bg-opacity-70 hidden items-center justify-center z-50 transition-opacity">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 transform transition-all">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center">
<i class="fa fa-check-circle text-green-600 dark:text-green-400 mr-2"></i>
Verify User
</h3>
<button type="button" onclick="hideVerifyModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fas fa-times text-xl"></i>
</button>
</div>
</div>
<div class="px-6 py-4">
<p class="text-gray-700 dark:text-gray-300">
Are you sure you want to manually verify this user? This will mark their email as verified.
</p>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button type="button"
onclick="hideVerifyModal()"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition font-medium">
<i class="fas fa-times mr-2"></i>Cancel
</button>
<button type="button"
onclick="submitVerifyForm()"
class="px-4 py-2 bg-green-600 dark:bg-green-700 text-white rounded-lg hover:bg-green-700 dark:hover:bg-green-800 transition font-medium">
<i class="fa fa-check mr-2"></i>Verify
</button>
</div>
</div>
</div>
+3
View File
@@ -84,6 +84,9 @@
<!-- Toast notifications will be dynamically inserted here -->
</div>
<!-- Confirmation Modal -->
@include('partials.confirmation-modal')
<!-- Scripts -->
<!-- Toast Notifications (must load before other scripts) -->
@include('partials.toast-notifications')
+3
View File
@@ -144,6 +144,9 @@
<!-- Toast notifications will be dynamically inserted here -->
</div>
<!-- Confirmation Modal -->
@include('partials.confirmation-modal')
<!-- Scripts -->
<!-- Toast Notifications (must load before other scripts) -->
@include('partials.toast-notifications')
@@ -0,0 +1,38 @@
<!-- Reusable Confirmation Modal -->
<div id="confirmationModal" class="fixed inset-0 bg-gray-900 dark:bg-black bg-opacity-50 dark:bg-opacity-70 hidden items-center justify-center z-50 transition-opacity">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 transform transition-all">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center" id="confirmationModalTitle">
<i class="fas fa-exclamation-circle text-blue-600 dark:text-blue-400 mr-2" id="confirmationModalIcon"></i>
<span id="confirmationModalTitleText">Confirm Action</span>
</h3>
<button type="button" onclick="closeConfirmationModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fas fa-times text-xl"></i>
</button>
</div>
</div>
<div class="px-6 py-4">
<p class="text-gray-700 dark:text-gray-300" id="confirmationModalMessage">
Are you sure you want to proceed?
</p>
<div id="confirmationModalDetails" class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mt-3 hidden">
<p class="text-sm text-gray-600 dark:text-gray-400" id="confirmationModalDetailsText"></p>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button type="button"
onclick="closeConfirmationModal()"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition font-medium">
<i class="fas fa-times mr-2"></i><span id="confirmationModalCancelText">Cancel</span>
</button>
<button type="button"
onclick="confirmConfirmationModal()"
id="confirmationModalConfirmBtn"
class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 transition font-medium">
<i class="fas fa-check mr-2"></i><span id="confirmationModalConfirmText">Confirm</span>
</button>
</div>
</div>
</div>