feat: add resources and view components

This commit is contained in:
2026-05-21 16:05:19 +07:00
parent 28a06315b8
commit b2d60e680d
249 changed files with 37379 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,851 @@
<x-app-layout>
@push('styles')
<style>
.status-widget-dark {
border-radius: 24px;
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
padding: 1.5rem;
color: white;
margin-bottom: 1.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
}
.btn-premium-action {
border-radius: 100px;
padding: 10px 24px;
font-weight: 700;
transition: all 0.3s ease;
}
.table-premium thead th {
background: #f8fafc;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
color: #64748b;
padding: 1.25rem 1rem;
border-bottom: 1px solid #f1f5f9;
}
.table-premium tbody td {
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
}
.custom-switch-premium .form-check-input {
width: 3rem;
height: 1.5rem;
cursor: pointer;
}
@keyframes pulse-danger {
0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); }
}
.pulse-danger {
animation: pulse-danger 2s infinite;
}
.animate-spin {
animation: spin 1s infinite linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
@endpush
<div class="container-fluid pb-5">
{{-- Premium Page Header --}}
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
<div>
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
{{ __('Backup & Storage') }}
</h4>
<p class="text-muted small mb-0">
{{ __('Securely manage your system archives and cloud synchronization.') }}
</p>
</div>
<div class="d-flex gap-2">
<button type="button"
class="btn btn-outline-dark btn-premium-action shadow-sm d-flex align-items-center gap-2"
data-bs-toggle="modal" data-bs-target="#backupDocsModal">
<i class="bi bi-book"></i> {{ __('Documentation') }}
</button>
@can('manage backup and storage')
<button type="button"
class="btn btn-primary btn-premium-action shadow-sm d-flex align-items-center gap-2"
id="btn-create-backup">
<i class="bi bi-plus-lg"></i> {{ __('Instant Backup') }}
</button>
@endcan
</div>
</div>
<div class="row gx-4">
{{-- Left Side: Control Center --}}
<div class="col-lg-4 animate__animated animate__fadeIn">
{{-- Storage Health Widget (Dark Premium) --}}
<div id="storage-health-widget" class="status-widget-dark" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<div class="bg-white bg-opacity-10 rounded-circle p-2">
<i class="bi bi-cloud-check text-info"></i>
</div>
<span class="small fw-bold opacity-75 text-uppercase tracking-wider"
id="health-label">Storage</span>
</div>
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2" id="health-status"
style="font-size: 10px;">Checking...</span>
</div>
<h2 class="mb-1 fw-bold" id="health-used" style="font-family: 'Outfit', sans-serif;">0</h2>
<p class="small opacity-50 mb-4" id="health-total-label">of <span id="health-total">0</span> used
</p>
<div class="progress bg-white bg-opacity-10 mb-2" style="height: 8px; border-radius: 10px;"
id="health-progress-wrapper">
<div class="progress-bar bg-info" id="health-progress-bar" role="progressbar"
style="width: 0%; border-radius: 10px;"></div>
</div>
{{-- Requirements Alert --}}
<div id="requirements-alert"
class="mt-3 p-2 rounded-3 bg-danger bg-opacity-25 border border-danger border-opacity-25 small"
style="display:none;">
<i class="bi bi-exclamation-triangle me-1"></i> <span id="req-msg">Check reqs</span>
</div>
</div>
<form id="backupConfigForm" action="{{ route('system-config.update') }}" method="POST"
autocomplete="off" class="ajax-form" data-reset="false">
@csrf
@method('PUT')
{{-- Automation Card --}}
<div class="card adminuiux-card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
<i class="bi bi-clock-history text-primary"></i>
{{ __('Automation Settings') }}
</h6>
<div
class="form-check form-switch custom-switch-premium mb-4 p-0 d-flex align-items-center justify-content-between">
<label class="form-check-label fw-semibold text-dark"
for="backup_db_enabled">{{ __('AUTO BACKUP') }}</label>
<input class="form-check-input ms-0" type="checkbox" role="switch"
id="backup_db_enabled" name="backup_db_enabled" value="1"
@checked(old('backup_db_enabled', $settings['backup_db_enabled'] ?? true))>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Backup Frequency') }}</label>
<select class="form-select" name="backup_db_frequency">
<option value="hourly" @selected(($settings['backup_db_frequency'] ?? '') == 'hourly')>{{ __('Hourly') }}</option>
<option value="daily" @selected(($settings['backup_db_frequency'] ?? 'daily') == 'daily')>{{ __('Daily') }}</option>
<option value="weekly" @selected(($settings['backup_db_frequency'] ?? '') == 'weekly')>{{ __('Weekly') }}</option>
<option value="monthly" @selected(($settings['backup_db_frequency'] ?? '') == 'monthly')>{{ __('Monthly') }}</option>
</select>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label fw-semibold">{{ __('Execution Time') }}</label>
<input type="time" class="form-control" name="backup_db_time"
value="{{ $settings['backup_db_time'] ?? '02:00' }}">
</div>
<div class="col-6">
<label class="form-label fw-semibold">{{ __('Retention (Days)') }}</label>
<input type="number" class="form-control text-center" name="backup_db_retention"
value="{{ $settings['backup_db_retention'] ?? 7 }}">
</div>
</div>
</div>
</div>
{{-- Storage Destination Card --}}
<div class="card adminuiux-card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
<i class="bi bi-hdd-network text-primary"></i>
{{ __('Storage Target') }}
</h6>
<div class="mb-4">
<label class="form-label fw-semibold mb-2">{{ __('Primary Driver') }}</label>
<select class="form-select" name="backup_db_driver" id="backup_db_driver">
<option value="local" @selected(($settings['backup_db_driver'] ?? 'local') == 'local')>Local Filesystem</option>
<option value="gdrive" @selected(($settings['backup_db_driver'] ?? '') == 'gdrive')>
Google Drive</option>
</select>
</div>
{{-- Cloud Fields Wrapper --}}
<div id="cloud_config_wrapper"
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
<div class="p-3 rounded-4 bg-light border border-opacity-10 mb-4">
{{-- Google Drive --}}
<div id="gdrive_fields"
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-google text-primary"></i>
<h6 class="fw-semibold mb-0 small">{{ __('Google Cloud Auth') }}</h6>
</div>
<div class="mb-2">
<input type="text"
class="form-control form-control-sm rounded-pill px-3 bg-white"
name="gdrive_client_id"
value="{{ $settings['gdrive_client_id'] ?? '' }}"
placeholder="Client ID">
</div>
<div class="input-group input-group-sm mb-2">
<input type="password"
class="form-control border-end-0 rounded-start-pill px-3 bg-white"
name="gdrive_client_secret"
value="{{ $settings['gdrive_client_secret'] ?? '' }}"
placeholder="Client Secret">
<button
class="btn btn-outline-secondary bg-white border-start-0 password-toggle rounded-end-pill pe-3"
type="button" style="border-color: #dee2e6;">
<i class="bi bi-eye text-secondary"></i>
</button>
</div>
<div class="input-group input-group-sm mb-2">
<input type="password"
class="form-control border-0 rounded-start-pill px-3 bg-white"
name="gdrive_refresh_token"
value="{{ $settings['gdrive_refresh_token'] ?? '' }}"
placeholder="Refresh Token">
<button class="btn btn-outline-secondary bg-white border-0 password-toggle"
type="button">
<i class="bi bi-eye text-secondary"></i>
</button>
<a href="{{ route('backup-restore.google-auth') }}"
class="btn btn-dark rounded-end-pill px-3" id="btn-gdrive-auth">
<i class="bi bi-key"></i>
</a>
</div>
<div class="input-group input-group-sm">
<input type="text"
class="form-control border-0 rounded-start-pill px-3 bg-white"
name="gdrive_folder"
value="{{ $settings['gdrive_folder'] ?? 'LaravelBackups' }}"
placeholder="Folder Name">
<button type="button"
class="btn btn-primary rounded-end-pill px-3 btn-test-connection"
data-driver="gdrive">
<i class="bi bi-broadcast"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@can('manage backup and storage')
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
{{ __('Save Configuration') }}
</button>
</div>
@endcan
</form>
</div>
{{-- Right Side: History (Col-8) --}}
<div class="col-lg-8 animate__animated animate__fadeIn">
<div class="card adminuiux-card h-100">
<div class="card-body p-0">
<div class="p-4 d-flex justify-content-between align-items-center border-bottom bg-white sticky-top"
style="z-index: 10; border-top-left-radius: 20px; border-top-right-radius: 20px;">
<h6 class="fw-bold mb-0 d-flex align-items-center gap-2">
<i class="bi bi-collection-play text-primary"></i>
{{ __('Archive Inventory') }}
</h6>
<button type="button" class="btn btn-sm btn-light rounded-pill px-3"
id="btn-refresh-backups">
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh List') }}
</button>
</div>
<div class="table-responsive">
<table class="table table-premium align-middle mb-0" id="backup-list-table">
<thead>
<tr>
<th class="ps-4">{{ __('Archive Name') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Timestamp') }}</th>
<th class="text-end pe-4">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
<tr id="backup-loading">
<td colspan="4" class="text-center py-5">
<div class="spinner-grow spinner-grow-sm text-primary me-2"></div>
<span
class="text-muted small fw-bold">{{ __('Scanning Archives...') }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{{-- ====================================================================
DOCUMENTATION (MODAL)
==================================================================== --}}
<div class="modal fade" id="backupDocsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
<div class="modal-header p-0 border-0" style="background:#0f172a;">
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
<div>
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Backup & Restore Documentation') }}</h3>
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
Everything you need to know about creating, scheduling, restoring, and securing your system archives.
<span class="text-warning fw-bold">Read this first</span> restoring overwrites your live data.
</p>
</div>
<div class="d-flex align-items-start gap-3">
<i class="bi bi-shield-fill-check display-3 opacity-25 d-none d-md-inline"></i>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
</div>
</div>
<div class="modal-body p-0">
<div class="p-4 p-lg-5">
{{-- DANGER NOTICE --}}
<div class="d-flex align-items-start gap-3 p-3 rounded-4 mb-5" style="background:#fef2f2;border:2px solid #fecaca;">
<i class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
<div>
<div class="fw-black text-danger mb-1">CRITICAL Restore is destructive</div>
<div class="small text-dark" style="line-height:1.7;">
Restoring a backup <span class="fw-bold">overwrites every table</span> in your current database with the data from the archive.
All records created since that backup was taken will be <span class="fw-bold text-danger">permanently lost</span>.
Always create a fresh <span class="fw-bold">Instant Backup</span> immediately before restoring, so you have a return path.
</div>
</div>
</div>
{{-- WORKFLOW STEPS --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-diagram-3 text-primary me-1"></i>How a backup is created
</h6>
<div class="row g-3 mb-5">
@php
$steps = [
['icon'=>'bi-database', 'color'=>'#3b82f6', 'title'=>'1. Snapshot', 'desc'=>'A full SQL dump of every table is generated via <code>mysqldump</code> / <code>pg_dump</code> at the moment you click Instant Backup or when the scheduler fires.'],
['icon'=>'bi-file-zip', 'color'=>'#8b5cf6', 'title'=>'2. Compress', 'desc'=>'The SQL dump is gzip-compressed into a single <code>.sql.gz</code> archive named with timestamp + driver tag.'],
['icon'=>'bi-hdd-network', 'color'=>'#22c55e', 'title'=>'3. Transfer', 'desc'=>'Archive is written to the configured driver. <span class="fw-bold">Local</span> saves to <code>storage/app/backups/</code>. <span class="fw-bold">Google Drive</span> uploads via OAuth refresh token.'],
['icon'=>'bi-clipboard-check','color'=>'#f59e0b', 'title'=>'4. Verify', 'desc'=>'File size and SHA-256 checksum are recorded. The Archive Inventory table reflects the new row immediately.'],
['icon'=>'bi-trash3', 'color'=>'#ef4444', 'title'=>'5. Prune', 'desc'=>'Archives older than the configured retention (days) are auto-deleted on the next scheduled run. Local + cloud are pruned independently.'],
];
@endphp
@foreach($steps as $s)
<div class="col-md-6 col-lg-4">
<div class="p-3 rounded-4 h-100" style="background:#f8fafc;border:1px solid #e2e8f0;">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="background:{{ $s['color'] }}1a;color:{{ $s['color'] }};width:36px;height:36px;">
<i class="{{ $s['icon'] }}"></i>
</div>
<div class="fw-bold text-dark">{{ $s['title'] }}</div>
</div>
<div class="small text-muted" style="line-height:1.6;">{!! $s['desc'] !!}</div>
</div>
</div>
@endforeach
</div>
{{-- STORAGE DRIVERS --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-hdd-stack text-info me-1"></i>Storage drivers comparison
</h6>
<div class="table-responsive mb-5">
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
<thead style="background:#e2e8f0;">
<tr>
<th class="ps-3">DRIVER</th>
<th>WHERE</th>
<th>SETUP</th>
<th>BEST FOR</th>
<th>RISK IF SERVER DIES</th>
</tr>
</thead>
<tbody>
<tr>
<td class="ps-3 fw-bold text-dark">
<i class="bi bi-hdd text-secondary me-1"></i>Local Filesystem
</td>
<td class="small text-muted"><code>storage/app/backups/</code></td>
<td class="small"><span class="badge bg-success-subtle text-success rounded-pill">Zero config</span></td>
<td class="small text-muted">Quick recovery, small datasets, dev environments</td>
<td class="small text-danger fw-bold">Total loss backups on same disk</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">
<i class="bi bi-google text-primary me-1"></i>Google Drive
</td>
<td class="small text-muted">OAuth folder (default: <code>LaravelBackups</code>)</td>
<td class="small"><span class="badge bg-warning-subtle text-warning rounded-pill">Client ID + Secret + Refresh Token</span></td>
<td class="small text-muted">Off-site redundancy, production, disaster recovery</td>
<td class="small text-success fw-bold">Safe off-server copy</td>
</tr>
</tbody>
</table>
</div>
{{-- GOOGLE DRIVE SETUP --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-google text-primary me-1"></i>Google Drive setup · Step-by-step
</h6>
<div class="row g-3 mb-5">
<div class="col-md-6">
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
<div class="fw-bold text-primary mb-2">1. Create OAuth client</div>
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
<li>Go to <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a></li>
<li>Create a project enable <span class="fw-bold">Google Drive API</span></li>
<li>OAuth consent screen External fill app name</li>
<li>Credentials Create OAuth client ID Web application</li>
<li>Add redirect URI: <code>{{ url('/backup-restore/google-callback') }}</code></li>
<li>Copy <span class="fw-bold">Client ID</span> &amp; <span class="fw-bold">Client Secret</span></li>
</ol>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
<div class="fw-bold text-primary mb-2">2. Link this app</div>
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
<li>Paste Client ID + Secret in the form on the left</li>
<li>Click the <i class="bi bi-key"></i> key button next to Refresh Token</li>
<li>Authorize on the Google consent screen</li>
<li>Refresh token is auto-filled on return</li>
<li>Click <i class="bi bi-broadcast"></i> to test connection</li>
<li>Save configuration</li>
</ol>
</div>
</div>
</div>
{{-- SCHEDULING --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-clock-history text-warning me-1"></i>Automation &amp; scheduling
</h6>
<div class="row g-3 mb-5">
<div class="col-md-3">
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
<div class="fw-bold text-dark mb-1">Frequency</div>
<div class="small text-muted">Hourly / Daily / Weekly / Monthly. The Laravel scheduler must be running (<code>cron</code> calling <code>php artisan schedule:run</code> every minute).</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
<div class="fw-bold text-dark mb-1">Execution Time</div>
<div class="small text-muted">24-hour format (default <code>02:00</code>). Pick low-traffic hours backup briefly locks tables.</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
<div class="fw-bold text-dark mb-1">Retention</div>
<div class="small text-muted">Days to keep. Older archives auto-pruned. Default <code>7</code>. Set to <code>0</code> to keep forever (not recommended).</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
<div class="fw-bold text-dark mb-1">Auto Backup toggle</div>
<div class="small text-muted">Master switch. When OFF, only manual <span class="fw-bold">Instant Backup</span> runs.</div>
</div>
</div>
</div>
{{-- RESTORE PROCEDURE --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-arrow-counterclockwise text-danger me-1"></i>How to restore safely
</h6>
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
<span style="color:#22c55e;">RECOMMENDED RESTORE SEQUENCE:</span>
<span style="color:#fbbf24;">[1]</span> Click <span style="color:#22c55e;">Instant Backup</span> NOW creates a return path
<span style="color:#fbbf24;">[2]</span> Enable Maintenance Mode (so users can't write data mid-restore)
<span style="color:#fbbf24;">[3]</span> Open Archive Inventory → identify the archive you want
<span style="color:#fbbf24;">[4]</span> Click the row action menu → <span style="color:#ef4444;">Restore</span>
<span style="color:#fbbf24;">[5]</span> Confirm the dialog (read it carefully — points of no return)
<span style="color:#fbbf24;">[6]</span> Wait for the success toast (do NOT navigate away)
<span style="color:#fbbf24;">[7]</span> Verify data integrity on a couple of critical tables
<span style="color:#fbbf24;">[8]</span> Disable Maintenance Mode
<span style="color:#94a3b8;"># If something looks wrong after restore:
# → Use the safety backup from step [1] to roll back</span></pre>
</div>
{{-- TROUBLESHOOTING --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>Troubleshooting
</h6>
<div class="table-responsive mb-5">
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
<thead style="background:#e2e8f0;">
<tr>
<th class="ps-3">SYMPTOM</th>
<th>LIKELY CAUSE</th>
<th>FIX</th>
</tr>
</thead>
<tbody>
<tr>
<td class="ps-3 fw-bold text-dark">Backup button does nothing</td>
<td class="small text-muted"><code>storage/app/backups/</code> not writable</td>
<td class="small text-muted"><code>chmod -R 775 storage/</code></td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">"Requirements not met" alert</td>
<td class="small text-muted"><code>mysqldump</code> / <code>pg_dump</code> not in PATH</td>
<td class="small text-muted">Install DB client tools on the server</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">Scheduled backup never runs</td>
<td class="small text-muted">Laravel scheduler cron not registered</td>
<td class="small text-muted">Add <code>* * * * * php artisan schedule:run</code> to crontab</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">Google Drive upload fails</td>
<td class="small text-muted">Refresh token expired or revoked</td>
<td class="small text-muted">Re-authorize via the <i class="bi bi-key"></i> key button</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">Disk fills up rapidly</td>
<td class="small text-muted">Retention too high or hourly schedule on large DB</td>
<td class="small text-muted">Lower retention, switch to daily, prune old archives manually</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">Restore hangs / times out</td>
<td class="small text-muted">Large archive, low PHP <code>max_execution_time</code></td>
<td class="small text-muted">Run restore via CLI: <code>php artisan backup:restore {file}</code></td>
</tr>
</tbody>
</table>
</div>
{{-- BEST PRACTICES --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-stars text-success me-1"></i>Best practices
</h6>
<div class="row g-2 mb-5">
@php
$tips = [
['icon'=>'bi-cloud-arrow-up', 'title'=>'Use 3-2-1 rule', 'desc'=>'<span class="fw-bold">3</span> copies of data, on <span class="fw-bold">2</span> different storage types, with <span class="fw-bold">1</span> off-site (Google Drive).'],
['icon'=>'bi-check2-circle', 'title'=>'Test your restores', 'desc'=>'A backup you have never restored is just hope, not insurance. Test in staging every quarter.'],
['icon'=>'bi-clock', 'title'=>'Off-hours schedule', 'desc'=>'Pick a time when traffic is lowest (typically 02:0004:00 local) to minimise lock contention.'],
['icon'=>'bi-shield-lock', 'title'=>'Restrict access', 'desc'=>'Only grant <code>manage backup and storage</code> to senior admins. Restore is one click away from catastrophe.'],
['icon'=>'bi-bar-chart-line', 'title'=>'Monitor storage health', 'desc'=>'Watch the dark widget in the sidebar when it turns red, prune or expand storage.'],
['icon'=>'bi-clipboard-pulse', 'title'=>'Document recovery plan', 'desc'=>'Write down (outside this system) the exact steps to restore + who has the Google account credentials.'],
];
@endphp
@foreach($tips as $tip)
<div class="col-md-6">
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<i class="{{ $tip['icon'] }} text-success fs-5 mt-1"></i>
<div>
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
<div class="small text-muted" style="line-height:1.6;">{!! $tip['desc'] !!}</div>
</div>
</div>
</div>
@endforeach
</div>
{{-- PERMISSIONS --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-person-badge text-secondary me-1"></i>Permissions
</h6>
<div class="row g-2 mb-4">
<div class="col-md-6">
<div class="p-3 rounded-4" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<div class="fw-bold text-success mb-1"><code class="text-success">view backup and storage</code></div>
<div class="small text-muted">Read-only access. Can see archive list and storage health but cannot create / restore / delete.</div>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded-4" style="background:#fef2f2;border:1px solid #fecaca;">
<div class="fw-bold text-danger mb-1"><code class="text-danger">manage backup and storage</code></div>
<div class="small text-muted">Full control: create, restore, delete archives + change driver config + edit retention. <span class="fw-bold">Restrict tightly.</span></div>
</div>
</div>
</div>
{{-- FOOTER NOTE --}}
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
<div>
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">Where archives are stored on this server</div>
<div class="small text-muted">
Local backups live at <code>storage/app/backups/</code>. They are <span class="fw-bold">excluded from your code repository</span> by <code>.gitignore</code>. To migrate archives to a new server, copy this directory (and the Google Drive token from Global Settings → Backup).
</div>
</div>
</div>
</div> {{-- closes p-4 p-lg-5 --}}
</div> {{-- closes modal-body --}}
</div> {{-- closes modal-content --}}
</div> {{-- closes modal-dialog --}}
</div> {{-- closes modal --}}
@push('scripts')
<script>
$(document).ready(function () {
function renderBackups(backups) {
const $tbody = $('#backup-list-table tbody');
$tbody.empty();
if (!backups || backups.length === 0) {
$tbody.append('<tr><td colspan="4" class="text-center py-5 text-muted small fw-bold">{{ __("No system archives found.") }}</td></tr>');
return;
}
backups.forEach(function (item) {
let storageIcon = 'bi-hdd text-secondary';
let storageClass = 'bg-secondary-subtle text-secondary';
if (item.storage === 'gdrive') {
storageIcon = 'bi-google text-primary';
storageClass = 'bg-primary-subtle text-primary';
} else if (item.storage === 's3') {
storageIcon = 'bi-clouds text-warning';
storageClass = 'bg-warning-subtle text-warning-emphasis';
}
$tbody.append(`
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="avatar avatar-35 bg-light rounded-circle d-flex align-items-center justify-content-center me-3 border">
<i class="bi ${storageIcon}"></i>
</div>
<div>
<div class="fw-bold text-dark" style="font-size: 0.85rem; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${item.name}">${item.name.split('/').pop()}</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted" style="font-size: 10px;">${item.size}</span>
<span class="badge ${storageClass} border-0 rounded-pill text-uppercase" style="font-size: 8px; padding: 2px 6px;">${item.storage}</span>
</div>
</div>
</div>
</td>
<td>
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-3 py-1" style="font-size: 10px;">
${item.status}
</span>
</td>
<td>
<span class="text-muted small">${item.date}</span>
</td>
<td class="text-end pe-4">
<div class="d-flex justify-content-end gap-1">
<a href="{{ route('backup-restore.download') }}?disk=${item.storage}&path=${item.name}" class="btn btn-icon btn-sm btn-light rounded-circle" title="Download">
<i class="bi bi-download"></i>
</a>
@can('manage backup and storage')
<button type="button" class="btn btn-icon btn-sm btn-outline-primary rounded-circle btn-restore-backup" data-disk="${item.storage}" data-path="${item.name}" title="Restore">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<button type="button" class="btn btn-icon btn-sm btn-outline-danger rounded-circle btn-delete-backup" data-disk="${item.storage}" data-path="${item.name}" title="Delete">
<i class="bi bi-trash"></i>
</button>
@endcan
</div>
</td>
</tr>
`);
});
}
function loadBackups(driver = null) {
const $tbody = $('#backup-list-table tbody');
$tbody.html('<tr id="backup-loading"><td colspan="4" class="text-center py-5"><div class="spinner-border spinner-border-sm text-primary me-2"></div><span class="small fw-bold">{{ __("Refreshing inventory...") }}</span></td></tr>');
const url = new URL("{{ route('backup-restore.index') }}", window.location.origin);
if (driver) url.searchParams.append('driver', driver);
$.get(url.toString(), function (response) {
if (response.success) {
renderBackups(response.backups);
if (response.stats) {
const stats = response.stats;
$('#storage-health-widget').fadeIn();
$('#health-label').text(stats.label + " Usage");
$('#health-used').text(stats.used);
$('#health-total').text(stats.total);
$('#health-progress-bar').css('width', stats.percentage + '%');
$('#health-progress-bar').removeClass('bg-success bg-warning bg-danger').addClass('bg-' + stats.health);
$('#health-status').text(stats.percentage + '% Cap').removeClass('text-success text-warning text-danger bg-success-subtle bg-warning-subtle bg-danger-subtle').addClass('text-' + stats.health + ' bg-' + stats.health + '-subtle');
if (stats.health === 'danger') {
$('#storage-health-widget').addClass('pulse-danger border border-danger border-opacity-50');
} else {
$('#storage-health-widget').removeClass('pulse-danger border border-danger border-opacity-50');
}
$('#health-total-label').show();
$('#health-progress-wrapper').show();
if (stats.requirements && !stats.requirements.status) {
$('#requirements-alert').fadeIn();
$('#req-msg').text(stats.requirements.message);
} else {
$('#requirements-alert').hide();
}
}
} else {
$tbody.html(`<tr><td colspan="4" class="text-center py-5 text-danger small fw-bold">${response.message || '{{ __("Failed to load inventory.") }}'}</td></tr>`);
}
});
}
loadBackups($('#backup_db_driver').val());
$('#btn-refresh-backups').on('click', function () {
const $icon = $(this).find('i');
$icon.addClass('animate-spin');
loadBackups($('#backup_db_driver').val());
setTimeout(() => $icon.removeClass('animate-spin'), 1000);
});
$('#backup_db_driver').on('change', function () {
const driver = $(this).val();
// UI Toggle
if (driver === 'gdrive') {
$('#cloud_config_wrapper').slideDown();
$('#gdrive_fields').fadeIn();
} else {
$('#cloud_config_wrapper').slideUp();
}
// Dynamic Usage Update
loadBackups(driver);
});
$('#btn-create-backup').on('click', function () {
const $btn = $(this);
const original = $btn.html();
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Preparing...');
$.post("{{ route('backup-restore.create') }}", { _token: "{{ csrf_token() }}" }, function (response) {
if (response.success) {
renderBackups(response.backups || []);
StandardSwal.fire({ title: "Backup Started", text: response.message, icon: 'success', timer: 2000, showConfirmButton: false });
} else {
StandardSwal.fire({ title: 'Error', text: response.message, icon: 'error' });
}
}).always(() => $btn.prop('disabled', false).html(original));
});
$(document).on('click', '.btn-delete-backup', function () {
const $btn = $(this);
StandardSwal.fire({
title: "Delete Archive?",
text: "This file will be permanently removed from storage.",
icon: 'warning',
showCancelButton: true,
confirmButtonText: "Delete Now"
}).then((result) => {
if (result.isConfirmed) {
$.post("{{ route('backup-restore.delete') }}", {
_token: "{{ csrf_token() }}",
disk: $btn.data('disk'),
path: $btn.data('path')
}, function (response) {
if (response.success) {
renderBackups(response.backups);
StandardSwal.fire({ title: "Deleted", text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
}
});
}
});
});
$(document).on('click', '.btn-restore-backup', function () {
const $btn = $(this);
StandardSwal.fire({
title: "{{ __('Restore System?') }}",
html: `
<div class="text-start small">
<p class="mb-3">{{ __('This will replace your current database with the selected archive. The system will enter Maintenance Mode during the process.') }}</p>
<div class="alert alert-danger border-0 shadow-sm d-flex align-items-center gap-3 mb-0">
<i class="bi bi-exclamation-octagon display-6"></i>
<div>
<strong class="d-block">{{ __('CRITICAL ACTION') }}</strong>
{{ __('All current unsaved data will be lost permanently.') }}
</div>
</div>
</div>
`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: "{{ __('I Understand, Restore Now') }}",
confirmButtonColor: '#dc3545',
cancelButtonText: "{{ __('Cancel') }}"
}).then((result) => {
if (result.isConfirmed) {
StandardSwal.fire({
title: "{{ __('System Restoration In Progress') }}",
html: "{{ __('Please wait, do not close this window. The system is currently in Maintenance Mode.') }}",
allowOutsideClick: false,
didOpen: () => StandardSwal.showLoading()
});
$.post("{{ route('backup-restore.restore') }}", {
_token: "{{ csrf_token() }}",
disk: $btn.data('disk'),
path: $btn.data('path')
}, function (response) {
StandardSwal.fire({ title: "{{ __('Restored Successfully') }}", text: response.message, icon: 'success', timer: 3000 });
setTimeout(() => location.reload(), 3000);
}).fail(function(xhr) {
const msg = xhr.responseJSON?.message || '{{ __("Restoration failed. System is back online.") }}';
StandardSwal.fire({ title: "{{ __('Restoration Failed') }}", text: msg, icon: 'error' });
});
}
});
});
$('.btn-test-connection').on('click', function () {
const $btn = $(this);
const original = $btn.html();
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
$.post("{{ route('backup-restore.test-connection') }}", {
_token: "{{ csrf_token() }}",
driver: $btn.data('driver')
}, function (response) {
StandardSwal.fire({ title: response.success ? "Success" : "Failed", text: response.message, icon: response.success ? 'success' : 'error' });
}).always(() => $btn.prop('disabled', false).html(original));
});
$('#btn-gdrive-auth').on('click', function (e) {
const clientId = $('input[name="gdrive_client_id"]').val();
const clientSecret = $('input[name="gdrive_client_secret"]').val();
if (!clientId || !clientSecret) {
e.preventDefault();
StandardSwal.fire({ title: "Auth Failed", text: "Save Client ID & Secret first.", icon: 'warning' });
}
});
});
</script>
@endpush
</x-app-layout>
@@ -0,0 +1,542 @@
<x-app-layout>
@push('styles')
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet" />
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css"
rel="stylesheet" />
<style>
/* Shared Styles from Backup & Storage */
.status-widget-dark {
border-radius: 24px;
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
padding: 1.5rem;
color: white;
margin-bottom: 1.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
}
/* Attached Design Preview Styles */
.mockup-container {
position: sticky;
top: 100px;
}
.browser-mockup-frame {
background: #e2e8f0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.browser-top-bar {
background: #f8fafc;
padding: 10px 18px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 12px;
}
.browser-dots {
display: flex;
gap: 6px;
}
.browser-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.browser-url-field {
background: #ffffff;
border-radius: 6px;
padding: 3px 15px;
font-size: 11px;
color: #94a3b8;
flex-grow: 1;
max-width: 450px;
text-align: center;
border: 1px solid #f1f5f9;
}
/* VISITOR VIEW BACKGROUND (STRICT AS PER IMAGE) */
.viewport-preview {
height: 700px;
background: #f4f4f4;
/* Base light gray */
background-image: repeating-linear-gradient(45deg, #f0f2f5, #f0f2f5 10px, #ffffff 10px, #ffffff 20px);
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
position: relative;
}
/* WHITE CARD (STRICT AS PER IMAGE) */
.visitor-card-premium {
background: #ffffff;
border-radius: 42px;
padding: 4rem 3rem;
text-align: center;
width: 100%;
max-width: 440px;
box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.04);
z-index: 2;
}
.under-maintenance-pill {
background: #ffffff;
border: 1px solid #eef0f3;
border-radius: 100px;
padding: 8px 18px;
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 2.5rem;
font-size: 11px;
font-weight: 800;
color: #1a1c1e;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02);
}
.pulse-dot-red {
width: 8px;
height: 8px;
background-color: #ef4444;
border-radius: 50%;
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
70% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* COUNTDOWN SQUARES (STRICT AS PER IMAGE) */
.countdown-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-top: 2rem;
}
.countdown-square {
background: #1a1c1e;
border-radius: 20px;
aspect-ratio: 1/1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: white;
transition: all 0.3s ease;
}
.countdown-square:hover {
transform: scale(1.05);
}
.countdown-number {
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
margin-bottom: 2px;
}
.countdown-label {
font-size: 7px;
text-transform: uppercase;
opacity: 0.5;
letter-spacing: 1px;
font-weight: 700;
}
.custom-switch-premium .form-check-input {
width: 3rem;
height: 1.5rem;
cursor: pointer;
}
.filepond--root {
margin-bottom: 0;
}
.filepond--panel-root {
background-color: #f8fafc;
border: 2px dashed #e2e8f0;
border-radius: 16px;
}
</style>
@endpush
<div class="container-fluid pb-5">
{{-- Page Header --}}
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
<div>
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
{{ __('Maintenance Mode') }}
</h4>
<p class="text-muted small mb-0">
{{ __('Take your application offline for scheduled updates and optimization.') }}
</p>
</div>
</div>
<form id="maintenanceConfigForm" action="{{ route('system-config.update') }}" method="POST"
enctype="multipart/form-data" autocomplete="off" class="ajax-form" data-reset="false">
@csrf
@method('PUT')
<div class="row gx-4">
{{-- Left: Controls (Col-4) --}}
<div class="col-lg-4 animate__animated animate__fadeIn">
{{-- Status Card (Backup Styling) --}}
<div class="status-widget-dark">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center gap-2">
<div class="bg-white bg-opacity-10 rounded-circle p-2">
<i class="bi bi-broadcast text-info"></i>
</div>
<span class="small fw-bold opacity-75 text-uppercase tracking-wider">Storage
Health</span>
</div>
@if($is_down)
<span class="badge rounded-pill bg-danger text-white fw-bold px-3 py-2"
style="font-size: 10px;">Maintenance</span>
@else
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2"
style="font-size: 10px;">Operational</span>
@endif
</div>
<div class="mb-4">
<h2 class="mb-1 fw-bold" style="font-family: 'Outfit', sans-serif;">
@if($is_down)
System Offline
@else
Systems Ready
@endif
</h2>
<p class="small opacity-50 mb-0">Public access control center.</p>
</div>
<div
class="form-check form-switch custom-switch-premium p-0 d-flex align-items-center justify-content-between bg-white bg-opacity-5 p-3 rounded-4 border border-white border-opacity-10">
<label class="form-check-label fw-semibold small text-white"
for="maintenance_mode_enabled">{{ __('ENABLE MODE') }}</label>
<input class="form-check-input ms-0" type="checkbox" role="switch"
id="maintenance_mode_enabled" name="maintenance_mode_enabled" value="1"
@checked(old('maintenance_mode_enabled', $settings['maintenance_mode_enabled'] ?? false))>
</div>
</div>
{{-- Config Sections --}}
<div class="card adminuiux-card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
<i class="bi bi-palette-fill text-primary"></i>
{{ __('Visual & Branding') }}
</h6>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Main Headline') }}</label>
<input type="text" class="form-control" name="maintenance_mode_title"
value="{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Description') }}</label>
<textarea class="form-control" name="maintenance_mode_message"
rows="3">{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}</textarea>
</div>
<div class="mb-0">
<label class="form-label fw-semibold mb-2">{{ __('Illustration / Logo') }}</label>
<input type="file" id="maintenance_mode_image" name="maintenance_mode_image"
accept="image/png,image/jpeg,image/svg+xml">
</div>
</div>
</div>
<div class="card adminuiux-card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
<i class="bi bi-clock-fill text-primary"></i>
{{ __('Time & Access') }}
</h6>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Secret Bypass Key') }}</label>
<input type="text" class="form-control" name="maintenance_mode_secret"
placeholder="e.g. admin-only"
value="{{ $settings['maintenance_mode_secret'] ?? '' }}">
</div>
<div class="row g-2">
<div class="col-12 mb-2">
<label class="form-label fw-semibold">{{ __('End Time') }}</label>
<input type="datetime-local" class="form-control" name="maintenance_mode_end_at"
value="{{ !empty($settings['maintenance_mode_end_at']) ? date('Y-m-d\TH:i', strtotime($settings['maintenance_mode_end_at'])) : '' }}">
</div>
<div class="col-12">
<label class="form-label fw-semibold">{{ __('Retry Interval (Seconds)') }}</label>
<input type="number" class="form-control" name="maintenance_mode_retry"
value="{{ $settings['maintenance_mode_retry'] ?? 3600 }}">
</div>
</div>
</div>
</div>
<div class="card adminuiux-card mb-4 border-warning border-opacity-25 bg-warning bg-opacity-5">
<div class="card-body">
<h6 class="fw-bold mb-3 d-flex align-items-center gap-2 text-warning-emphasis">
<i class="bi bi-megaphone-fill"></i>
{{ __('Broadcast Warning') }}
</h6>
<p class="extra-small text-muted mb-4">
{{ __('Alert all active users before shutting down the system. They will receive a real-time notification.') }}
</p>
<div class="input-group">
<select class="form-select" id="broadcast_minutes">
<option value="1">1 {{ __('Minute') }}</option>
<option value="5" selected>5 {{ __('Minutes') }}</option>
<option value="10">10 {{ __('Minutes') }}</option>
<option value="30">30 {{ __('Minutes') }}</option>
</select>
<button type="button" class="btn btn-warning fw-bold px-3" id="btn-broadcast-warning">
<i class="bi bi-send-fill me-1"></i> {{ __('Send Alert') }}
</button>
</div>
</div>
</div>
@can('manage maintenance mode')
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
{{ __('Apply Configuration') }}
</button>
</div>
@endcan
</div>
{{-- Right: Live Preview (Col-8) --}}
<div class="col-lg-8 animate__animated animate__fadeIn">
<div class="mockup-container">
<div class="browser-mockup-frame">
{{-- Toolbar --}}
<div class="browser-top-bar">
<div class="browser-dots">
<div class="browser-dot" style="background: #ff5f56;"></div>
<div class="browser-dot" style="background: #ffbd2e;"></div>
<div class="browser-dot" style="background: #27c93f;"></div>
</div>
<div class="browser-url-field">
{{ url('/') }}
</div>
</div>
{{-- Viewport (Design from Image) --}}
<div class="viewport-preview">
<div class="visitor-card-premium">
{{-- Maintenance Pill --}}
<div class="under-maintenance-pill">
<span class="pulse-dot-red"></span>
{{ __('UNDER MAINTENANCE') }}
</div>
{{-- Logo Container --}}
<div class="mb-4 mx-auto" id="preview-mnt-image-container"
style="max-width: 140px; min-height: 80px; display: flex; align-items: center; justify-content: center;">
@php
$mnt_img = $settings['maintenance_mode_image'] ?? '';
$display_img = null;
if (!empty($mnt_img)) {
$display_img = str_starts_with($mnt_img, 'assets/') ? asset($mnt_img) : asset('storage/' . $mnt_img);
} elseif (!empty($settings['app_logo'])) {
$display_img = str_starts_with($settings['app_logo'], 'assets/') ? asset($settings['app_logo']) : asset('storage/' . $settings['app_logo']);
}
@endphp
@if($display_img)
<img src="{{ $display_img }}" class="img-fluid" id="mnt-preview-img" alt="Logo">
@else
<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25"
style="font-size: 3.5rem !important;"></i>
@endif
</div>
{{-- Content --}}
<h2 class="fw-black mb-3 text-dark" id="preview-mnt-title"
style="font-family: 'Outfit', sans-serif;">
{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}
</h2>
<p class="text-secondary small mb-5" id="preview-mnt-message"
style="line-height: 1.6; opacity: 0.8; font-weight: 500;">
{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}
</p>
{{-- Countdown Grid (Image Style) --}}
<div class="countdown-grid" id="preview-mnt-countdown">
@foreach(['Days', 'Hours', 'Mins', 'Secs'] as $label)
<div class="countdown-square shadow-lg">
<div class="countdown-number" id="cd-{{ strtolower($label) }}">00</div>
<div class="countdown-label">{{ $label }}</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
@push('scripts')
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/filepond/dist/filepond.js" crossorigin="anonymous"></script>
<script>
$(document).ready(function () {
// Initialize FilePond
FilePond.registerPlugin(FilePondPluginImagePreview);
const pond = FilePond.create(document.querySelector('#maintenance_mode_image'), {
name: 'maintenance_mode_image',
labelIdle: '<span class="text-muted small">Drop Logo or <span class="text-primary fw-bold">Browse</span></span>',
imagePreviewHeight: 120,
stylePanelLayout: 'compact',
});
// Form Handling
$('#maintenanceConfigForm').on('ajaxForm:beforeSend', function (e, formData) {
const files = pond.getFiles();
if (files.length > 0) {
formData.set('maintenance_mode_image', files[0].file);
}
});
$('#maintenanceConfigForm').on('ajaxForm:success', function (e, response) {
StandardSwal.fire({ title: 'Success!', text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
if (response.settings && response.settings.maintenance_mode_image) {
const path = response.settings.maintenance_mode_image;
const newUrl = path.startsWith('assets/') ? `/${path}` : `/storage/${path}`;
originalImgSrc = `${newUrl}?v=${new Date().getTime()}`;
}
if (response.hasOwnProperty('is_down')) {
setTimeout(() => location.reload(), 1000);
}
pond.removeFiles();
});
// Live Preview Sync
$('input[name="maintenance_mode_title"]').on('input', function () {
$('#preview-mnt-title').text($(this).val() || 'biiproject.com');
});
$('textarea[name="maintenance_mode_message"]').on('input', function () {
$('#preview-mnt-message').text($(this).val() || 'Maintenance in progress...');
});
// Countdown Logic
let countdownInterval;
function updateCountdown() {
const val = $('input[name="maintenance_mode_end_at"]').val();
if (!val) {
$('#preview-mnt-countdown').addClass('opacity-25');
return;
}
$('#preview-mnt-countdown').removeClass('opacity-25');
if (countdownInterval) clearInterval(countdownInterval);
const target = new Date(val).getTime();
countdownInterval = setInterval(() => {
const now = new Date().getTime();
const diff = target - now;
if (diff < 0) {
clearInterval(countdownInterval);
$('#cd-days, #cd-hours, #cd-mins, #cd-secs').text('00');
return;
}
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const s = Math.floor((diff % (1000 * 60)) / 1000);
$('#cd-days').text(d.toString().padStart(2, '0'));
$('#cd-hours').text(h.toString().padStart(2, '0'));
$('#cd-mins').text(m.toString().padStart(2, '0'));
$('#cd-secs').text(s.toString().padStart(2, '0'));
}, 1000);
}
$('input[name="maintenance_mode_end_at"]').on('change', updateCountdown);
updateCountdown();
// Image Swap
let originalImgSrc = $('#mnt-preview-img').attr('src');
pond.on('addfile', (error, file) => {
if (!error) {
const url = URL.createObjectURL(file.file);
$('#preview-mnt-image-container').html(`<img src="${url}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
}
});
pond.on('removefile', () => {
if (originalImgSrc) {
$('#preview-mnt-image-container').html(`<img src="${originalImgSrc}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
} else {
$('#preview-mnt-image-container').html(`<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25" style="font-size: 3.5rem !important;"></i>`);
}
});
// Broadcast Warning
$('#btn-broadcast-warning').on('click', function () {
const minutes = $('#broadcast_minutes').val();
const $btn = $(this);
StandardSwal.fire({
title: "{{ __('Send Broadcast Alert?') }}",
text: "{{ __('All active users will receive a warning about the upcoming maintenance.') }}",
icon: 'warning',
showCancelButton: true,
confirmButtonText: "{{ __('Send Alert') }}"
}).then((result) => {
if (result.isConfirmed) {
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>');
$.post("{{ route('maintenance-mode.broadcast') }}", {
_token: "{{ csrf_token() }}",
minutes: minutes
}, function (response) {
StandardSwal.fire({ title: "{{ __('Success') }}", text: response.message, icon: 'success' });
}).always(() => $btn.prop('disabled', false).html('<i class="bi bi-send-fill me-1"></i> {{ __("Send Alert") }}'));
}
});
});
});
</script>
<style>
.x-small {
font-size: 11px;
}
</style>
@endpush
</x-app-layout>
@@ -0,0 +1,707 @@
<x-app-layout>
@push('styles')
<style>
.ck-editor__editable {
min-height: 200px;
}
</style>
@endpush
<div class="container-fluid pb-4">
<div class="row align-items-center mb-4">
<div class="col">
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
</div>
@hasanyrole('Developer|Administrator')
<div class="col-auto d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
<i class="bi bi-book"></i>{{ __('Documentation') }}
</button>
<div class="btn-group">
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
</button>
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
</button>
</div>
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
</button>
</div>
@else
<div class="col-auto d-flex gap-2">
<button type="button"
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
<i class="bi bi-book"></i>{{ __('Documentation') }}
</button>
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
</button>
</div>
@endhasanyrole
</div>
<div class="row justify-content-center">
<div class="col-12 col-xl-10 col-lg-11">
{{-- Standardized Notification Feed --}}
<div id="notification-feed">
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
</div>
</div>
</div>
{{-- Pagination --}}
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
</div>
</div>
{{-- ====================================================================
DOCUMENTATION (MODAL)
==================================================================== --}}
<div class="modal fade" id="notifDocsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
<div class="modal-header p-0 border-0" style="background:#0f172a;">
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
<div>
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Notification Center Guide') }}</h3>
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
{{ __('How notifications flow through this system — from sending to bell icon to acknowledged. Who sees what, when, and why.') }}
</p>
</div>
<div class="d-flex align-items-start gap-3">
<i class="bi bi-bell-fill display-3 opacity-25 d-none d-md-inline"></i>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
</div>
</div>
<div class="modal-body p-0">
<div class="p-4 p-lg-5">
{{-- LIFECYCLE DIAGRAM --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-arrow-right-circle text-primary me-1"></i>{{ __('Notification lifecycle') }}
</h6>
<div class="p-4 rounded-4 mb-5" style="background:#f8fafc;border:1px solid #e2e8f0;">
<div class="row text-center align-items-center g-3">
<div class="col-md-2">
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
<i class="bi bi-send-fill text-primary fs-3"></i>
<div class="fw-bold small mt-2">{{ __('Sent') }}</div>
<div class="extra-small text-muted">Admin clicks Send / system triggers</div>
</div>
</div>
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
<div class="col-md-2">
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
<i class="bi bi-database-fill text-info fs-3"></i>
<div class="fw-bold small mt-2">{{ __('Persisted') }}</div>
<div class="extra-small text-muted">Row written to <code>notifications</code> table</div>
</div>
</div>
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
<div class="col-md-2">
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
<i class="bi bi-broadcast text-warning fs-3"></i>
<div class="fw-bold small mt-2">{{ __('Broadcast') }}</div>
<div class="extra-small text-muted">Real-time via Reverb WebSocket</div>
</div>
</div>
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
<div class="col-md-2">
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
<i class="bi bi-bell-fill text-success fs-3"></i>
<div class="fw-bold small mt-2">{{ __('Delivered') }}</div>
<div class="extra-small text-muted">Bell icon + feed updates</div>
</div>
</div>
</div>
<p class="small text-muted text-center mb-0 mt-4" style="line-height:1.7;">
{{ __('Notifications persist in the database even after they are read — only "Clear read" deletes them. The feed paginates 15 per page.') }}
</p>
</div>
{{-- THREE TYPES --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-tags-fill text-info me-1"></i>{{ __('Notification types · pick the right one') }}
</h6>
<div class="row g-3 mb-5">
<div class="col-md-4">
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-info-circle-fill text-primary fs-3"></i>
<div class="fw-bold text-primary fs-6">{{ __('INFORMATION') }}</div>
</div>
<div class="small text-muted mb-3" style="line-height:1.7;">
{{ __('Neutral updates. No action required from the recipient.') }}
</div>
<div class="extra-small text-muted">
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
<li>{{ __('New feature announcement') }}</li>
<li>{{ __('Scheduled maintenance reminder') }}</li>
<li>{{ __('Newsletter / company update') }}</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="p-4 rounded-4 h-100" style="background:#fffbeb;border:1px solid #fde68a;">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-3"></i>
<div class="fw-bold fs-6" style="color:#a16207;">{{ __('WARNING') }}</div>
</div>
<div class="small text-muted mb-3" style="line-height:1.7;">
{{ __('Recipient should pay attention. Soft urgency.') }}
</div>
<div class="extra-small text-muted">
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
<li>{{ __('Quota nearing limit') }}</li>
<li>{{ __('Pending approval required') }}</li>
<li>{{ __('Deprecated feature retiring soon') }}</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4">
<div class="p-4 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-megaphone-fill text-danger fs-3"></i>
<div class="fw-bold text-danger fs-6">{{ __('SYSTEM ALERT') }}</div>
</div>
<div class="small text-muted mb-3" style="line-height:1.7;">
{{ __('Immediate attention. Affects the platform itself.') }}
</div>
<div class="extra-small text-muted">
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
<li>{{ __('Emergency maintenance starting NOW') }}</li>
<li>{{ __('Security incident notification') }}</li>
<li>{{ __('Service outage / degradation') }}</li>
</ul>
</div>
</div>
</div>
</div>
{{-- RECIPIENT TARGETING --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-people-fill text-success me-1"></i>{{ __('Recipient targeting') }}
</h6>
<div class="row g-3 mb-5">
<div class="col-md-6">
<div class="p-4 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-globe text-success fs-4"></i>
<div class="fw-bold text-success">{{ __('All Users (Public)') }}</div>
</div>
<div class="small text-muted" style="line-height:1.7;">
{{ __('Broadcast to every active account in the system, regardless of role. Use for company-wide announcements only — over-using this trains users to ignore the bell icon.') }}
</div>
</div>
</div>
<div class="col-md-6">
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-funnel-fill text-primary fs-4"></i>
<div class="fw-bold text-primary">{{ __('By Role') }}</div>
</div>
<div class="small text-muted" style="line-height:1.7;">
{{ __('Pick a specific role (e.g. Developer, Manager, Staff). Only users holding that role will see the notification. Precise — recommended default.') }}
</div>
</div>
</div>
</div>
{{-- USER ACTIONS --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-hand-index-fill text-warning me-1"></i>{{ __('Reader actions') }}
</h6>
<div class="row g-3 mb-5">
@php
$actions = [
['icon'=>'bi-check2','color'=>'#22c55e','title'=>'Mark as read','desc'=>'Click a single notification card. Removes its unread highlight; stays in the feed.','boldWord'=>null],
['icon'=>'bi-check-all','color'=>'#3b82f6','title'=>'Mark all read','desc'=>'Bulk action. Marks every unread notification at once. Useful after vacation / morning catch-up.','boldWord'=>null],
['icon'=>'bi-trash3-fill','color'=>'#ef4444','title'=>'Clear read','desc1'=>'Permanently deletes ','boldWord'=>'already-read','desc2'=>' notifications. Unread ones are preserved. Cannot be undone.'],
['icon'=>'bi-x-lg','color'=>'#94a3b8','title'=>'Delete one','desc'=>'Dismiss a single notification (read or unread). Removes it from your feed only — other recipients still see theirs.','boldWord'=>null],
];
@endphp
@foreach($actions as $a)
<div class="col-md-6 col-lg-3">
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
<div class="rounded-3 d-inline-flex align-items-center justify-content-center mb-2"
style="background:{{ $a['color'] }}1a;color:{{ $a['color'] }};width:40px;height:40px;">
<i class="{{ $a['icon'] }} fs-5"></i>
</div>
<div class="fw-bold text-dark mb-1" style="font-size:.88rem;">{{ $a['title'] }}</div>
<div class="small text-muted" style="line-height:1.6;">
@if(isset($a['boldWord']))
{{ $a['desc1'] }}<span class="fw-bold">{{ $a['boldWord'] }}</span>{{ $a['desc2'] }}
@else
{{ $a['desc'] }}
@endif
</div>
</div>
</div>
@endforeach
</div>
{{-- WRITING GUIDE --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-pencil-square text-info me-1"></i>{{ __('Writing a good notification') }}
</h6>
<div class="row g-3 mb-5">
<div class="col-md-6">
<div class="p-3 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<div class="fw-bold text-success mb-2"><i class="bi bi-check-circle me-1"></i>{{ __('DO') }}</div>
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
<li>{{ __('Lead with the most important info — recipients scan, not read') }}</li>
<li>{{ __('Keep title under 60 characters (it gets truncated on mobile)') }}</li>
<li>{{ __('Use plain language, avoid internal jargon') }}</li>
<li>{{ __('Include an action verb if user needs to do something ("Click here", "Approve", "Review")') }}</li>
<li>{{ __('Match the type to the urgency') }}</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
<div class="fw-bold text-danger mb-2"><i class="bi bi-x-circle me-1"></i>{{ __("DON'T") }}</div>
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
<li>{{ __('Use SYSTEM ALERT for routine info — it loses meaning') }}</li>
<li>{{ __('Send the same message twice — there is no undo') }}</li>
<li>{{ __('Include sensitive data (passwords, tokens) — notifications are stored plaintext') }}</li>
<li>{{ __('Mass-send "All Users" for things only a few care about') }}</li>
<li>{{ __('Forget to test on the recipient role first (small group) before broadcasting') }}</li>
</ul>
</div>
</div>
</div>
{{-- ARCHITECTURE --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-gear-fill text-secondary me-1"></i>{{ __('How it works under the hood') }}
</h6>
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
<span style="color:#22c55e;">DELIVERY PIPELINE:</span>
<span style="color:#fbbf24;">[1]</span> Admin submits form <span style="color:#22c55e;">NotificationCenterController::store()</span>
<span style="color:#fbbf24;">[2]</span> Recipients resolved:
<span style="color:#94a3b8;"> "all" User::where('active', true)->get()
role-X User::role('X')->get()</span>
<span style="color:#fbbf24;">[3]</span> Laravel <span style="color:#22c55e;">SystemManagementNotification</span> dispatched per user
<span style="color:#94a3b8;">Channels: ['database', 'broadcast']</span>
<span style="color:#fbbf24;">[4]</span> <span style="color:#22c55e;">database</span> channel row in <code style="color:#f59e0b;">notifications</code> table
<span style="color:#fbbf24;">[5]</span> <span style="color:#22c55e;">broadcast</span> channel Reverb WebSocket bell icon updates live
<span style="color:#fbbf24;">[6]</span> Recipient pulls feed <span style="color:#22c55e;">GET /notification-center/api/recent</span>
<span style="color:#94a3b8;"># Feature flag: <code style="color:#f59e0b;">feature_notification_center</code> in Global Settings
# When OFF, the menu hides for non-admins but admins still see it.</span></pre>
</div>
{{-- PERMISSIONS --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-person-badge text-secondary me-1"></i>{{ __('Who can do what') }}
</h6>
<div class="table-responsive mb-5">
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
<thead style="background:#e2e8f0;">
<tr>
<th class="ps-3">{{ __('CAPABILITY') }}</th>
<th>{{ __('REQUIRED ROLE / PERMISSION') }}</th>
<th>{{ __('NOTES') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Read own feed') }}</td>
<td class="small"><code>view notification center</code></td>
<td class="small text-muted">{{ __('Required to even open this page') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Mark / delete own notifications') }}</td>
<td class="small"><code>view notification center</code></td>
<td class="small text-muted">{{ __('Implicit — users can always manage their own feed') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Send to a role') }}</td>
<td class="small">{{ __('Role:') }} <span class="badge bg-dark text-white">Developer</span> {{ __('or') }} <span class="badge bg-dark text-white">Administrator</span></td>
<td class="small text-muted">{{ __('Anyone outside these roles will not see the Send button') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Toggle the whole feature off') }}</td>
<td class="small"><code>manage global settings</code></td>
<td class="small text-muted">{{ __('Global Settings → Notifications →') }} <code>feature_notification_center</code></td>
</tr>
</tbody>
</table>
</div>
{{-- TROUBLESHOOTING --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>{{ __('Troubleshooting') }}
</h6>
<div class="table-responsive mb-5">
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
<thead style="background:#e2e8f0;">
<tr>
<th class="ps-3">{{ __('SYMPTOM') }}</th>
<th>{{ __('LIKELY CAUSE') }}</th>
<th>{{ __('FIX') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Sent but recipient sees nothing') }}</td>
<td class="small text-muted">{{ __('Recipient has no matching role, or is inactive') }}</td>
<td class="small text-muted">{{ __('Verify the user is active + holds the targeted role') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Bell icon does not update live') }}</td>
<td class="small text-muted">{{ __('Reverb WebSocket disconnected') }}</td>
<td class="small text-muted">{{ __('Check Monitoring Center → Reverb status. Restart if IDLE during traffic.') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('"Clear read" did nothing') }}</td>
<td class="small text-muted">{{ __('Nothing was read yet — only read items get purged') }}</td>
<td class="small text-muted">{{ __('Mark as read first, then Clear read') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Menu hidden in sidebar') }}</td>
<td class="small text-muted">{{ __('Feature flag') }} <code>feature_notification_center</code> {{ __('is OFF') }}</td>
<td class="small text-muted">{{ __('Global Settings → Notifications → enable the toggle') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Send button missing') }}</td>
<td class="small text-muted">{{ __('Account lacks Developer / Administrator role') }}</td>
<td class="small text-muted">{{ __('Ask an admin to grant the role, or use CLI to seed') }}</td>
</tr>
<tr>
<td class="ps-3 fw-bold text-dark">{{ __('Feed empty even after sending') }}</td>
<td class="small text-muted">{{ __('The notifications table was truncated') }}</td>
<td class="small text-muted">{{ __('Send a fresh one — old data is gone, new ones will appear') }}</td>
</tr>
</tbody>
</table>
</div>
{{-- BEST PRACTICES --}}
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
<i class="bi bi-stars text-success me-1"></i>{{ __('Best practices') }}
</h6>
<div class="row g-2 mb-4">
@php
$tips = [
['icon'=>'bi-target','title'=>'Be specific with audience','desc'=>'Target a role, not "All Users", whenever the message only matters to a subset. Saves everyone scroll time.'],
['icon'=>'bi-clock-history','title'=>'Mind the timing','desc'=>'Avoid sending non-urgent notifications outside business hours — push fatigue eats engagement.'],
['icon'=>'bi-funnel','title'=>'One topic per notification','desc'=>'If you have three things to say, send three notifications. Long mixed messages get skimmed.'],
['icon'=>'bi-chat-square-quote','title'=>'Test on yourself first','desc'=>'Send to "Developer" role first, check how it looks on mobile + desktop, then broadcast.'],
['icon'=>'bi-shield-check','title'=>'Never include secrets','desc'=>'Database rows persist until cleared. Passwords, tokens, payment info → never via notification.'],
['icon'=>'bi-bell-slash','title'=>'Respect the bell','desc'=>'Each unnecessary notification is one step closer to users disabling the bell entirely. Less is more.'],
];
@endphp
@foreach($tips as $tip)
<div class="col-md-6">
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
<i class="{{ $tip['icon'] }} text-success fs-4"></i>
<div>
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
<div class="small text-muted" style="line-height:1.6;">{{ $tip['desc'] }}</div>
</div>
</div>
</div>
@endforeach
</div>
{{-- FOOTER NOTE --}}
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
<div>
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ __('Implementation note') }}</div>
<div class="small text-muted">
Notifications use Laravel's built-in <code>Illuminate\Notifications</code> with two channels:
<code>database</code> (persistent storage) and <code>broadcast</code> (real-time via Reverb WebSocket).
The notification class is <code>App\Notifications\SystemManagementNotification</code>.
Per-user rows live in the <code>notifications</code> table with Laravel-standard UUIDs.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- MODAL --}}
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-3 border-0 shadow-lg">
<div class="modal-header">
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ route('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
@csrf
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
<select name="recipient" class="form-select">
<option value="all">{{ __('All Users (Public)') }}</option>
@foreach($roles as $roleName)
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
@endforeach
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
<select name="type" class="form-select">
<option value="info">{{ __('Information') }}</option>
<option value="warning">{{ __('Warning') }}</option>
<option value="system">{{ __('System Alert') }}</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
{{ __('Close') }}
</button>
<button type="submit" class="btn btn-dark rounded-pill px-4">
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
let notificationEditor;
let currentPage = 1;
const editorEl = document.querySelector('#notificationMessage');
if (editorEl && typeof ClassicEditor !== 'undefined') {
ClassicEditor
.create(editorEl, {
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
})
.then(newEditor => {
notificationEditor = newEditor;
editorEl.ckeditorInstance = newEditor;
})
.catch(error => console.error('CKEditor Error:', error));
}
// Load feed — this MUST always run
loadFeed(1);
function loadFeed(page = 1) {
currentPage = page;
const $container = $('#notification-feed');
$.ajax({
url: "{{ route('notification-center.index') }}",
data: {
start: (page - 1) * 10,
length: 10,
draw: 1
},
success: function(response) {
console.log('Notification Response:', response);
if (!response.data || response.data.length === 0) {
$container.html(`
<div class="text-center py-5 opacity-50">
<i class="bi bi-inbox h1 display-1"></i>
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
</div>
`);
} else {
let html = '';
response.data.forEach(n => {
try {
html += window.renderNotificationCard(n);
} catch (e) {
console.error('Render Error:', e, n);
}
});
$container.html(html);
}
renderPagination(response.recordsTotal, page);
},
error: function(xhr, status, error) {
console.error('AJAX Error:', status, error, xhr.responseText);
let message = 'Failed to load notifications.';
try {
if (xhr.responseJSON && xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
} catch(e) {}
$container.html(`
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-danger">
<i class="bi bi-exclamation-circle h3"></i>
<p class="small mt-2">${message}</p>
<p class="text-muted smallest">Status: ${xhr.status}</p>
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
</div>
</div>
`);
}
});
}
window.reloadFeed = () => loadFeed(currentPage);
function renderPagination(total, current) {
const pages = Math.ceil(total / 10);
if (pages <= 1) { $('#feed-pagination').html(''); return; }
let html = '<ul class="pagination pagination-sm m-0">';
// Previous button
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">&laquo;</a></li>`;
// Page numbers with sliding window (current page +/- 2)
const delta = 2;
const left = current - delta;
const right = current + delta;
const range = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || (i >= left && i <= right)) {
range.push(i);
}
}
let last = 0;
for (let i of range) {
if (last) {
if (i - last === 2) {
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
} else if (i - last !== 1) {
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
}
}
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
last = i;
}
// Next button
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">&raquo;</a></li>`;
html += '</ul>';
$('#feed-pagination').html(html);
}
$(document).on('click', '.page-link', function(e) {
e.preventDefault();
loadFeed($(this).data('page'));
});
$(document).on('click', '.btn-delete', function() {
const url = $(this).data('url');
StandardSwal.fire({
title: 'Delete this notification?',
text: 'This notification will be permanently removed from your history.',
icon: 'warning',
showCancelButton: true,
customClass: {
confirmButton: 'btn-pill-danger',
cancelButton: 'btn-pill-cancel'
},
confirmButtonText: 'Yes, Delete',
cancelButtonText: "Cancel",
}).then(result => {
if (result.isConfirmed) {
$.ajax({
url: url,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'Notification deleted');
},
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
});
}
});
});
$('#page-mark-all-read').on('click', function() {
$.ajax({
url: "{{ route('notification-center.read-all') }}",
method: 'PATCH',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'All marked as read');
},
error: () => window.showNotificationToast('error', 'Process failed')
});
});
$('#page-clear-read').on('click', function() {
StandardSwal.fire({
title: "Clear all read notifications?",
text: "All read updates will be permanently purged from your feed.",
icon: 'warning',
showCancelButton: true,
customClass: {
confirmButton: 'btn-pill-danger',
cancelButton: 'btn-pill-cancel'
},
confirmButtonText: "Yes, Clear",
cancelButtonText: "Cancel",
}).then(result => {
if (result.isConfirmed) {
$.ajax({
url: "{{ route('notification-center.clear-read') }}",
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'Read notifications cleared');
},
error: () => window.showNotificationToast('error', 'Clear failed')
});
}
});
});
// Listen for standard AJAX success to reload feed
$('#manualNotificationForm').on('ajaxForm:success', function() {
window.reloadNotificationUI();
if (notificationEditor) notificationEditor.setData('');
});
});
</script>
@endpush
</x-app-layout>
@@ -0,0 +1,317 @@
<x-app-layout>
@push('styles')
<style>
.ck-editor__editable {
min-height: 200px;
}
</style>
@endpush
<div class="container-fluid py-4">
<div class="row align-items-center mb-4">
<div class="col">
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
</div>
@hasanyrole('Developer|Administrator')
<div class="col-auto">
<div class="btn-group me-2">
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
</button>
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
</button>
</div>
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
</button>
</div>
@else
<div class="col-auto">
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
</button>
</div>
@endhasanyrole
</div>
<div class="row justify-content-center">
<div class="col-12 col-xl-10 col-lg-11">
{{-- Standardized Notification Feed --}}
<div id="notification-feed">
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
</div>
</div>
</div>
{{-- Pagination --}}
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
</div>
</div>
</div>
{{-- MODAL --}}
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-3 border-0 shadow-lg">
<div class="modal-header">
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ rout('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
@csrf
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
<select name="recipient" class="form-select">
<option value="all">{{ __('All Users (Public)') }}</option>
@foreach($roles as $roleName)
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
@endforeach
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
<select name="type" class="form-select">
<option value="info">{{ __('Information') }}</option>
<option value="warning">{{ __('Warning') }}</option>
<option value="system">{{ __('System Alert') }}</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
{{ __('Close') }}
</button>
<button type="submit" class="btn btn-dark rounded-pill px-4">
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
let notificationEditor;
let currentPage = 1;
const editorEl = document.querySelector('#notificationMessage');
if (editorEl && typeof ClassicEditor !== 'undefined') {
ClassicEditor
.create(editorEl, {
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
})
.then(newEditor => {
notificationEditor = newEditor;
editorEl.ckeditorInstance = newEditor;
})
.catch(error => console.error('CKEditor Error:', error));
}
// Load feed — this MUST always run
loadFeed(1);
function loadFeed(page = 1) {
currentPage = page;
const $container = $('#notification-feed');
$.ajax({
url: "{{ route('notification-center.index') }}",
data: {
start: (page - 1) * 10,
length: 10,
draw: 1
},
success: function(response) {
console.log('Notification Response:', response);
if (!response.data || response.data.length === 0) {
$container.html(`
<div class="text-center py-5 opacity-50">
<i class="bi bi-inbox h1 display-1"></i>
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
</div>
`);
} else {
let html = '';
response.data.forEach(n => {
try {
html += window.renderNotificationCard(n);
} catch (e) {
console.error('Render Error:', e, n);
}
});
$container.html(html);
}
renderPagination(response.recordsTotal, page);
},
error: function(xhr, status, error) {
console.error('AJAX Error:', status, error, xhr.responseText);
let message = 'Failed to load notifications.';
try {
if (xhr.responseJSON && xhr.responseJSON.message) {
message = xhr.responseJSON.message;
}
} catch(e) {}
$container.html(`
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-danger">
<i class="bi bi-exclamation-circle h3"></i>
<p class="small mt-2">${message}</p>
<p class="text-muted smallest">Status: ${xhr.status}</p>
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
</div>
</div>
`);
}
});
}
window.reloadFeed = () => loadFeed(currentPage);
function renderPagination(total, current) {
const pages = Math.ceil(total / 10);
if (pages <= 1) { $('#feed-pagination').html(''); return; }
let html = '<ul class="pagination pagination-sm m-0">';
// Previous button
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">&laquo;</a></li>`;
// Page numbers with sliding window (current page +/- 2)
const delta = 2;
const left = current - delta;
const right = current + delta;
const range = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || (i >= left && i <= right)) {
range.push(i);
}
}
let last = 0;
for (let i of range) {
if (last) {
if (i - last === 2) {
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
} else if (i - last !== 1) {
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
}
}
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
last = i;
}
// Next button
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">&raquo;</a></li>`;
html += '</ul>';
$('#feed-pagination').html(html);
}
$(document).on('click', '.page-link', function(e) {
e.preventDefault();
loadFeed($(this).data('page'));
});
$(document).on('click', '.btn-delete', function() {
const url = $(this).data('url');
StandardSwal.fire({
title: 'Delete this notification?',
text: 'This notification will be permanently removed from your history.',
icon: 'warning',
showCancelButton: true,
customClass: {
confirmButton: 'btn-pill-danger',
cancelButton: 'btn-pill-cancel'
},
confirmButtonText: 'Yes, Delete',
cancelButtonText: "Cancel",
}).then(result => {
if (result.isConfirmed) {
$.ajax({
url: url,
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'Notification deleted');
},
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
});
}
});
});
$('#page-mark-all-read').on('click', function() {
$.ajax({
url: "{{ route('notification-center.read-all') }}",
method: 'PATCH',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'All marked as read');
},
error: () => window.showNotificationToast('error', 'Process failed')
});
});
$('#page-clear-read').on('click', function() {
StandardSwal.fire({
title: "Clear all read notifications?",
text: "All read updates will be permanently purged from your feed.",
icon: 'warning',
showCancelButton: true,
customClass: {
confirmButton: 'btn-pill-danger',
cancelButton: 'btn-pill-cancel'
},
confirmButtonText: "Yes, Clear",
cancelButtonText: "Cancel",
}).then(result => {
if (result.isConfirmed) {
$.ajax({
url: "{{ route('notification-center.clear-read') }}",
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
success: (res) => {
window.reloadNotificationUI();
window.showNotificationToast('success', res.message || 'Read notifications cleared');
},
error: () => window.showNotificationToast('error', 'Clear failed')
});
}
});
});
// Listen for standard AJAX success to reload feed
$('#manualNotificationForm').on('ajaxForm:success', function() {
window.reloadNotificationUI();
if (notificationEditor) notificationEditor.setData('');
});
});
</script>
@endpush
</x-app-layout>
@@ -0,0 +1,246 @@
<x-app-layout>
<div id="session-manager-root" x-data="{
sessionDetail: { session: { user_name: '', user_email: '', ip_address: '', id: '' }, device: { browser: '', browser_icon: '', os: '', os_icon: '' }, time: '', is_current: false },
openSession(btn) {
try {
this.sessionDetail = JSON.parse(btn.dataset.activity);
const modalEl = document.getElementById('sessionDetailModal');
let modal = bootstrap.Modal.getInstance(modalEl);
if (!modal) {
modal = new bootstrap.Modal(modalEl);
}
modal.show();
} catch (e) {
console.error('Error opening session detail:', e);
window.StandardSwal.fire({ title: 'Error', text: 'Could not parse session data.', icon: 'error' });
}
},
async terminate() {
const sid = this.sessionDetail.session.id;
const url = `{{ route('session-manager.terminate', ':id') }}`.replace(':id', sid);
const r = await window.StandardSwal.fire({
title: 'Terminate Connection?',
text: 'Sever all encrypted links for this digital session?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, Terminate'
});
if (r.isConfirmed) {
const res = await fetch(url, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('sessionDetailModal')).hide();
window.reloadDataTable?.();
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
}
}
},
copyMetadata() {
navigator.clipboard.writeText(JSON.stringify(this.sessionDetail.session, null, 2));
window.StandardSwal.fire({ title: 'Copied!', text: 'Session metadata context copied.', icon: 'success', timer: 1500, showConfirmButton: false });
}
}">
<div class="container-fluid" id="main-content">
<div class="row gx-3 gx-lg-4">
<div class="col-12">
<div class="card adminuiux-card">
<div class="card-body p-0">
{{-- Header Section --}}
<div class="p-4 pb-0">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-0 fw-bold">{{ __('Session Manager') }}</h5>
<small class="text-muted">
{{ __('Real-time monitoring and management of authenticated user sessions.') }}
</small>
</div>
<button @click="window.reloadDataTable?.()" class="btn btn-outline-secondary btn-sm rounded-pill px-3">
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh') }}
</button>
</div>
</div>
{{-- Table Section --}}
<div class="p-4">
<div class="table-responsive">
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
data-server-side="true" data-ajax-url="{{ route('session-manager') }}"
data-order='@json([[4, "desc"]])'>
<thead>
<tr>
<th class="text-wrap">{{ __('Status') }}</th>
<th class="text-wrap">{{ __('Identity Profile') }}</th>
<th class="text-wrap">{{ __('Device') }}</th>
<th class="text-wrap">{{ __('IP Address') }}</th>
<th class="text-wrap">{{ __('Last Activity') }}</th>
<th class="text-wrap text-end" data-orderable="false" data-searchable="false">{{ __('Action') }}</th>
</tr>
{{-- Filter Row --}}
<tr class="filter-row">
<th>
<select class="form-select form-select-sm">
<option value="">{{ __('All') }}</option>
<option value="active">{{ __('Active') }}</option>
<option value="ended">{{ __('Idle') }}</option>
</select>
</th>
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Identity') }}"></th>
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Device') }}"></th>
<th><input class="form-control form-control-sm" placeholder="{{ __('IP Trace') }}"></th>
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Time') }}"></th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{-- Session Diagnostic Modal (Bootstrap) --}}
<div class="modal fade" id="sessionDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content rounded-4 border-0 shadow-lg">
<div class="modal-header border-bottom-0 p-4">
<div class="d-flex align-items-center gap-3">
<div>
<h5 class="modal-title fw-bold mb-0">{{ __('Session Diagnostics') }}</h5>
<small class="text-uppercase text-muted fw-black ls-1" style="font-size: 10px;">{{ __('End-to-End Security Audit') }}</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4 pt-0">
<div class="row g-4">
<div class="col-md-6">
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Identity Context') }}</label>
<div class="p-3 bg-light rounded-3 border">
<div class="fw-bold text-dark" x-text="sessionDetail.session.user_name || 'Guest Observer'"></div>
<div class="small text-muted" x-text="sessionDetail.session.user_email || 'anonymous'"></div>
</div>
</div>
<div class="col-md-6">
<label class="ls-1 text-uppercase text-muted fw-bold mb-2 d-block text-md-end" style="font-size: 10px;">{{ __('Temporal Sync') }}</label>
<div class="p-3 bg-light rounded-3 border text-md-end">
<div class="fw-bold text-dark" x-text="sessionDetail.time"></div>
<div class="small text-muted text-uppercase ls-1" style="font-size: 10px;">Last Pulse Detected</div>
</div>
</div>
<div class="col-12">
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Connected Node Details') }}</label>
<div class="row g-3">
<div class="col-md-4">
<div class="card card-body text-center py-4 rounded-4 border">
<i class="bi fs-2 mb-2" :class="sessionDetail.device.browser_icon"></i>
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.browser"></div>
</div>
</div>
<div class="col-md-4">
<div class="card card-body text-center py-4 rounded-4 border">
<i class="bi fs-2 mb-2" :class="sessionDetail.device.os_icon"></i>
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.os"></div>
</div>
</div>
<div class="col-md-4">
<div class="card card-body text-center py-4 rounded-4 border">
<i class="bi bi-broadcast fs-2 mb-2"></i>
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.session.ip_address"></div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="ls-1 text-uppercase text-muted fw-bold" style="font-size: 10px;">{{ __('Raw Metadata Payload') }}</label>
<a href="javascript:void(0)" @click="copyMetadata" class="text-decoration-none fw-bold ls-1 text-uppercase" style="font-size: 10px;">Copy manifest</a>
</div>
<div class="bg-dark rounded-4 p-4 shadow-inner">
<pre class="text-success m-0 fw-mono small overflow-auto no-scrollbar" style="max-height: 200px;" x-text="JSON.stringify(sessionDetail.session, null, 2)"></pre>
</div>
</div>
</div>
</div>
<div class="modal-footer border-top-0 p-4">
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">Close</button>
@can('manage active sessions')
<template x-if="!sessionDetail.is_current">
<button type="button" @click="terminate" class="btn btn-danger rounded-pill px-4 shadow-sm">Terminate Session</button>
</template>
@endcan
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
document.addEventListener("DOMContentLoaded", () => {
const tableElement = $('#datatables');
window.reloadDataTable = () => tableElement.DataTable().ajax.reload(null, false);
$(document).on("click", ".btn-detail-session", function() {
const root = document.getElementById('session-manager-root');
if (root && window.Alpine) {
const alpine = window.Alpine.$data(root);
alpine.openSession(this);
}
});
$(document).on("click", ".btn-terminate-session", async function() {
const id = $(this).data('id');
const url = $(this).data('url');
const r = await window.StandardSwal.fire({
title: 'Terminate Connection?',
text: 'Sever all encrypted links for this digital session?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, Terminate'
});
if (r.isConfirmed) {
const res = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' } });
const data = await res.json();
if (data.success) {
window.reloadDataTable();
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
}
}
});
tableElement.on('draw.dt', function() {
tableElement.find('tbody tr').each(function() {
const rowData = tableElement.DataTable().row(this).data();
if (rowData && rowData[6] === true) {
$(this).addClass('table-success-light');
}
});
});
});
</script>
<style>
.table-success-light { background-color: rgba(25, 135, 84, 0.05) !important; border-left: 4px solid #198754 !important; }
.ls-1 { letter-spacing: 0.1em; }
.fw-black { font-weight: 900; }
.extra-small { font-size: 10px; }
pre::-webkit-scrollbar { width: 4px; height: 4px; }
pre::-webkit-scrollbar-thumb { background: rgba(25, 135, 84, 0.3); border-radius: 4px; }
</style>
@endpush
</x-app-layout>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff