diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php
index 8237f9011..51280de72 100644
--- a/app/Http/Middleware/ContentSecurityPolicy.php
+++ b/app/Http/Middleware/ContentSecurityPolicy.php
@@ -36,13 +36,14 @@ class ContentSecurityPolicy
// Build CSP directives for non-Turnstile pages
$directives = [
"default-src 'self'",
- "script-src 'self' 'nonce-{$nonce}' 'unsafe-eval' 'unsafe-hashes' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com blob:",
+ "script-src 'self' 'nonce-{$nonce}' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com blob:",
"script-src-elem 'self' 'nonce-{$nonce}' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com",
- "script-src-attr 'unsafe-inline'",
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/",
- "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com/ data:",
+ "script-src-attr 'none'",
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud",
+ "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud",
+ "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud data:",
"img-src 'self' data: https: blob:",
- "connect-src 'self' https://www.google.com",
+ "connect-src 'self' https://www.google.com https://cdn.tiny.cloud https://sp.tinymce.com",
"frame-src 'self' https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com data: blob:",
"child-src 'self' https://www.google.com https://challenges.cloudflare.com blob:",
"worker-src 'self' blob:",
diff --git a/app/Models/UsenetGroup.php b/app/Models/UsenetGroup.php
index 27747c7bc..381dec5bb 100644
--- a/app/Models/UsenetGroup.php
+++ b/app/Models/UsenetGroup.php
@@ -396,16 +396,16 @@ class UsenetGroup extends Model
$res->where('groups_id', $id);
}
- $res->get();
+ $releases = $res->get();
$releaseManagement = app(\App\Services\Releases\ReleaseManagementService::class);
$nzb = app(NzbService::class);
$releaseImage = new ReleaseImageService;
- foreach ($res as $row) {
+ foreach ($releases as $row) {
$releaseManagement->deleteSingleWithService(
[
- 'g' => $row['guid'],
- 'i' => $row['id'],
+ 'g' => $row->guid,
+ 'i' => $row->id,
],
$nzb,
$releaseImage
diff --git a/package.json b/package.json
index 9f87635eb..d1d219646 100644
--- a/package.json
+++ b/package.json
@@ -19,10 +19,10 @@
"vue": "^3.5.24"
},
"dependencies": {
+ "@alpinejs/csp": "^3.15.8",
"@fortawesome/fontawesome-free": "^6.6.0",
"@melloware/coloris": "^0.24.2",
"@simonwep/pickr": "^1.9.1",
- "alpinejs": "^3.15.2",
"date-fns": "^3.6.0",
"feather-icons": "^4.29.2",
"laravel-echo": "^2.2.6",
diff --git a/resources/css/app.css b/resources/css/app.css
index 154e1dcd4..d76b17715 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -21,6 +21,11 @@
/* Import custom CSP-safe styles */
@import './csp-safe.css';
+/* Alpine.js x-cloak - hide elements until Alpine initializes */
+[x-cloak] {
+ display: none !important;
+}
+
/* Fixed layout - ensure html and body have proper height constraints */
html, body {
height: 100%;
diff --git a/resources/js/alpine/components/admin-submenu.js b/resources/js/alpine/components/admin-submenu.js
new file mode 100644
index 000000000..24b1761e6
--- /dev/null
+++ b/resources/js/alpine/components/admin-submenu.js
@@ -0,0 +1,12 @@
+/**
+ * Alpine.data('adminSubmenu') - Admin sidebar submenu toggle
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('adminSubmenu', () => ({
+ open: false,
+
+ toggle() {
+ this.open = !this.open;
+ }
+}));
diff --git a/resources/js/alpine/components/admin/dashboard.js b/resources/js/alpine/components/admin/dashboard.js
new file mode 100644
index 000000000..93ece8f09
--- /dev/null
+++ b/resources/js/alpine/components/admin/dashboard.js
@@ -0,0 +1,103 @@
+/**
+ * Alpine.data('adminDashboard') - Admin dashboard charts (Chart.js)
+ * Alpine.data('recentActivity') - Auto-refreshing recent activity
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+Alpine.data('adminDashboard', () => ({
+ init() {
+ this._waitForChartJs();
+ },
+
+ _waitForChartJs() {
+ if (typeof Chart !== 'undefined') { this._initCharts(); return; }
+ let attempts = 0;
+ const check = setInterval(() => {
+ attempts++;
+ if (typeof Chart !== 'undefined') { clearInterval(check); this._initCharts(); }
+ else if (attempts >= 30) { clearInterval(check); console.warn('Chart.js not loaded'); }
+ }, 100);
+ },
+
+ _initCharts() {
+ this._initChart('downloadsChart', 'bar', 'Downloads', 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false);
+ this._initChart('apiHitsChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
+ this._initChart('downloadsMinuteChart', 'line', 'Downloads', 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true);
+ this._initChart('apiHitsMinuteChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
+ this._initPercentChart('cpuHistory24hChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
+ this._initPercentChart('ramHistory24hChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
+ this._initPercentChart('cpuHistory30dChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
+ this._initPercentChart('ramHistory30dChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
+ },
+
+ _initChart(canvasId, type, label, bg, border, fill) {
+ const canvas = document.getElementById(canvasId);
+ if (!canvas) return;
+ const data = JSON.parse(canvas.dataset.chartData || canvas.dataset.history || '[]');
+ if (data.length === 0) return;
+ new Chart(canvas, {
+ type, data: {
+ labels: data.map(i => i.time),
+ datasets: [{ label, data: data.map(i => i.count), backgroundColor: bg, borderColor: border, borderWidth: type === 'bar' ? 1 : 2, fill, tension: 0.4 }]
+ },
+ options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }, plugins: { legend: { display: false } } }
+ });
+ },
+
+ _initPercentChart(canvasId, label, bg, border) {
+ const canvas = document.getElementById(canvasId);
+ if (!canvas) return;
+ const data = JSON.parse(canvas.dataset.history || '[]');
+ if (data.length === 0) return;
+ new Chart(canvas, {
+ type: 'line', data: {
+ labels: data.map(i => i.time),
+ datasets: [{ label, data: data.map(i => i.value), backgroundColor: bg, borderColor: border, borderWidth: 2, fill: true, tension: 0.4 }]
+ },
+ options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } } }, plugins: { legend: { display: false } } }
+ });
+ }
+}));
+
+Alpine.data('recentActivity', () => ({
+ _interval: null,
+
+ init() {
+ const url = this.$el.dataset.refreshUrl;
+ if (!url) return;
+ this._interval = setInterval(() => this._refresh(url), 20 * 60 * 1000);
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) this._refresh(url); });
+ },
+
+ _refresh(url) {
+ fetch(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin' })
+ .then(r => { if (!r.ok) throw new Error('fail'); return r.json(); })
+ .then(data => {
+ if (!data.success || !data.activities) return;
+ this.$el.innerHTML = '';
+ if (data.activities.length === 0) {
+ this.$el.innerHTML = '
No recent activity
';
+ return;
+ }
+ data.activities.forEach(a => {
+ const div = document.createElement('div');
+ div.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors';
+ div.innerHTML = '
' + escapeHtml(a.message) + '
' + escapeHtml(a.created_at) + '
';
+ this.$el.appendChild(div);
+ });
+ const ts = document.getElementById('activity-last-updated');
+ if (ts) ts.innerHTML = ' Last updated: ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ })
+ .catch(() => {});
+ },
+
+ destroy() {
+ if (this._interval) clearInterval(this._interval);
+ }
+}));
diff --git a/resources/js/alpine/components/admin/features.js b/resources/js/alpine/components/admin/features.js
new file mode 100644
index 000000000..85bfced78
--- /dev/null
+++ b/resources/js/alpine/components/admin/features.js
@@ -0,0 +1,431 @@
+/**
+ * Admin feature components: TinyMCE, user edit, verify modal, scroll sync, etc.
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+}
+
+// TinyMCE editor (loads from CDN, remains imperative)
+Alpine.data('tinyMceEditor', () => ({
+ init() {
+ if (!document.getElementById('body') && !document.querySelector('.tinymce-editor')) return;
+ this._loadAndInit();
+ },
+
+ _loadAndInit() {
+ if (typeof tinymce !== 'undefined') { this._doInit(); return; }
+ const body = document.getElementById('body');
+ const editors = document.querySelectorAll('.tinymce-editor');
+ let apiKey = 'no-api-key';
+ const meta = document.querySelector('meta[name="tinymce-api-key"]');
+ if (meta) { apiKey = meta.content; }
+ else if (window.NNTmuxConfig?.tinymceApiKey) apiKey = window.NNTmuxConfig.tinymceApiKey;
+ else if (body?.dataset.tinymceApiKey) apiKey = body.dataset.tinymceApiKey;
+ else { for (let e of editors) { if (e.dataset.tinymceApiKey) { apiKey = e.dataset.tinymceApiKey; break; } } }
+
+ const script = document.createElement('script');
+ script.src = 'https://cdn.tiny.cloud/1/' + apiKey + '/tinymce/8/tinymce.min.js';
+ script.referrerPolicy = 'origin';
+ script.onload = () => this._doInit();
+ script.onerror = () => {
+ document.querySelectorAll('#body, .tinymce-editor').forEach(ta => {
+ if (ta.parentElement) {
+ const err = document.createElement('div');
+ err.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded';
+ err.innerHTML = ' TinyMCE editor failed to load.';
+ ta.parentElement.insertBefore(err, ta.nextSibling);
+ }
+ });
+ };
+ document.head.appendChild(script);
+ },
+
+ _doInit() {
+ const dark = document.documentElement.classList.contains('dark');
+ tinymce.init({
+ selector: '#body, .tinymce-editor', height: 500, menubar: true,
+ skin: dark ? 'oxide-dark' : 'oxide', content_css: dark ? 'dark' : 'default',
+ plugins: ['advlist','autolink','lists','link','image','charmap','preview','anchor','searchreplace','visualblocks','code','fullscreen','insertdatetime','media','table','help','wordcount','emoticons'],
+ toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table emoticons | removeformat code fullscreen | help',
+ toolbar_mode: 'sliding', content_style: 'body { font-family: Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.6; }',
+ branding: false, promotion: false, resize: true, statusbar: true, valid_elements: '*[*]', extended_valid_elements: '*[*]',
+ setup: function(editor) { editor.on('change blur keyup submit', function() { editor.save(); }); }
+ });
+
+ // Watch dark mode changes to reinit TinyMCE
+ new MutationObserver(() => {
+ if (!tinymce.editors.length) return;
+ const contents = {};
+ tinymce.editors.forEach(e => { contents[e.id] = e.getContent(); });
+ tinymce.remove();
+ const d = document.documentElement.classList.contains('dark');
+ tinymce.init({
+ selector: '#body, .tinymce-editor', height: 500, menubar: true,
+ skin: d ? 'oxide-dark' : 'oxide', content_css: d ? 'dark' : 'default',
+ plugins: ['advlist','autolink','lists','link','image','charmap','preview','anchor','searchreplace','visualblocks','code','fullscreen','insertdatetime','media','table','help','wordcount','emoticons'],
+ toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table emoticons | removeformat code fullscreen | help',
+ toolbar_mode: 'sliding', branding: false, promotion: false, valid_elements: '*[*]', extended_valid_elements: '*[*]',
+ setup: function(editor) { editor.on('change blur keyup submit', function() { editor.save(); }); }
+ }).then(eds => { eds.forEach(e => { if (contents[e.id]) e.setContent(contents[e.id]); }); });
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+ }
+}));
+
+// User Edit datetime picker
+Alpine.data('adminUserEdit', () => ({
+ init() {
+ if (!document.getElementById('rolechangedate')) return;
+ this._initDateTime();
+ this._setupExpiry();
+ },
+
+ _initDateTime() {
+ const hidden = document.getElementById('rolechangedate');
+ if (!hidden?.value) return;
+ const d = new Date(hidden.value);
+ if (isNaN(d.getTime())) return;
+ this._setSelects(d);
+ this._updatePreview();
+ },
+
+ _setSelects(d) {
+ const ids = ['expiry_year','expiry_month','expiry_day','expiry_hour','expiry_minute'];
+ const vals = [d.getFullYear().toString(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0'), String(d.getHours()).padStart(2,'0'), String(d.getMinutes()).padStart(2,'0')];
+ ids.forEach((id, i) => { const el = document.getElementById(id); if (el) el.value = vals[i]; });
+ },
+
+ updateDateTime() {
+ const y = document.getElementById('expiry_year')?.value;
+ const mo = document.getElementById('expiry_month')?.value;
+ const d = document.getElementById('expiry_day')?.value;
+ const h = document.getElementById('expiry_hour')?.value;
+ const mi = document.getElementById('expiry_minute')?.value;
+ if (y && mo && d && h && mi) {
+ document.getElementById('rolechangedate').value = y + '-' + mo + '-' + d + 'T' + h + ':' + mi + ':00';
+ this._updatePreview();
+ }
+ },
+
+ updateValidDays() {
+ const y = parseInt(document.getElementById('expiry_year')?.value);
+ const m = parseInt(document.getElementById('expiry_month')?.value);
+ const daySelect = document.getElementById('expiry_day');
+ if (!y || !m || !daySelect) return;
+ const days = new Date(y, m, 0).getDate();
+ const cur = parseInt(daySelect.value);
+ daySelect.innerHTML = '-- ';
+ for (let i = 1; i <= days; i++) { const o = document.createElement('option'); o.value = String(i).padStart(2,'0'); o.textContent = i; daySelect.appendChild(o); }
+ if (cur && cur <= days) daySelect.value = String(cur).padStart(2,'0');
+ },
+
+ setExpiry(days, hours) {
+ let base;
+ const y = document.getElementById('expiry_year')?.value;
+ const mo = document.getElementById('expiry_month')?.value;
+ const d = document.getElementById('expiry_day')?.value;
+ const h = document.getElementById('expiry_hour')?.value;
+ const mi = document.getElementById('expiry_minute')?.value;
+ const orig = document.getElementById('original_user_expiry')?.value;
+ if (y && mo && d && h && mi) base = new Date(y, parseInt(mo)-1, d, h, mi);
+ else if (orig) base = new Date(orig);
+ else base = new Date();
+ base.setDate(base.getDate() + days);
+ base.setHours(base.getHours() + hours);
+ this._setSelects(base);
+ this.updateValidDays();
+ this.updateDateTime();
+ },
+
+ setEndOfDay() {
+ const d = new Date(); d.setHours(23,59,0,0);
+ this._setSelects(d);
+ this.updateValidDays();
+ this.updateDateTime();
+ },
+
+ clearExpiry() {
+ ['expiry_year','expiry_month','expiry_day','expiry_hour','expiry_minute'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
+ document.getElementById('rolechangedate').value = '';
+ const p = document.getElementById('datetime_preview'); if (p) p.classList.add('hidden');
+ },
+
+ _setupExpiry() {
+ ['expiry_year','expiry_month','expiry_day','expiry_hour','expiry_minute'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.addEventListener('change', () => this.updateDateTime());
+ });
+ const y = document.getElementById('expiry_year'), m = document.getElementById('expiry_month');
+ if (y) y.addEventListener('change', () => this.updateValidDays());
+ if (m) m.addEventListener('change', () => this.updateValidDays());
+ },
+
+ _updatePreview() {
+ const hidden = document.getElementById('rolechangedate');
+ const preview = document.getElementById('datetime_preview');
+ const display = document.getElementById('datetime_display');
+ if (!hidden?.value || !preview || !display) { if (preview) preview.classList.add('hidden'); return; }
+ const d = new Date(hidden.value);
+ display.textContent = d.toLocaleDateString('en-US', { weekday:'short', year:'numeric', month:'long', day:'numeric', hour:'2-digit', minute:'2-digit' });
+ preview.classList.remove('hidden');
+ const diff = d - new Date();
+ display.classList.remove('text-red-600','dark:text-red-400','text-yellow-600','dark:text-yellow-400','text-blue-600','dark:text-blue-400');
+ if (diff < 0) display.classList.add('text-red-600','dark:text-red-400');
+ else if (diff < 7*24*60*60*1000) display.classList.add('text-yellow-600','dark:text-yellow-400');
+ else display.classList.add('text-blue-600','dark:text-blue-400');
+ }
+}));
+
+// Verify User Modal
+Alpine.data('verifyUser', () => ({
+ open: false,
+ _form: null,
+
+ show(form) { this._form = form; this.open = true; },
+ hide() { this.open = false; this._form = null; },
+ submit() { if (this._form) this._form.submit(); this.hide(); },
+
+ init() {
+ const self = this;
+ window.showVerifyModal = function(e, form) { e.preventDefault(); self.show(form); };
+ window.hideVerifyModal = function() { self.hide(); };
+ window.submitVerifyForm = function() { self.submit(); };
+ }
+}));
+
+// User List Scroll Sync
+Alpine.data('adminUserList', () => ({
+ init() {
+ const top = document.getElementById('topScroll');
+ const bottom = document.getElementById('bottomScroll');
+ const content = document.getElementById('topScrollContent');
+ const table = bottom?.querySelector('table');
+ if (!top || !bottom || !content || !table) return;
+
+ const sync = () => { content.style.width = table.scrollWidth + 'px'; };
+ sync();
+ window.addEventListener('resize', sync);
+ top.addEventListener('scroll', function() { if (!top._syncing) { bottom._syncing = true; bottom.scrollLeft = top.scrollLeft; bottom._syncing = false; } });
+ bottom.addEventListener('scroll', function() { if (!bottom._syncing) { top._syncing = true; top.scrollLeft = bottom.scrollLeft; top._syncing = false; } });
+ }
+}));
+
+// Admin deleted users
+Alpine.data('adminDeletedUsers', () => ({
+ allChecked: false,
+
+ toggleAll() {
+ this.$el.querySelectorAll('.user-checkbox').forEach(cb => { cb.checked = this.allChecked; });
+ },
+
+ onCheckboxChange() {
+ const all = this.$el.querySelectorAll('.user-checkbox');
+ const checked = this.$el.querySelectorAll('.user-checkbox:checked');
+ this.allChecked = all.length > 0 && checked.length === all.length;
+ },
+
+ submitBulkAction(e) {
+ e.preventDefault();
+ const action = this.$el.querySelector('#bulkAction')?.value;
+ const checked = this.$el.querySelectorAll('.user-checkbox:checked');
+ const errEl = document.getElementById('validationError');
+ const errMsg = document.getElementById('validationErrorMessage');
+ if (errEl) errEl.classList.add('hidden');
+
+ if (!action) { if (errEl && errMsg) { errMsg.textContent = 'Please select an action.'; errEl.classList.remove('hidden'); } return; }
+ if (checked.length === 0) { if (errEl && errMsg) { errMsg.textContent = 'Please select at least one user.'; errEl.classList.remove('hidden'); } return; }
+
+ const text = action === 'restore' ? 'restore' : 'permanently delete';
+ const type = action === 'restore' ? 'success' : 'danger';
+ const form = this.$el.querySelector('#bulkActionForm');
+
+ showConfirm({
+ title: action === 'restore' ? 'Restore Users' : 'Delete Users',
+ message: 'Are you sure you want to ' + text + ' ' + checked.length + ' user(s)?',
+ type: type,
+ confirmText: action === 'restore' ? 'Restore' : 'Delete',
+ onConfirm: function() { if (form) form.submit(); }
+ });
+ },
+
+ restoreUser(userId, username) {
+ showConfirm({ title: 'Restore User', message: "Are you sure you want to restore user '" + username + "'?", type: 'success', confirmText: 'Restore', onConfirm: function() {
+ const form = document.getElementById('individualActionForm');
+ if (form) { form.action = window.location.origin + '/admin/deleted-users/restore/' + userId; form.submit(); }
+ }});
+ },
+
+ deleteUser(userId, username) {
+ showConfirm({ title: 'Permanently Delete User', message: "Are you sure you want to PERMANENTLY delete user '" + username + "'?", details: 'This action cannot be undone!', type: 'danger', confirmText: 'Delete Permanently', onConfirm: function() {
+ const form = document.getElementById('individualActionForm');
+ if (form) { form.action = window.location.origin + '/admin/deleted-users/permanent-delete/' + userId; form.submit(); }
+ }});
+ }
+}));
+
+// Invitations select all
+Alpine.data('adminInvitations', () => ({
+ allChecked: false,
+ toggleAll() { this.$el.querySelectorAll('.invitation-checkbox').forEach(cb => { cb.checked = this.allChecked; }); },
+ onCheckboxChange() {
+ const all = this.$el.querySelectorAll('.invitation-checkbox');
+ const checked = this.$el.querySelectorAll('.invitation-checkbox:checked');
+ this.allChecked = all.length > 0 && checked.length === all.length;
+ }
+}));
+
+// Regex form validation
+Alpine.data('adminRegexForm', () => ({
+ validate(e) {
+ const inputs = this.$el.querySelectorAll('input[name*="regex"], textarea[name*="regex"]');
+ let valid = true;
+ inputs.forEach(input => {
+ const val = input.value.trim();
+ if (val && input.hasAttribute('required')) {
+ const delims = ['/','/','#','~','%','@','!'];
+ if (delims.includes(val[0])) {
+ let found = false;
+ for (let i = 1; i < val.length; i++) { if (val[i] === val[0]) { found = true; break; } }
+ if (!found) { valid = false; input.classList.add('border-red-500'); }
+ else input.classList.remove('border-red-500');
+ }
+ }
+ });
+ if (!valid) { e.preventDefault(); showToast('Please fix regex validation errors', 'error'); }
+ }
+}));
+
+// Tmux crap types toggle
+Alpine.data('tmuxEdit', () => ({
+ customCrap: false,
+ init() {
+ const checked = document.querySelector('input[name="fix_crap_opt"]:checked');
+ if (checked) this.customCrap = checked.value === 'Custom';
+ },
+ setCrapOpt(value) { this.customCrap = value === 'Custom'; }
+}));
+
+// Image fallbacks (global)
+Alpine.data('imageFallback', () => ({
+ onError(e) {
+ const fallback = e.target.getAttribute('data-fallback-src');
+ if (fallback && e.target.src !== fallback) e.target.src = fallback;
+ }
+}));
+
+// Select redirect
+Alpine.data('selectRedirect', () => ({
+ onChange(e) {
+ const url = e.target.value;
+ if (url && url !== '#') window.location.href = url;
+ }
+}));
+
+// Confirm delete (link or form)
+Alpine.data('confirmAction', () => ({
+ confirmDelete(e, message) {
+ e.preventDefault();
+ e.stopPropagation();
+ const el = e.currentTarget;
+ const form = el.closest('form');
+ showConfirm({
+ message: message || 'Are you sure you want to delete this item?',
+ type: 'danger',
+ confirmText: 'Delete',
+ onConfirm: function() { if (form) form.submit(); else if (el.href) window.location.href = el.href; }
+ });
+ }
+}));
+
+// Download NZB toast
+Alpine.data('downloadNzb', () => ({
+ onClick() { showToast('Downloading NZB...', 'success'); }
+}));
+
+// Logout
+Alpine.data('logout', () => ({
+ submit(e) {
+ e.preventDefault();
+ const form = document.getElementById('logout-form') || document.getElementById('sidebar-logout-form');
+ if (form) form.submit();
+ }
+}));
+
+// Promotion toggle/delete
+Alpine.data('promotionAction', () => ({
+ togglePromotion(e, name, isActive, href) {
+ e.preventDefault();
+ const action = isActive ? 'deactivate' : 'activate';
+ showConfirm({
+ title: action.charAt(0).toUpperCase() + action.slice(1) + ' Promotion',
+ message: 'Are you sure you want to ' + action + ' the promotion "' + name + '"?',
+ type: isActive ? 'warning' : 'success',
+ confirmText: action.charAt(0).toUpperCase() + action.slice(1),
+ onConfirm: function() { window.location.href = href; }
+ });
+ },
+ deletePromotion(e, name) {
+ e.preventDefault();
+ const form = e.currentTarget.closest('form');
+ showConfirm({
+ title: 'Delete Promotion',
+ message: 'Are you sure you want to delete the promotion "' + name + '"?',
+ details: 'This action cannot be undone.',
+ type: 'danger',
+ confirmText: 'Delete',
+ onConfirm: function() { if (form) form.submit(); }
+ });
+ }
+}));
+
+// Binary blacklist
+Alpine.data('binaryBlacklist', () => ({
+ deleteBlacklist(e, id) {
+ e.preventDefault();
+ if (confirm('Are you sure? This will delete the blacklist from this list.')) {
+ if (typeof ajax_binaryblacklist_delete === 'function') ajax_binaryblacklist_delete(id);
+ }
+ }
+}));
+
+// Regex delete
+Alpine.data('regexDelete', () => ({
+ deleteRegex(e, id, deleteUrl) {
+ e.preventDefault();
+ if (confirm('Are you sure you want to delete this regex? This action cannot be undone.')) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch(deleteUrl + '?id=' + id, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf } })
+ .then(r => r.json())
+ .then(d => {
+ if (d.success) { const row = document.getElementById('row-' + id); if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 300); } showToast('Regex deleted successfully', 'success'); }
+ else showToast('Error deleting regex', 'error');
+ }).catch(() => showToast('Error deleting regex', 'error'));
+ }
+ }
+}));
+
+// Release delete
+Alpine.data('releaseDelete', () => ({
+ deleteRelease(e, id, deleteUrl) {
+ e.preventDefault();
+ const el = e.currentTarget;
+ showConfirm({ title: 'Delete Release', message: 'Are you sure you want to delete this release? This action cannot be undone.', type: 'danger', confirmText: 'Delete', onConfirm: function() {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ showToast('Deleting release...', 'info');
+ fetch(deleteUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf, 'Content-Type': 'application/json', 'Accept': 'application/json' } })
+ .then(r => {
+ if (r.ok) { const row = el.closest('tr'); if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 300); } showToast('Release deleted successfully', 'success'); }
+ else showToast('Error deleting release: ' + r.status, 'error');
+ }).catch(err => showToast('Error deleting release: ' + err.message, 'error'));
+ }});
+ }
+}));
+
+// My Movies confirm
+Alpine.data('myMovies', () => ({
+ confirmRemove(e) {
+ if (!confirm('Are you sure you want to remove this movie from your watchlist?')) e.preventDefault();
+ }
+}));
diff --git a/resources/js/alpine/components/admin/groups.js b/resources/js/alpine/components/admin/groups.js
new file mode 100644
index 000000000..912235acf
--- /dev/null
+++ b/resources/js/alpine/components/admin/groups.js
@@ -0,0 +1,173 @@
+/**
+ * Alpine.data('adminGroups') - Admin groups management
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+}
+
+Alpine.data('adminGroups', () => ({
+ resetAllOpen: false,
+ purgeAllOpen: false,
+ resetSelectedOpen: false,
+ selectedGroupNames: [],
+ allChecked: false,
+
+ init() {
+ const container = this.$el.querySelector('[data-ajax-url]') || this.$el;
+ this._ajaxUrl = container.dataset.ajaxUrl || '/admin/ajax';
+ this._csrf = container.dataset.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content;
+
+ // Backward compat
+ const self = this;
+ window.ajax_group_status = function(id, s) { self._toggleStatus(id, s); };
+ window.ajax_backfill_status = function(id, s) { self._toggleBackfill(id, s); };
+ window.ajax_group_reset = function(id) { self._resetGroup(id); };
+ window.confirmGroupDelete = function(id) { self._deleteGroup(id); };
+ window.confirmGroupPurge = function(id) { self._purgeGroup(id); };
+ window.ajax_group_reset_all = function() { self._resetAll(); };
+ window.ajax_group_purge_all = function() { self._purgeAll(); };
+ window.ajax_group_reset_selected = function() { self._resetSelected(); };
+ window.showResetAllModal = function() { self.resetAllOpen = true; };
+ window.hideResetAllModal = function() { self.resetAllOpen = false; };
+ window.showPurgeAllModal = function() { self.purgeAllOpen = true; };
+ window.hidePurgeAllModal = function() { self.purgeAllOpen = false; };
+ window.showResetSelectedModal = function() { self._showResetSelected(); };
+ window.hideResetSelectedModal = function() { self.resetSelectedOpen = false; };
+ window.toggleSelectAllGroups = function(cb) { self._toggleSelectAll(cb); };
+ window.getSelectedGroups = function() { return self._getSelected(); };
+ window.updateSelectionUI = function() { self._updateSelectionUI(); };
+ },
+
+ handleAction(action, groupId, status) {
+ switch (action) {
+ case 'toggle-group-status': this._toggleStatus(groupId, status); break;
+ case 'toggle-backfill': this._toggleBackfill(groupId, status); break;
+ case 'reset-group': this._resetGroup(groupId); break;
+ case 'delete-group': this._deleteGroup(groupId); break;
+ case 'purge-group': this._purgeGroup(groupId); break;
+ case 'reset-all': this._resetAll(); break;
+ case 'purge-all': this._purgeAll(); break;
+ case 'reset-selected': this._resetSelected(); break;
+ case 'show-reset-modal': this.resetAllOpen = true; break;
+ case 'hide-reset-modal': this.resetAllOpen = false; break;
+ case 'show-purge-modal': this.purgeAllOpen = true; break;
+ case 'hide-purge-modal': this.purgeAllOpen = false; break;
+ case 'show-reset-selected-modal': this._showResetSelected(); break;
+ case 'hide-reset-selected-modal': this.resetSelectedOpen = false; break;
+ case 'select-all-groups': break; // handled via x-model
+ }
+ },
+
+ toggleAllCheckboxes() {
+ const boxes = this.$el.querySelectorAll('.group-checkbox');
+ boxes.forEach(cb => { cb.checked = this.allChecked; });
+ this._updateSelectionUI();
+ },
+
+ onGroupCheckboxChange() {
+ this._updateSelectionUI();
+ },
+
+ _post(body) {
+ return fetch(this._ajaxUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-TOKEN': this._csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: new URLSearchParams(body)
+ }).then(r => r.json());
+ },
+
+ _toggleStatus(id, status) {
+ this._post({ action: 'toggle_group_active_status', group_id: id, group_status: status }).then(data => {
+ if (data.success) {
+ const cell = document.getElementById('group-' + id);
+ if (cell && data.newStatus !== undefined) {
+ const active = data.newStatus == 1;
+ cell.innerHTML = active
+ ? ' Active '
+ : ' Inactive ';
+ }
+ showToast(data.message || 'Group status updated', 'success');
+ } else showToast(data.message || 'Error', 'error');
+ }).catch(() => showToast('Error updating group status', 'error'));
+ },
+
+ _toggleBackfill(id, status) {
+ this._post({ action: 'toggle_group_backfill', group_id: id, backfill: status }).then(data => {
+ if (data.success) {
+ const cell = document.getElementById('backfill-' + id);
+ if (cell && data.newStatus !== undefined) {
+ const en = data.newStatus == 1;
+ cell.innerHTML = en
+ ? ' Enabled '
+ : ' Disabled ';
+ }
+ showToast(data.message || 'Backfill status updated', 'success');
+ } else showToast(data.message || 'Error', 'error');
+ }).catch(() => showToast('Error updating backfill status', 'error'));
+ },
+
+ _resetGroup(id) {
+ showConfirm({ title: 'Reset Group', message: 'Are you sure you want to reset this group?', details: 'This will reset the article pointers back to the current state.', type: 'warning', confirmText: 'Reset', cancelText: 'Cancel', onConfirm: () => {
+ this._post({ action: 'reset_group', group_id: id }).then(d => showToast(d.message || (d.success ? 'Group reset' : 'Error'), d.success ? 'success' : 'error')).catch(() => showToast('Error', 'error'));
+ }});
+ },
+
+ _deleteGroup(id) {
+ showConfirm({ title: 'Delete Group', message: 'Are you sure you want to delete this group?', details: 'This action cannot be undone.', type: 'danger', confirmText: 'Delete', cancelText: 'Cancel', onConfirm: () => {
+ this._post({ action: 'delete_group', group_id: id }).then(d => {
+ if (d.success) { const row = document.getElementById('grouprow-' + id); if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 300); } }
+ showToast(d.message || (d.success ? 'Deleted' : 'Error'), d.success ? 'success' : 'error');
+ }).catch(() => showToast('Error', 'error'));
+ }});
+ },
+
+ _purgeGroup(id) {
+ showConfirm({ title: 'Purge Group', message: 'Are you sure you want to purge this group?', details: 'This will delete all releases and binaries. Cannot be undone!', type: 'danger', confirmText: 'Purge', cancelText: 'Cancel', onConfirm: () => {
+ this._post({ action: 'purge_group', group_id: id }).then(d => showToast(d.message || (d.success ? 'Purged' : 'Error'), d.success ? 'success' : 'error')).catch(() => showToast('Error', 'error'));
+ }});
+ },
+
+ _resetAll() { this.resetAllOpen = false; this._post({ action: 'reset_all_groups' }).then(d => showToast(d.message || 'Done', d.success ? 'success' : 'error')).catch(() => showToast('Error', 'error')); },
+ _purgeAll() { this.purgeAllOpen = false; this._post({ action: 'purge_all_groups' }).then(d => showToast(d.message || 'Done', d.success ? 'success' : 'error')).catch(() => showToast('Error', 'error')); },
+
+ _getSelected() {
+ return Array.from(this.$el.querySelectorAll('.group-checkbox:checked')).map(cb => ({ id: cb.dataset.groupId, name: cb.dataset.groupName }));
+ },
+
+ _showResetSelected() {
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('No groups selected', 'warning'); return; }
+ this.selectedGroupNames = selected.map(g => g.name);
+ this.resetSelectedOpen = true;
+ },
+
+ _resetSelected() {
+ this.resetSelectedOpen = false;
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('No groups selected', 'warning'); return; }
+ this._post({ action: 'reset_selected_groups', group_ids: JSON.stringify(selected.map(g => g.id)) }).then(d => {
+ if (d.success) { this.$el.querySelectorAll('.group-checkbox:checked').forEach(cb => { cb.checked = false; }); this.allChecked = false; this._updateSelectionUI(); }
+ showToast(d.message || 'Done', d.success ? 'success' : 'error');
+ }).catch(() => showToast('Error', 'error'));
+ },
+
+ _toggleSelectAll(cb) { this.allChecked = cb.checked; this.toggleAllCheckboxes(); },
+
+ _updateSelectionUI() {
+ const selected = this._getSelected();
+ const counter = document.getElementById('selection-counter');
+ const countSpan = document.getElementById('selected-count');
+ const resetBtn = document.getElementById('reset-selected-btn');
+ if (counter && countSpan) { if (selected.length > 0) { counter.classList.remove('hidden'); countSpan.textContent = selected.length; } else counter.classList.add('hidden'); }
+ if (resetBtn) { if (selected.length > 0) resetBtn.classList.remove('hidden'); else resetBtn.classList.add('hidden'); }
+ const all = this.$el.querySelectorAll('.group-checkbox');
+ const allC = Array.from(all).every(cb => cb.checked);
+ const someC = Array.from(all).some(cb => cb.checked);
+ this.allChecked = allC;
+ const selAll = document.getElementById('select-all-groups');
+ if (selAll) { selAll.checked = allC; selAll.indeterminate = someC && !allC; }
+ }
+}));
diff --git a/resources/js/alpine/components/auth-page.js b/resources/js/alpine/components/auth-page.js
new file mode 100644
index 000000000..67f93f812
--- /dev/null
+++ b/resources/js/alpine/components/auth-page.js
@@ -0,0 +1,40 @@
+/**
+ * Alpine.data('authPage') - Login/register page features
+ * Alpine.data('otpInput') - 2FA OTP auto-submit
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('authPage', () => ({
+ init() {
+ // Auto-hide success messages after 5 seconds on login page
+ if (window.location.pathname.includes('/login')) {
+ setTimeout(() => {
+ this.$el.querySelectorAll('.bg-green-50, .bg-blue-50').forEach(alert => {
+ alert.style.transition = 'opacity 0.5s ease-out';
+ alert.style.opacity = '0';
+ setTimeout(() => alert.remove(), 500);
+ });
+ }, 5000);
+ }
+ }
+}));
+
+Alpine.data('otpInput', () => ({
+ value: '',
+
+ onInput() {
+ // Remove non-numeric
+ this.value = this.value.replace(/[^0-9]/g, '');
+ // Auto-submit at 6 digits
+ if (this.value.length === 6) {
+ setTimeout(() => {
+ const form = this.$el.closest('form');
+ if (form) form.submit();
+ }, 300);
+ }
+ },
+
+ init() {
+ this.$nextTick(() => this.$el.focus());
+ }
+}));
diff --git a/resources/js/alpine/components/cart-button.js b/resources/js/alpine/components/cart-button.js
new file mode 100644
index 000000000..96ad0f251
--- /dev/null
+++ b/resources/js/alpine/components/cart-button.js
@@ -0,0 +1,54 @@
+/**
+ * Alpine.data('cartButton') - Add to cart button (browse/details pages)
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('cartButton', () => ({
+ clicked: false,
+
+ addToCart(guid) {
+ if (!guid || this.clicked) return;
+ this.clicked = true;
+
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/cart/add', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrf,
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ body: JSON.stringify({ id: guid })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ if (data.cartCount) Alpine.store('cart').setCount(data.cartCount);
+ showToast('Added to cart successfully!', 'success');
+ } else {
+ showToast('Failed to add item to cart', 'error');
+ }
+ setTimeout(() => { this.clicked = false; }, 2000);
+ })
+ .catch(() => {
+ showToast('An error occurred', 'error');
+ this.clicked = false;
+ });
+ }
+}));
+
+// Standalone addToCart for backward compat
+window.addToCart = function(guid) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/cart/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: guid })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) showToast('Added to cart successfully!', 'success');
+ else showToast('Failed to add item to cart', 'error');
+ })
+ .catch(() => showToast('An error occurred', 'error'));
+};
diff --git a/resources/js/alpine/components/cart-page.js b/resources/js/alpine/components/cart-page.js
new file mode 100644
index 000000000..42bcc6c6b
--- /dev/null
+++ b/resources/js/alpine/components/cart-page.js
@@ -0,0 +1,159 @@
+/**
+ * Alpine.data('cartPage') - Cart page with select-all, download, delete
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('cartPage', () => ({
+ allChecked: false,
+
+ init() {
+ this._updateCheckAll();
+ },
+
+ toggleAll() {
+ const boxes = this.$el.querySelectorAll('.cart-checkbox');
+ boxes.forEach(cb => { cb.checked = this.allChecked; });
+ },
+
+ onCheckboxChange() {
+ this._updateCheckAll();
+ },
+
+ _updateCheckAll() {
+ const boxes = this.$el.querySelectorAll('.cart-checkbox');
+ const checked = this.$el.querySelectorAll('.cart-checkbox:checked');
+ this.allChecked = boxes.length > 0 && checked.length === boxes.length;
+ },
+
+ _getSelected() {
+ return Array.from(this.$el.querySelectorAll('.cart-checkbox:checked')).map(cb => cb.value);
+ },
+
+ downloadSelected() {
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('Please select at least one item', 'error'); return; }
+ if (selected.length === 1) {
+ window.location.href = '/getnzb/' + selected[0];
+ } else {
+ window.location.href = '/getnzb?id=' + encodeURIComponent(selected.join(',')) + '&zip=1';
+ }
+ showToast('Downloading ' + selected.length + ' NZB' + (selected.length > 1 ? 's' : '') + '...', 'success');
+ },
+
+ deleteSelected() {
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('Please select at least one item', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ showConfirm({
+ title: 'Delete from Cart',
+ message: 'Are you sure you want to delete ' + selected.length + ' item' + (selected.length > 1 ? 's' : '') + ' from your cart?',
+ type: 'danger',
+ confirmText: 'Delete',
+ onConfirm: function() {
+ fetch('/cart/delete/' + selected.join(','), {
+ method: 'POST',
+ headers: { 'X-CSRF-TOKEN': csrf, 'Content-Type': 'application/json' }
+ })
+ .then(r => {
+ if (r.ok) { showToast('Items deleted successfully', 'success'); setTimeout(() => window.location.reload(), 1000); }
+ else showToast('Failed to delete items', 'error');
+ })
+ .catch(() => showToast('Failed to delete items', 'error'));
+ }
+ });
+ },
+
+ deleteItem(url, name) {
+ showConfirm({
+ title: 'Remove from Cart',
+ message: 'Are you sure you want to remove "' + name + '" from your cart?',
+ type: 'warning',
+ confirmText: 'Remove',
+ onConfirm: function() {
+ showToast('Removing item from cart...', 'info');
+ setTimeout(() => { window.location.href = url; }, 500);
+ }
+ });
+ }
+}));
+
+// Multi-operations for browse pages (select all releases, download, cart, delete)
+Alpine.data('releaseMultiOps', () => ({
+ allChecked: false,
+
+ toggleAll() {
+ const boxes = document.querySelectorAll('.chkRelease');
+ boxes.forEach(cb => { cb.checked = this.allChecked; });
+ },
+
+ _getSelected() {
+ return Array.from(document.querySelectorAll('.chkRelease:checked')).map(cb => cb.value);
+ },
+
+ downloadSelected() {
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('Please select at least one release', 'error'); return; }
+ if (selected.length === 1) {
+ window.location.href = '/getnzb/' + selected[0];
+ } else {
+ window.location.href = '/getnzb?id=' + encodeURIComponent(selected.join(',')) + '&zip=1';
+ }
+ showToast('Downloading ' + selected.length + ' NZB' + (selected.length > 1 ? 's' : '') + '...', 'success');
+ },
+
+ addSelectedToCart() {
+ const selected = this._getSelected();
+ if (selected.length === 0) { showToast('Please select at least one release', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/cart/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: selected.join(',') })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) showToast(data.message || 'Added ' + selected.length + ' item(s) to cart', 'success');
+ else showToast('Failed to add items to cart', 'error');
+ })
+ .catch(() => showToast('An error occurred', 'error'));
+ },
+
+ deleteSelected() {
+ const selected = this._getSelected();
+ const selectedEls = Array.from(document.querySelectorAll('.chkRelease:checked'));
+ if (selected.length === 0) { showToast('Please select at least one release to delete', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+
+ showConfirm({
+ title: 'Delete Releases',
+ message: 'Are you sure you want to delete ' + selected.length + ' release' + (selected.length > 1 ? 's' : '') + '? This action cannot be undone.',
+ type: 'danger',
+ confirmText: 'Delete',
+ onConfirm: function() {
+ showToast('Deleting ' + selected.length + ' release(s)...', 'info');
+ let deletedCount = 0, errorCount = 0;
+
+ const promises = selectedEls.map(checkbox => {
+ const guid = checkbox.value;
+ const row = checkbox.closest('tr');
+ return fetch('/admin/release-delete/' + guid, {
+ method: 'POST',
+ headers: { 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json' }
+ }).then(r => {
+ if (r.ok) { deletedCount++; if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 300); } }
+ else errorCount++;
+ }).catch(() => errorCount++);
+ });
+
+ Promise.all(promises).then(() => {
+ const selAll = document.getElementById('chkSelectAll');
+ if (selAll) selAll.checked = false;
+ if (deletedCount > 0 && errorCount === 0) showToast('Successfully deleted ' + deletedCount + ' release(s)', 'success');
+ else if (deletedCount > 0) showToast('Deleted ' + deletedCount + ', ' + errorCount + ' failed', 'error');
+ else showToast('Failed to delete releases', 'error');
+ if (document.querySelectorAll('.chkRelease').length === 0) setTimeout(() => window.location.reload(), 1500);
+ });
+ }
+ });
+ }
+}));
diff --git a/resources/js/alpine/components/confirm-modal.js b/resources/js/alpine/components/confirm-modal.js
new file mode 100644
index 000000000..0d672ba22
--- /dev/null
+++ b/resources/js/alpine/components/confirm-modal.js
@@ -0,0 +1,79 @@
+/**
+ * Alpine.data('confirmModal') - Styled confirmation modal (replaces native confirm)
+ * Global singleton - one per page, invoked via window.showConfirm() or $dispatch.
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('confirmModal', () => ({
+ open: false,
+ title: 'Confirm Action',
+ message: 'Are you sure you want to proceed?',
+ details: '',
+ confirmText: 'Confirm',
+ cancelText: 'Cancel',
+ type: 'info', // info, warning, danger, success
+ _onConfirm: null,
+ _resolve: null,
+
+ show(options) {
+ const defaults = {
+ title: 'Confirm Action',
+ message: 'Are you sure you want to proceed?',
+ details: '',
+ confirmText: 'Confirm',
+ cancelText: 'Cancel',
+ type: 'info',
+ onConfirm: null
+ };
+ const config = Object.assign({}, defaults, options);
+
+ this.title = config.title;
+ this.message = config.message;
+ this.details = config.details;
+ this.confirmText = config.confirmText;
+ this.cancelText = config.cancelText;
+ this.type = config.type;
+ this._onConfirm = config.onConfirm;
+ this.open = true;
+
+ return new Promise(resolve => { this._resolve = resolve; });
+ },
+
+ confirm() {
+ this.open = false;
+ if (this._onConfirm) this._onConfirm();
+ if (this._resolve) this._resolve(true);
+ this._onConfirm = null;
+ this._resolve = null;
+ },
+
+ cancel() {
+ this.open = false;
+ if (this._resolve) this._resolve(false);
+ this._onConfirm = null;
+ this._resolve = null;
+ },
+
+ iconClass() {
+ if (this.type === 'danger') return 'fa-exclamation-triangle text-red-600 dark:text-red-400';
+ if (this.type === 'warning') return 'fa-exclamation-circle text-yellow-600 dark:text-yellow-400';
+ if (this.type === 'success') return 'fa-check-circle text-green-600 dark:text-green-400';
+ return 'fa-info-circle text-blue-600 dark:text-blue-400';
+ },
+
+ confirmBtnClass() {
+ const base = 'px-4 py-2 text-white rounded-lg transition font-medium ';
+ if (this.type === 'danger') return base + 'bg-red-600 dark:bg-red-700 hover:bg-red-700 dark:hover:bg-red-800';
+ if (this.type === 'warning') return base + 'bg-yellow-600 dark:bg-yellow-700 hover:bg-yellow-700 dark:hover:bg-yellow-800';
+ if (this.type === 'success') return base + 'bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800';
+ return base + 'bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800';
+ },
+
+ init() {
+ // Global showConfirm for backward compatibility + non-Alpine callers
+ const self = this;
+ window.showConfirm = function(options) { return self.show(options); };
+ window.closeConfirmationModal = function() { self.cancel(); };
+ window.confirmConfirmationModal = function() { self.confirm(); };
+ }
+}));
diff --git a/resources/js/alpine/components/content-toggle.js b/resources/js/alpine/components/content-toggle.js
new file mode 100644
index 000000000..f33edbf16
--- /dev/null
+++ b/resources/js/alpine/components/content-toggle.js
@@ -0,0 +1,84 @@
+/**
+ * Alpine.data('contentToggle') - Admin content enable/disable toggle
+ * Alpine.data('contentDelete') - Admin content delete with confirmation
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+}
+
+Alpine.data('contentToggle', () => ({
+ toggleStatus(contentId, currentStatus, el) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ if (!contentId || !csrf) { showToast('Error: Missing required data', 'error'); return; }
+
+ el.disabled = true;
+ el.style.opacity = '0.6';
+
+ fetch('/admin/content-toggle-status', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: contentId })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ const row = el.closest('tr');
+ const statusCell = row.cells[5];
+ const ns = data.status;
+
+ if (ns === 1) {
+ statusCell.innerHTML = ' Enabled ';
+ el.className = 'content-toggle-status text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300';
+ el.innerHTML = ' ';
+ } else {
+ statusCell.innerHTML = ' Disabled ';
+ el.className = 'content-toggle-status text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300';
+ el.innerHTML = ' ';
+ }
+ el.setAttribute('data-current-status', ns);
+ el.title = ns === 1 ? 'Disable' : 'Enable';
+ showToast(data.message, 'success');
+ } else {
+ showToast(data.message || 'Failed to toggle content status', 'error');
+ }
+ el.disabled = false;
+ el.style.opacity = '1';
+ })
+ .catch(() => { showToast('An error occurred while toggling content status', 'error'); el.disabled = false; el.style.opacity = '1'; });
+ },
+
+ deleteContent(contentId, contentTitle, el) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ if (!contentId || !csrf) { showToast('Error: Missing required data', 'error'); return; }
+
+ showConfirm({
+ title: 'Delete Content',
+ message: 'Are you sure you want to delete "' + contentTitle + '"?',
+ details: 'This action cannot be undone.',
+ type: 'danger',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ onConfirm: function() {
+ el.disabled = true;
+ el.style.opacity = '0.6';
+ fetch('/admin/content-delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: contentId })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ const row = el.closest('tr');
+ if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => { row.remove(); const tbody = document.querySelector('tbody'); if (tbody && tbody.children.length === 0) location.reload(); }, 300); }
+ showToast(data.message, 'success');
+ } else { showToast(data.message || 'Failed to delete content', 'error'); el.disabled = false; el.style.opacity = '1'; }
+ })
+ .catch(() => { showToast('An error occurred while deleting content', 'error'); el.disabled = false; el.style.opacity = '1'; });
+ }
+ });
+ }
+}));
diff --git a/resources/js/alpine/components/dropdown.js b/resources/js/alpine/components/dropdown.js
new file mode 100644
index 000000000..a43ea9ebf
--- /dev/null
+++ b/resources/js/alpine/components/dropdown.js
@@ -0,0 +1,45 @@
+/**
+ * Alpine.data('dropdown') - Header navigation dropdown menus
+ * Supports click toggle, hover keep-open, and click-outside close.
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('dropdown', () => ({
+ open: false,
+ _closeTimeout: null,
+
+ toggle() {
+ this.open = !this.open;
+ },
+
+ close() {
+ this.open = false;
+ },
+
+ delayedClose() {
+ this._closeTimeout = setTimeout(() => this.close(), 300);
+ },
+
+ cancelClose() {
+ clearTimeout(this._closeTimeout);
+ }
+}));
+
+// Nested submenu (e.g. Foreign languages)
+Alpine.data('submenu', () => ({
+ open: false,
+ _closeTimeout: null,
+
+ show() {
+ clearTimeout(this._closeTimeout);
+ this.open = true;
+ },
+
+ delayedHide() {
+ this._closeTimeout = setTimeout(() => { this.open = false; }, 200);
+ },
+
+ cancelHide() {
+ clearTimeout(this._closeTimeout);
+ }
+}));
diff --git a/resources/js/alpine/components/event-bridge.js b/resources/js/alpine/components/event-bridge.js
new file mode 100644
index 000000000..669317524
--- /dev/null
+++ b/resources/js/alpine/components/event-bridge.js
@@ -0,0 +1,1330 @@
+/**
+ * Event Bridge - Document-level event delegation for existing Blade templates
+ *
+ * The Blade templates use CSS class hooks (.add-to-cart, .report-trigger, etc.)
+ * and data attributes (data-confirm, data-open-nfo, etc.) without x-data wrappers.
+ * This bridge catches those events at the document level and routes them to
+ * the appropriate Alpine store/global functions.
+ *
+ * This file can be removed once all Blade templates are updated with x-data
+ * directives and Alpine event bindings.
+ */
+import Alpine from '@alpinejs/csp';
+
+function findWithAttr(e, attr) {
+ if (!e || !e.target) return null;
+ if (e.target.hasAttribute && e.target.hasAttribute(attr)) return e.target;
+ return e.target.closest ? e.target.closest('[' + attr + ']') : null;
+}
+
+function findWithClass(e, cls) {
+ if (!e || !e.target) return null;
+ if (e.target.classList && e.target.classList.contains(cls)) return e.target;
+ return e.target.closest ? e.target.closest('.' + cls) : null;
+}
+
+document.addEventListener('click', function(e) {
+
+ // --- Add to Cart (individual buttons) ---
+ const cartBtn = findWithClass(e, 'add-to-cart');
+ if (cartBtn) {
+ e.preventDefault();
+ const guid = cartBtn.dataset.guid;
+ const iconEl = cartBtn.querySelector('.icon_cart');
+ if (!guid) return;
+ if (iconEl && iconEl.classList.contains('icon_cart_clicked')) return;
+
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/cart/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: guid })
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (data.success) {
+ if (iconEl) {
+ iconEl.classList.remove('fa-shopping-basket', 'fa-shopping-cart');
+ iconEl.classList.add('fa-check', 'icon_cart_clicked');
+ setTimeout(() => {
+ iconEl.classList.remove('fa-check', 'icon_cart_clicked');
+ iconEl.classList.add('fa-shopping-basket');
+ }, 2000);
+ }
+ if (data.cartCount) {
+ const countEl = document.querySelector('.cart-count');
+ if (countEl) countEl.textContent = data.cartCount;
+ Alpine.store('cart').setCount(data.cartCount);
+ }
+ showToast('Added to cart successfully!', 'success');
+ } else {
+ showToast('Failed to add item to cart', 'error');
+ }
+ })
+ .catch(() => showToast('An error occurred', 'error'));
+ return;
+ }
+
+ // --- Download NZB toast ---
+ if (findWithClass(e, 'download-nzb')) {
+ showToast('Downloading NZB...', 'success');
+ // Don't prevent default - let the link navigate
+ }
+
+ // --- NFO modal open ---
+ const nfoOpen = findWithAttr(e, 'data-open-nfo');
+ if (nfoOpen) {
+ e.preventDefault();
+ const guid = nfoOpen.getAttribute('data-open-nfo');
+ if (guid && typeof openNfoModal === 'function') openNfoModal(guid);
+ return;
+ }
+
+ // --- NFO modal close ---
+ if (findWithAttr(e, 'data-close-nfo-modal')) {
+ e.preventDefault();
+ if (typeof closeNfoModal === 'function') closeNfoModal();
+ return;
+ }
+
+ // --- NFO badge ---
+ const nfoBadge = findWithClass(e, 'nfo-badge');
+ if (nfoBadge) {
+ e.preventDefault();
+ const guid = nfoBadge.getAttribute('data-guid');
+ if (guid && typeof openNfoModal === 'function') openNfoModal(guid);
+ return;
+ }
+
+ // --- Preview badge ---
+ const previewBadge = findWithClass(e, 'preview-badge');
+ if (previewBadge) {
+ e.preventDefault();
+ const guid = previewBadge.dataset.guid;
+ if (guid && typeof showPreviewImage === 'function') showPreviewImage(guid, 'preview');
+ return;
+ }
+
+ // --- Sample badge ---
+ const sampleBadge = findWithClass(e, 'sample-badge');
+ if (sampleBadge) {
+ e.preventDefault();
+ const guid = sampleBadge.dataset.guid;
+ if (guid && typeof showPreviewImage === 'function') showPreviewImage(guid, 'sample');
+ return;
+ }
+
+ // --- Mediainfo badge ---
+ const mediainfoBadge = findWithClass(e, 'mediainfo-badge');
+ if (mediainfoBadge) {
+ e.preventDefault();
+ const releaseId = mediainfoBadge.dataset.releaseId;
+ if (releaseId && typeof showMediainfo === 'function') showMediainfo(releaseId);
+ return;
+ }
+
+ // --- Filelist badge ---
+ const filelistBadge = findWithClass(e, 'filelist-badge');
+ if (filelistBadge) {
+ e.preventDefault();
+ const guid = filelistBadge.dataset.guid;
+ if (guid && typeof showFilelist === 'function') showFilelist(guid);
+ return;
+ }
+
+ // --- Admin Release Reports: Comment/Description Modal ---
+ const reportDescBtn = findWithClass(e, 'report-description-btn');
+ if (reportDescBtn) {
+ e.preventDefault();
+ const modal = document.getElementById('reportDescriptionModal');
+ const desc = reportDescBtn.dataset.description || '';
+ const reason = reportDescBtn.dataset.reason || '';
+ const reporter = reportDescBtn.dataset.reporter || '';
+ if (modal) {
+ document.getElementById('reportDescReason').textContent = reason;
+ document.getElementById('reportDescReporter').textContent = reporter;
+ document.getElementById('reportDescContent').textContent = desc;
+ modal.classList.remove('hidden');
+ }
+ return;
+ }
+
+ // --- Admin Release Reports: Close description modal ---
+ if (findWithClass(e, 'report-desc-modal-close') || findWithClass(e, 'report-desc-modal-backdrop')) {
+ e.preventDefault();
+ const modal = document.getElementById('reportDescriptionModal');
+ if (modal) modal.classList.add('hidden');
+ return;
+ }
+
+ // --- Admin Release Reports: Revert button ---
+ const revertBtn = findWithClass(e, 'revert-report-btn');
+ if (revertBtn) {
+ e.preventDefault();
+ const modal = document.getElementById('revertConfirmModal');
+ const form = document.getElementById('revertConfirmForm');
+ const statusSpan = document.getElementById('revertReportStatus');
+ const actionUrl = revertBtn.dataset.actionUrl || '';
+ const status = revertBtn.dataset.reportStatus || '';
+ if (modal && form) {
+ form.action = actionUrl;
+ if (statusSpan) statusSpan.textContent = status;
+ modal.classList.remove('hidden');
+ }
+ return;
+ }
+
+ // --- Admin Release Reports: Close revert modal ---
+ if (findWithClass(e, 'revert-modal-close') || findWithClass(e, 'revert-modal-backdrop')) {
+ e.preventDefault();
+ const modal = document.getElementById('revertConfirmModal');
+ if (modal) modal.classList.add('hidden');
+ return;
+ }
+
+ // --- Modal close buttons (data attributes) ---
+ if (findWithAttr(e, 'data-close-preview-modal')) { e.preventDefault(); if (typeof closePreviewModal === 'function') closePreviewModal(); return; }
+ if (findWithAttr(e, 'data-close-mediainfo-modal')) { e.preventDefault(); if (typeof closeMediainfoModal === 'function') closeMediainfoModal(); return; }
+ if (findWithAttr(e, 'data-close-filelist-modal')) { e.preventDefault(); if (typeof closeFilelistModal === 'function') closeFilelistModal(); return; }
+ if (findWithAttr(e, 'data-close-image-modal')) { e.preventDefault(); if (typeof closeImageModal === 'function') closeImageModal(); return; }
+
+ // --- Confirmation modal buttons ---
+ if (findWithAttr(e, 'data-close-confirmation-modal')) { e.preventDefault(); if (typeof closeConfirmationModal === 'function') closeConfirmationModal(); return; }
+ if (findWithAttr(e, 'data-confirm-confirmation-modal')) { e.preventDefault(); if (typeof confirmConfirmationModal === 'function') confirmConfirmationModal(); return; }
+
+ // --- Logout ---
+ if (findWithAttr(e, 'data-logout')) {
+ e.preventDefault();
+ const form = document.getElementById('logout-form') || document.getElementById('sidebar-logout-form');
+ if (form) form.submit();
+ return;
+ }
+
+ // --- Confirm delete (data-confirm-delete) ---
+ const confirmDelete = findWithAttr(e, 'data-confirm-delete');
+ if (confirmDelete) {
+ e.preventDefault();
+ e.stopPropagation();
+ const form = confirmDelete.closest('form');
+ showConfirm({
+ message: 'Are you sure you want to delete this item?',
+ type: 'danger',
+ confirmText: 'Delete',
+ onConfirm: function() { if (form) form.submit(); else if (confirmDelete.href) window.location.href = confirmDelete.href; }
+ });
+ return;
+ }
+
+ // --- Confirm action (data-confirm) ---
+ const confirmAction = findWithAttr(e, 'data-confirm');
+ if (confirmAction) {
+ e.preventDefault();
+ e.stopPropagation();
+ const message = confirmAction.getAttribute('data-confirm');
+ const form = confirmAction.closest('form');
+ showConfirm({
+ message: message,
+ type: 'danger',
+ confirmText: 'Confirm',
+ onConfirm: function() { if (form) form.submit(); else if (confirmAction.href) window.location.href = confirmAction.href; }
+ });
+ return;
+ }
+
+ // --- Release report trigger ---
+ const reportTrigger = findWithClass(e, 'report-trigger');
+ if (reportTrigger) {
+ e.preventDefault();
+ e.stopPropagation();
+ const container = reportTrigger.closest('.report-button-container');
+ if (!container) return;
+ const modal = container.querySelector('.report-modal');
+ if (!modal) return;
+
+ // Skip if already initialized by Alpine - check if modal has x-data
+ if (modal.hasAttribute('x-data')) return;
+
+ const releaseId = reportTrigger.getAttribute('data-report-release-id');
+ const reasonSelect = modal.querySelector('.report-reason');
+ const descriptionTextarea = modal.querySelector('.report-description');
+ const charCount = modal.querySelector('.report-char-count');
+ const submitButton = modal.querySelector('.report-submit');
+ const errorDiv = modal.querySelector('.report-error');
+ const successDiv = modal.querySelector('.report-success');
+ const closeButtons = modal.querySelectorAll('.report-modal-close');
+ const backdrop = modal.querySelector('.report-modal-backdrop');
+ const reportLabel = reportTrigger.querySelector('.report-label');
+ const flagIcon = reportTrigger.querySelector('i');
+
+ // Reset and show
+ if (errorDiv) errorDiv.classList.add('hidden');
+ if (successDiv) successDiv.classList.add('hidden');
+ if (reasonSelect) reasonSelect.value = '';
+ if (descriptionTextarea) descriptionTextarea.value = '';
+ if (charCount) charCount.textContent = '0/1000 characters';
+ if (submitButton) submitButton.disabled = true;
+ modal.classList.remove('hidden');
+
+ let hasReported = false;
+ let isSubmitting = false;
+
+ // Close function
+ function closeReportModal() {
+ modal.classList.add('hidden');
+ if (hasReported) {
+ reportTrigger.disabled = true;
+ reportTrigger.classList.add('opacity-50', 'cursor-not-allowed');
+ if (flagIcon) flagIcon.classList.add('text-red-500');
+ if (reportLabel) reportLabel.textContent = 'Reported';
+ }
+ }
+
+ // Wire up close buttons (one-time, checks for duplicate)
+ if (!modal._bridgeWired) {
+ modal._bridgeWired = true;
+ closeButtons.forEach(btn => btn.addEventListener('click', function(ev) { ev.preventDefault(); closeReportModal(); }));
+ if (backdrop) backdrop.addEventListener('click', closeReportModal);
+ if (descriptionTextarea && charCount) {
+ descriptionTextarea.addEventListener('input', function() { charCount.textContent = this.value.length + '/1000 characters'; });
+ }
+ if (reasonSelect && submitButton) {
+ reasonSelect.addEventListener('change', function() { submitButton.disabled = !this.value; });
+ }
+ if (submitButton) {
+ submitButton.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (isSubmitting || !reasonSelect || !reasonSelect.value) return;
+ isSubmitting = true;
+ submitButton.disabled = true;
+ submitButton.innerHTML = ' Submitting...';
+ if (errorDiv) errorDiv.classList.add('hidden');
+ if (successDiv) successDiv.classList.add('hidden');
+
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
+ fetch('/release-report', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
+ body: JSON.stringify({ release_id: releaseId, reason: reasonSelect.value, description: descriptionTextarea ? descriptionTextarea.value : '' })
+ })
+ .then(r => r.json().then(data => ({ ok: r.ok, data })))
+ .then(result => {
+ if (result.ok && result.data.success) {
+ if (successDiv) { successDiv.querySelector('p').textContent = result.data.message; successDiv.classList.remove('hidden'); }
+ hasReported = true;
+ setTimeout(closeReportModal, 2000);
+ } else {
+ if (errorDiv) { errorDiv.querySelector('p').textContent = result.data.message || 'An error occurred.'; errorDiv.classList.remove('hidden'); }
+ }
+ })
+ .catch(() => { if (errorDiv) { errorDiv.querySelector('p').textContent = 'Network error.'; errorDiv.classList.remove('hidden'); } })
+ .finally(() => { isSubmitting = false; if (submitButton) { submitButton.disabled = !reasonSelect.value; submitButton.innerHTML = 'Submit Report'; } });
+ });
+ }
+ }
+ return;
+ }
+
+ // --- Admin menu submenu toggle ---
+ const menuToggle = findWithAttr(e, 'data-toggle-submenu');
+ if (menuToggle) {
+ const menuId = menuToggle.getAttribute('data-toggle-submenu');
+ if (menuId) {
+ const submenu = document.getElementById(menuId);
+ const icon = document.getElementById(menuId + '-icon');
+ if (submenu) submenu.classList.toggle('hidden');
+ if (icon) icon.classList.toggle('rotate-180');
+ }
+ return;
+ }
+
+ // --- Admin groups data-action delegation ---
+ const actionTarget = e.target.closest('[data-action]');
+ if (actionTarget) {
+ const action = actionTarget.dataset.action;
+ const groupId = actionTarget.dataset.groupId;
+ const status = actionTarget.dataset.status;
+
+ switch (action) {
+ case 'show-reset-modal': if (typeof showResetAllModal === 'function') showResetAllModal(); break;
+ case 'hide-reset-modal': if (typeof hideResetAllModal === 'function') hideResetAllModal(); break;
+ case 'show-purge-modal': if (typeof showPurgeAllModal === 'function') showPurgeAllModal(); break;
+ case 'hide-purge-modal': if (typeof hidePurgeAllModal === 'function') hidePurgeAllModal(); break;
+ case 'show-reset-selected-modal': if (typeof showResetSelectedModal === 'function') showResetSelectedModal(); break;
+ case 'hide-reset-selected-modal': if (typeof hideResetSelectedModal === 'function') hideResetSelectedModal(); break;
+ case 'select-all-groups': if (typeof toggleSelectAllGroups === 'function') toggleSelectAllGroups(actionTarget); break;
+ case 'toggle-group-status': if (typeof ajax_group_status === 'function') ajax_group_status(groupId, status); break;
+ case 'toggle-backfill': if (typeof ajax_backfill_status === 'function') ajax_backfill_status(groupId, status); break;
+ case 'reset-group': if (typeof ajax_group_reset === 'function') ajax_group_reset(groupId); break;
+ case 'delete-group': if (typeof confirmGroupDelete === 'function') confirmGroupDelete(groupId); break;
+ case 'purge-group': if (typeof confirmGroupPurge === 'function') confirmGroupPurge(groupId); break;
+ case 'reset-all': if (typeof ajax_group_reset_all === 'function') ajax_group_reset_all(); break;
+ case 'purge-all': if (typeof ajax_group_purge_all === 'function') ajax_group_purge_all(); break;
+ case 'reset-selected': if (typeof ajax_group_reset_selected === 'function') ajax_group_reset_selected(); break;
+ }
+ return;
+ }
+
+ // --- Multi-operations: Download selected ---
+ if (findWithClass(e, 'nzb_multi_operations_download')) {
+ e.preventDefault();
+ const selected = Array.from(document.querySelectorAll('.chkRelease:checked')).map(cb => cb.value);
+ if (selected.length === 0) { showToast('Please select at least one release', 'error'); return; }
+ if (selected.length === 1) window.location.href = '/getnzb/' + selected[0];
+ else window.location.href = '/getnzb?id=' + encodeURIComponent(selected.join(',')) + '&zip=1';
+ showToast('Downloading ' + selected.length + ' NZB(s)...', 'success');
+ return;
+ }
+
+ // --- Multi-operations: Cart selected ---
+ if (findWithClass(e, 'nzb_multi_operations_cart')) {
+ e.preventDefault();
+ const selected = Array.from(document.querySelectorAll('.chkRelease:checked')).map(cb => cb.value);
+ if (selected.length === 0) { showToast('Please select at least one release', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/cart/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id: selected.join(',') })
+ }).then(r => r.json()).then(data => {
+ if (data.success) showToast(data.message || 'Added ' + selected.length + ' item(s) to cart', 'success');
+ else showToast('Failed to add items to cart', 'error');
+ }).catch(() => showToast('An error occurred', 'error'));
+ return;
+ }
+
+ // --- Multi-operations: Delete selected (admin) ---
+ if (findWithClass(e, 'nzb_multi_operations_delete')) {
+ e.preventDefault();
+ const checkboxes = document.querySelectorAll('.chkRelease:checked');
+ const selected = Array.from(checkboxes);
+ if (selected.length === 0) { showToast('Please select at least one release to delete', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ showConfirm({
+ title: 'Delete Releases',
+ message: 'Are you sure you want to delete ' + selected.length + ' release(s)? This action cannot be undone.',
+ type: 'danger', confirmText: 'Delete',
+ onConfirm: function() {
+ showToast('Deleting...', 'info');
+ let ok = 0, fail = 0;
+ Promise.all(selected.map(cb => {
+ const row = cb.closest('tr');
+ return fetch('/admin/release-delete/' + cb.value, {
+ method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/json' }
+ }).then(r => { if (r.ok) { ok++; if (row) { row.style.transition='opacity 0.3s'; row.style.opacity='0'; setTimeout(()=>row.remove(),300); } } else fail++; }).catch(() => fail++);
+ })).then(() => {
+ const sa = document.getElementById('chkSelectAll'); if (sa) sa.checked = false;
+ if (ok > 0 && fail === 0) showToast('Deleted ' + ok + ' release(s)', 'success');
+ else if (ok > 0) showToast('Deleted ' + ok + ', ' + fail + ' failed', 'error');
+ else showToast('Failed to delete releases', 'error');
+ if (!document.querySelectorAll('.chkRelease').length) setTimeout(() => window.location.reload(), 1500);
+ });
+ }
+ });
+ return;
+ }
+
+ // --- Cart page: Download selected ---
+ if (findWithClass(e, 'nzb_multi_operations_download_cart')) {
+ e.preventDefault();
+ const selected = Array.from(document.querySelectorAll('.cart-checkbox:checked')).map(cb => cb.value);
+ if (selected.length === 0) { showToast('Please select at least one item', 'error'); return; }
+ if (selected.length === 1) window.location.href = '/getnzb/' + selected[0];
+ else window.location.href = '/getnzb?id=' + encodeURIComponent(selected.join(',')) + '&zip=1';
+ showToast('Downloading...', 'success');
+ return;
+ }
+
+ // --- Cart page: Delete selected ---
+ if (findWithClass(e, 'nzb_multi_operations_cartdelete')) {
+ e.preventDefault();
+ const selected = Array.from(document.querySelectorAll('.cart-checkbox:checked')).map(cb => cb.value);
+ if (selected.length === 0) { showToast('Please select at least one item', 'error'); return; }
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ showConfirm({
+ title: 'Delete from Cart',
+ message: 'Are you sure you want to delete ' + selected.length + ' item(s) from your cart?',
+ type: 'danger', confirmText: 'Delete',
+ onConfirm: function() {
+ fetch('/cart/delete/' + selected.join(','), { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'Content-Type': 'application/json' } })
+ .then(r => { if (r.ok) { showToast('Items deleted', 'success'); setTimeout(() => window.location.reload(), 1000); } else showToast('Failed', 'error'); })
+ .catch(() => showToast('Failed', 'error'));
+ }
+ });
+ return;
+ }
+
+ // --- Cart page: Individual delete ---
+ const cartDeleteLink = findWithClass(e, 'cart-delete-link');
+ if (cartDeleteLink) {
+ e.preventDefault();
+ e.stopPropagation();
+ const name = cartDeleteLink.getAttribute('data-release-name');
+ const url = cartDeleteLink.getAttribute('data-delete-url');
+ showConfirm({
+ title: 'Remove from Cart',
+ message: 'Are you sure you want to remove "' + (name || 'this item') + '" from your cart?',
+ type: 'warning', confirmText: 'Remove',
+ onConfirm: function() { showToast('Removing...', 'info'); setTimeout(() => { window.location.href = url; }, 500); }
+ });
+ return;
+ }
+
+ // --- Image modal triggers ---
+ const imgTrigger = findWithClass(e, 'image-modal-trigger');
+ if (imgTrigger) {
+ e.preventDefault();
+ const url = imgTrigger.getAttribute('data-image-url');
+ const title = imgTrigger.getAttribute('data-image-title') || 'Image Preview';
+ if (typeof openImageModal === 'function') openImageModal(url, title);
+ return;
+ }
+
+ // --- Verify user modal ---
+ const verifyBtn = findWithAttr(e, 'data-show-verify-modal');
+ if (verifyBtn) {
+ const form = verifyBtn.closest('form');
+ if (form && typeof showVerifyModal === 'function') showVerifyModal(e, form);
+ return;
+ }
+ if (findWithAttr(e, 'data-close-verify-modal')) { e.preventDefault(); if (typeof hideVerifyModal === 'function') hideVerifyModal(); return; }
+ if (findWithAttr(e, 'data-submit-verify-form')) { e.preventDefault(); if (typeof submitVerifyForm === 'function') submitVerifyForm(); return; }
+
+ // --- Restore/delete user buttons ---
+ const restoreBtn = findWithClass(e, 'restore-user-btn');
+ if (restoreBtn) {
+ e.preventDefault();
+ const userId = restoreBtn.getAttribute('data-user-id');
+ const username = restoreBtn.getAttribute('data-username') || 'this user';
+ showConfirm({
+ title: 'Restore User', message: 'Are you sure you want to restore user "' + username + '"?',
+ type: 'success', confirmText: 'Restore',
+ onConfirm: function() {
+ const form = document.getElementById('individualActionForm');
+ if (form) { form.action = '/admin/deleted-users/restore/' + userId; form.method = 'POST'; form.submit(); }
+ }
+ });
+ return;
+ }
+
+ const deleteUserBtn = findWithClass(e, 'delete-user-btn');
+ if (deleteUserBtn) {
+ e.preventDefault();
+ const userId = deleteUserBtn.getAttribute('data-user-id');
+ const username = deleteUserBtn.getAttribute('data-username') || 'this user';
+ showConfirm({
+ title: 'Permanently Delete User',
+ message: 'Are you sure you want to permanently delete user "' + username + '"? This action cannot be undone.',
+ type: 'danger', confirmText: 'Delete Permanently',
+ onConfirm: function() {
+ const form = document.getElementById('individualActionForm');
+ if (form) { form.action = '/admin/deleted-users/permanent-delete/' + userId; form.method = 'POST'; form.submit(); }
+ }
+ });
+ return;
+ }
+
+ // --- Promotion toggle/delete ---
+ const promoToggle = findWithClass(e, 'promotion-toggle-btn');
+ if (promoToggle) {
+ e.preventDefault();
+ const name = promoToggle.getAttribute('data-promotion-name');
+ const active = promoToggle.getAttribute('data-promotion-active') === '1';
+ const action = active ? 'deactivate' : 'activate';
+ showConfirm({
+ title: action.charAt(0).toUpperCase() + action.slice(1) + ' Promotion',
+ message: 'Are you sure you want to ' + action + ' the promotion "' + name + '"?',
+ type: active ? 'warning' : 'success',
+ confirmText: action.charAt(0).toUpperCase() + action.slice(1),
+ onConfirm: function() { window.location.href = promoToggle.href; }
+ });
+ return;
+ }
+
+ const promoDelete = findWithClass(e, 'promotion-delete-btn');
+ if (promoDelete) {
+ e.preventDefault();
+ const name = promoDelete.getAttribute('data-promotion-name');
+ const form = promoDelete.closest('form');
+ showConfirm({
+ title: 'Delete Promotion', message: 'Are you sure you want to delete the promotion "' + name + '"?',
+ details: 'This action cannot be undone.', type: 'danger', confirmText: 'Delete',
+ onConfirm: function() { if (form) form.submit(); }
+ });
+ return;
+ }
+
+ // --- Content toggle/delete ---
+ const contentToggle = findWithClass(e, 'content-toggle-status');
+ if (contentToggle) {
+ e.preventDefault();
+ const id = contentToggle.getAttribute('data-content-id');
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ if (!id || !csrf) return;
+ contentToggle.disabled = true; contentToggle.style.opacity = '0.6';
+ fetch('/admin/content-toggle-status', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' },
+ body: JSON.stringify({ id })
+ }).then(r => r.json()).then(data => {
+ if (data.success) {
+ const row = contentToggle.closest('tr'), sc = row.cells[5], ns = data.status;
+ if (ns === 1) { sc.innerHTML = ' Enabled '; contentToggle.className = 'content-toggle-status text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300'; contentToggle.innerHTML = ' '; }
+ else { sc.innerHTML = ' Disabled '; contentToggle.className = 'content-toggle-status text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'; contentToggle.innerHTML = ' '; }
+ contentToggle.setAttribute('data-current-status', ns); contentToggle.title = ns === 1 ? 'Disable' : 'Enable';
+ showToast(data.message, 'success');
+ } else showToast(data.message || 'Failed', 'error');
+ contentToggle.disabled = false; contentToggle.style.opacity = '1';
+ }).catch(() => { showToast('Error', 'error'); contentToggle.disabled = false; contentToggle.style.opacity = '1'; });
+ return;
+ }
+
+ const contentDelete = findWithClass(e, 'content-delete');
+ if (contentDelete) {
+ e.preventDefault();
+ const id = contentDelete.getAttribute('data-content-id');
+ const title = contentDelete.getAttribute('data-content-title');
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ if (!id || !csrf) return;
+ showConfirm({ title: 'Delete Content', message: 'Are you sure you want to delete "' + title + '"?', details: 'This action cannot be undone.', type: 'danger', confirmText: 'Delete',
+ onConfirm: function() {
+ contentDelete.disabled = true; contentDelete.style.opacity = '0.6';
+ fetch('/admin/content-delete', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ id }) })
+ .then(r => r.json()).then(data => {
+ if (data.success) { const row = contentDelete.closest('tr'); if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => { row.remove(); if (document.querySelector('tbody')?.children.length === 0) location.reload(); }, 300); } showToast(data.message, 'success'); }
+ else { showToast(data.message || 'Failed', 'error'); contentDelete.disabled = false; contentDelete.style.opacity = '1'; }
+ }).catch(() => { showToast('Error', 'error'); contentDelete.disabled = false; contentDelete.style.opacity = '1'; });
+ }
+ });
+ return;
+ }
+
+ // --- Regex delete ---
+ const regexDel = findWithAttr(e, 'data-delete-regex');
+ if (regexDel) {
+ e.preventDefault();
+ const id = regexDel.getAttribute('data-delete-regex');
+ const url = regexDel.getAttribute('data-delete-url');
+ if (confirm('Are you sure you want to delete this regex?')) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch(url + '?id=' + id, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf } })
+ .then(r => r.json()).then(d => {
+ if (d.success) { const row = document.getElementById('row-' + id); if (row) { row.style.transition = 'opacity 0.3s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 300); } showToast('Regex deleted', 'success'); }
+ else showToast('Error', 'error');
+ }).catch(() => showToast('Error', 'error'));
+ }
+ return;
+ }
+
+ // --- Release delete ---
+ const relDel = findWithAttr(e, 'data-delete-release');
+ if (relDel) {
+ e.preventDefault();
+ const id = relDel.getAttribute('data-delete-release');
+ const url = relDel.getAttribute('data-delete-url');
+ showConfirm({ title: 'Delete Release', message: 'Are you sure? This cannot be undone.', type: 'danger', confirmText: 'Delete',
+ onConfirm: function() {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ showToast('Deleting...', 'info');
+ fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf, 'Content-Type': 'application/json', 'Accept': 'application/json' } })
+ .then(r => { if (r.ok) { const row = relDel.closest('tr'); if (row) { row.style.transition='opacity 0.3s'; row.style.opacity='0'; setTimeout(()=>row.remove(),300); } showToast('Deleted', 'success'); } else showToast('Error: ' + r.status, 'error'); })
+ .catch(err => showToast('Error: ' + err.message, 'error'));
+ }
+ });
+ return;
+ }
+
+ // --- Binary blacklist delete ---
+ const blDel = findWithAttr(e, 'data-delete-blacklist');
+ if (blDel) {
+ e.preventDefault();
+ const id = blDel.getAttribute('data-delete-blacklist');
+ if (confirm('Are you sure? This will delete the blacklist.')) {
+ if (typeof ajax_binaryblacklist_delete === 'function') ajax_binaryblacklist_delete(id);
+ }
+ return;
+ }
+
+ // --- Season tab ---
+ const seasonTab = findWithClass(e, 'season-tab');
+ if (seasonTab) {
+ e.preventDefault();
+ const num = seasonTab.getAttribute('data-season');
+ if (num && typeof switchSeason === 'function') switchSeason(num);
+ return;
+ }
+});
+
+// --- Change event delegation ---
+document.addEventListener('change', function(e) {
+ // Select redirects
+ if (e.target.hasAttribute('data-redirect-on-change')) {
+ const url = e.target.value;
+ if (url && url !== '#') window.location.href = url;
+ }
+
+ // Group checkbox
+ if (e.target.classList.contains('group-checkbox')) {
+ if (typeof updateSelectionUI === 'function') updateSelectionUI();
+ }
+});
+
+// --- Cart page: Check-all checkbox ---
+(function() {
+ const checkAll = document.getElementById('check-all');
+ const checkboxes = document.querySelectorAll('.cart-checkbox');
+ if (checkAll && checkboxes.length > 0) {
+ function updateState() {
+ const checked = Array.from(checkboxes).filter(cb => cb.checked).length;
+ checkAll.checked = checked === checkboxes.length;
+ checkAll.indeterminate = checked > 0 && checked < checkboxes.length;
+ }
+ checkAll.addEventListener('change', function() { checkboxes.forEach(cb => { cb.checked = this.checked; }); });
+ checkboxes.forEach(cb => cb.addEventListener('change', updateState));
+ updateState();
+ }
+
+ // Select-all for chkRelease (browse pages)
+ const selectAll = document.getElementById('chkSelectAll');
+ if (selectAll) {
+ selectAll.addEventListener('change', function() {
+ document.querySelectorAll('.chkRelease').forEach(cb => { cb.checked = this.checked; });
+ });
+ }
+})();
+
+// --- Sidebar toggles ---
+document.querySelectorAll('.sidebar-toggle').forEach(toggle => {
+ toggle.addEventListener('click', function() {
+ const targetId = this.getAttribute('data-target');
+ const submenu = document.getElementById(targetId);
+ const chevron = this.querySelector('.fa-chevron-down');
+ if (submenu) submenu.classList.toggle('hidden');
+ if (chevron) chevron.classList.toggle('rotate-180');
+ });
+});
+
+// --- Password visibility toggle ---
+document.querySelectorAll('.password-toggle-btn').forEach(btn => {
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ const fieldId = this.getAttribute('data-field-id');
+ if (!fieldId) return;
+ const field = document.getElementById(fieldId);
+ const icon = document.getElementById(fieldId + '-eye');
+ if (!field) return;
+ if (field.type === 'password') { field.type = 'text'; if (icon) { icon.classList.remove('fa-eye'); icon.classList.add('fa-eye-slash'); } }
+ else { field.type = 'password'; if (icon) { icon.classList.remove('fa-eye-slash'); icon.classList.add('fa-eye'); } }
+ });
+});
+window.togglePasswordVisibility = function(fieldId) {
+ const field = document.getElementById(fieldId);
+ const icon = document.getElementById(fieldId + '-eye');
+ if (!field) return;
+ if (field.type === 'password') { field.type = 'text'; if (icon) { icon.classList.remove('fa-eye'); icon.classList.add('fa-eye-slash'); } }
+ else { field.type = 'password'; if (icon) { icon.classList.remove('fa-eye-slash'); icon.classList.add('fa-eye'); } }
+};
+
+// --- Image fallbacks ---
+document.querySelectorAll('img[data-fallback-src]').forEach(img => {
+ img.addEventListener('error', function() {
+ const fb = this.getAttribute('data-fallback-src');
+ if (fb && this.src !== fb) this.src = fb;
+ });
+});
+
+// --- Theme toggle button ---
+(function() {
+ const themeToggle = document.getElementById('theme-toggle');
+ if (themeToggle) {
+ themeToggle.addEventListener('click', function() {
+ Alpine.store('theme').cycle();
+ });
+ }
+})();
+
+// --- Search autocomplete initialization ---
+(function() {
+ function initAutocomplete(inputId, dropdownId, formId, itemClass, maxItems) {
+ const input = document.getElementById(inputId);
+ const dropdown = document.getElementById(dropdownId);
+ const form = document.getElementById(formId);
+ if (!input || !dropdown) return;
+
+ let debounceTimer, currentIndex = -1, suggestions = [];
+
+ input.addEventListener('input', function() {
+ clearTimeout(debounceTimer);
+ const q = this.value.trim();
+ if (q.length < 2) { hideDropdown(); return; }
+ debounceTimer = setTimeout(() => {
+ fetch('/api/search/autocomplete?q=' + encodeURIComponent(q))
+ .then(r => r.json())
+ .then(data => {
+ if (data.success && data.suggestions && data.suggestions.length > 0) {
+ suggestions = data.suggestions;
+ currentIndex = -1;
+ renderDropdown(suggestions, q);
+ } else hideDropdown();
+ }).catch(() => hideDropdown());
+ }, 200);
+ });
+
+ input.addEventListener('keydown', function(ev) {
+ if (dropdown.classList.contains('hidden')) return;
+ const items = dropdown.querySelectorAll('.' + itemClass);
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); currentIndex = Math.min(currentIndex + 1, items.length - 1); updateSelection(items); }
+ else if (ev.key === 'ArrowUp') { ev.preventDefault(); currentIndex = Math.max(currentIndex - 1, 0); updateSelection(items); }
+ else if (ev.key === 'Enter' && currentIndex >= 0 && items[currentIndex]) { ev.preventDefault(); input.value = suggestions[currentIndex]; hideDropdown(); if (form) form.submit(); }
+ else if (ev.key === 'Escape') hideDropdown();
+ });
+
+ document.addEventListener('click', function(ev) {
+ if (!input.contains(ev.target) && !dropdown.contains(ev.target)) hideDropdown();
+ });
+
+ function renderDropdown(items, query) {
+ currentIndex = -1;
+ const escapeRegex = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+ const limit = maxItems || 10;
+ const sizeClass = itemClass === 'header-autocomplete-item' ? 'px-3 py-2 text-sm' : 'px-4 py-2';
+ const iconSize = itemClass === 'header-autocomplete-item' ? 'text-xs' : '';
+ dropdown.innerHTML = items.slice(0, limit).map((item, i) => {
+ const hl = item.replace(regex, '$1 ');
+ return ' ' + hl + '
';
+ }).join('');
+ dropdown.querySelectorAll('.' + itemClass).forEach((el, i) => {
+ el.addEventListener('click', () => { input.value = suggestions[i]; hideDropdown(); if (form) form.submit(); });
+ el.addEventListener('mouseenter', () => { currentIndex = i; updateSelection(dropdown.querySelectorAll('.' + itemClass)); });
+ });
+ dropdown.classList.remove('hidden');
+ }
+
+ function hideDropdown() { dropdown.classList.add('hidden'); dropdown.innerHTML = ''; suggestions = []; currentIndex = -1; }
+ function updateSelection(items) { items.forEach((el, i) => { if (i === currentIndex) el.classList.add('bg-blue-100', 'dark:bg-blue-900'); else el.classList.remove('bg-blue-100', 'dark:bg-blue-900'); }); }
+ }
+
+ // Header desktop search
+ initAutocomplete('header-search-input', 'header-autocomplete-dropdown', 'header-search-form', 'header-autocomplete-item', 8);
+ // Header mobile search
+ initAutocomplete('mobile-search-input', 'mobile-autocomplete-dropdown', 'mobile-search-form-el', 'header-autocomplete-item', 8);
+ // Main search page
+ initAutocomplete('search', 'autocomplete-dropdown', 'searchForm', 'autocomplete-item', 10);
+ // Dynamic autocomplete inputs
+ document.querySelectorAll('[data-autocomplete-input]').forEach(function(el) {
+ const ddId = el.getAttribute('data-autocomplete-input');
+ const formId = el.getAttribute('data-autocomplete-form');
+ if (el.id && ddId) initAutocomplete(el.id, ddId, formId, 'autocomplete-suggestion', 10);
+ });
+})();
+
+// --- Sort dropdowns ---
+(function() {
+ document.querySelectorAll('.sort-dropdown').forEach(function(dd) {
+ const toggle = dd.querySelector('.sort-dropdown-toggle');
+ const menu = dd.querySelector('.sort-dropdown-menu');
+ const chevron = dd.querySelector('.sort-dropdown-chevron');
+ if (!toggle || !menu) return;
+ if (toggle.hasAttribute('data-sort-initialized')) return;
+ toggle.setAttribute('data-sort-initialized', 'true');
+ toggle.addEventListener('click', function(ev) {
+ ev.preventDefault(); ev.stopPropagation();
+ const isOpen = !menu.classList.contains('hidden');
+ document.querySelectorAll('.sort-dropdown-menu').forEach(m => m.classList.add('hidden'));
+ document.querySelectorAll('.sort-dropdown-chevron').forEach(c => c.classList.remove('rotate-180'));
+ if (!isOpen) { menu.classList.remove('hidden'); if (chevron) chevron.classList.add('rotate-180'); }
+ });
+ });
+ if (!window._sortDropdownOutsideListenerAdded) {
+ window._sortDropdownOutsideListenerAdded = true;
+ document.addEventListener('click', function(ev) {
+ if (!ev.target.closest('.sort-dropdown')) {
+ document.querySelectorAll('.sort-dropdown-menu').forEach(m => m.classList.add('hidden'));
+ document.querySelectorAll('.sort-dropdown-chevron').forEach(c => c.classList.remove('rotate-180'));
+ }
+ });
+ }
+})();
+
+// --- Header dropdown menus ---
+(function() {
+ const containers = document.querySelectorAll('.dropdown-container');
+ containers.forEach(function(container) {
+ const toggle = container.querySelector('.dropdown-toggle');
+ const menu = container.querySelector('.dropdown-menu');
+ if (!toggle || !menu) return;
+ let closeTimeout;
+ menu.style.display = 'none';
+ toggle.addEventListener('click', function(ev) {
+ ev.preventDefault(); ev.stopPropagation();
+ const open = menu.style.display === 'block';
+ containers.forEach(c => { const m = c.querySelector('.dropdown-menu'); if (m && c !== container) m.style.display = 'none'; });
+ menu.style.display = open ? 'none' : 'block';
+ });
+ container.addEventListener('mouseenter', () => clearTimeout(closeTimeout));
+ container.addEventListener('mouseleave', () => { closeTimeout = setTimeout(() => { menu.style.display = 'none'; }, 300); });
+ menu.addEventListener('mouseenter', () => clearTimeout(closeTimeout));
+ });
+ document.addEventListener('click', function(ev) {
+ if (!ev.target.closest('.dropdown-container')) containers.forEach(c => { const m = c.querySelector('.dropdown-menu'); if (m) m.style.display = 'none'; });
+ });
+
+ // Nested submenus
+ document.querySelectorAll('.submenu-container').forEach(function(container) {
+ const sub = container.querySelector('.submenu');
+ if (!sub) return;
+ let t;
+ container.addEventListener('mouseenter', () => { clearTimeout(t); sub.style.display = 'block'; });
+ container.addEventListener('mouseleave', () => { t = setTimeout(() => { sub.style.display = 'none'; }, 200); });
+ sub.addEventListener('mouseenter', () => clearTimeout(t));
+ });
+})();
+
+// --- Mobile menu toggle ---
+(function() {
+ const toggle = document.getElementById('mobile-menu-toggle');
+ const panel = document.getElementById('mobile-nav-panel');
+ const iconOpen = document.getElementById('mobile-menu-icon-open');
+ const iconClose = document.getElementById('mobile-menu-icon-close');
+ const searchToggle = document.getElementById('mobile-search-toggle');
+ const searchForm = document.getElementById('mobile-search-form');
+
+ if (toggle && panel) {
+ toggle.addEventListener('click', function() {
+ const isOpen = !panel.classList.contains('hidden');
+ if (isOpen) { panel.classList.add('hidden'); toggle.setAttribute('aria-expanded', 'false'); }
+ else { panel.classList.remove('hidden'); toggle.setAttribute('aria-expanded', 'true'); if (searchForm) searchForm.classList.add('hidden'); }
+ if (iconOpen && iconClose) { iconOpen.classList.toggle('hidden'); iconClose.classList.toggle('hidden'); }
+ });
+ }
+
+ // Mobile nav section accordion toggles
+ document.querySelectorAll('.mobile-nav-toggle').forEach(function(t) {
+ t.addEventListener('click', function() {
+ const section = this.closest('.mobile-nav-section');
+ if (!section) return;
+ const submenu = section.querySelector('.mobile-nav-submenu');
+ const chevron = this.querySelector('.mobile-nav-chevron');
+ if (submenu) submenu.classList.toggle('hidden');
+ if (chevron) chevron.classList.toggle('rotate-180');
+ });
+ });
+
+ if (searchToggle && searchForm) {
+ searchToggle.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ searchForm.classList.toggle('hidden');
+ if (panel && !searchForm.classList.contains('hidden')) {
+ panel.classList.add('hidden');
+ if (iconOpen && iconClose) { iconOpen.classList.remove('hidden'); iconClose.classList.add('hidden'); }
+ if (toggle) toggle.setAttribute('aria-expanded', 'false');
+ }
+ });
+ }
+
+ // Mobile sidebar toggle
+ const mobileSidebarToggle = document.getElementById('mobile-sidebar-toggle');
+ if (mobileSidebarToggle) {
+ mobileSidebarToggle.addEventListener('click', function() {
+ const sidebar = document.getElementById('sidebar');
+ if (sidebar) { sidebar.classList.toggle('hidden'); sidebar.classList.toggle('flex'); }
+ });
+ }
+
+ // Close on resize past lg
+ window.addEventListener('resize', function() {
+ if (window.innerWidth >= 1024) {
+ if (panel) panel.classList.add('hidden');
+ if (searchForm) searchForm.classList.add('hidden');
+ if (iconOpen && iconClose) { iconOpen.classList.remove('hidden'); iconClose.classList.add('hidden'); }
+ if (toggle) toggle.setAttribute('aria-expanded', 'false');
+ }
+ });
+})();
+
+// --- Tab switcher (data-tab-trigger) ---
+document.querySelectorAll('[data-tab-trigger]').forEach(function(trigger) {
+ trigger.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ const tabId = this.getAttribute('data-tab-trigger');
+ document.querySelectorAll('.tab-content').forEach(t => { t.style.display = 'none'; });
+ const sel = document.getElementById(tabId);
+ if (sel) sel.style.display = 'block';
+ document.querySelectorAll('[data-tab-trigger]').forEach(t => { t.classList.remove('active', 'border-blue-500', 'text-blue-600'); t.classList.add('border-transparent', 'text-gray-500'); });
+ this.classList.remove('border-transparent', 'text-gray-500');
+ this.classList.add('active', 'border-blue-500', 'text-blue-600');
+ });
+});
+
+// --- Profile tabs (tab-link) ---
+(function() {
+ const links = document.querySelectorAll('.tab-link');
+ const contents = document.querySelectorAll('.tab-content');
+ if (!links.length || !contents.length) return;
+ let chartsInited = false;
+ links.forEach(link => {
+ link.addEventListener('click', function(ev) {
+ ev.preventDefault();
+ const targetId = this.getAttribute('href').substring(1);
+ links.forEach(l => { l.classList.remove('bg-blue-50', 'text-blue-700', 'font-medium'); l.classList.add('text-gray-700', 'dark:text-gray-300'); });
+ this.classList.add('bg-blue-50', 'text-blue-700', 'font-medium');
+ this.classList.remove('text-gray-700', 'dark:text-gray-300');
+ contents.forEach(c => { c.style.display = 'none'; });
+ const target = document.getElementById(targetId);
+ if (target) { target.style.display = 'block'; if (targetId === 'api' && !chartsInited) { chartsInited = true; let att = 0; const check = setInterval(() => { att++; if (typeof Chart !== 'undefined') { clearInterval(check); if (typeof initializeProfileCharts === 'function') initializeProfileCharts(); } else if (att >= 20) clearInterval(check); }, 100); } }
+ history.pushState(null, null, '#' + targetId);
+ });
+ });
+ const hash = window.location.hash.substring(1);
+ if (hash && document.getElementById(hash)) {
+ const link = document.querySelector('a[href="#' + hash + '"]');
+ if (link) link.click();
+ } else { contents.forEach((c, i) => { if (i !== 0) c.style.display = 'none'; }); }
+})();
+
+// --- Profile Edit: 2FA form toggle ---
+(function() {
+ const toggleBtn = document.getElementById('toggle-disable-2fa-btn');
+ const cancelBtn = document.getElementById('cancel-disable-2fa-btn');
+ const formContainer = document.getElementById('disable-2fa-form-container');
+ if (toggleBtn && formContainer) toggleBtn.addEventListener('click', () => { formContainer.style.display = formContainer.style.display === 'none' ? 'block' : 'none'; });
+ if (cancelBtn && formContainer) cancelBtn.addEventListener('click', () => { formContainer.style.display = 'none'; const p = document.getElementById('disable_2fa_password'); if (p) p.value = ''; });
+
+ // Theme radio buttons on profile edit
+ document.querySelectorAll('input[name="theme_preference"]').forEach(radio => {
+ if (radio.dataset.themeListenerAttached === 'true') return;
+ radio.dataset.themeListenerAttached = 'true';
+ radio.addEventListener('change', function() {
+ if (window._updatingThemeUI) return;
+ Alpine.store('theme').set(this.value);
+ });
+ });
+})();
+
+// --- Auth pages: auto-hide alerts, 2FA auto-submit ---
+(function() {
+ if (window.location.pathname.includes('/login')) {
+ setTimeout(() => {
+ document.querySelectorAll('.bg-green-50, .bg-blue-50').forEach(el => {
+ el.style.transition = 'opacity 0.5s ease-out'; el.style.opacity = '0';
+ setTimeout(() => el.remove(), 500);
+ });
+ }, 5000);
+ }
+ const otp = document.getElementById('one_time_password');
+ if (otp) {
+ otp.addEventListener('input', function() { this.value = this.value.replace(/[^0-9]/g, ''); if (this.value.length === 6) setTimeout(() => this.form.submit(), 300); });
+ otp.focus();
+ }
+})();
+
+// --- Progress bar animation ---
+document.querySelectorAll('.progress-bar').forEach(bar => {
+ const w = bar.dataset.width;
+ if (w) setTimeout(() => { bar.style.width = w + '%'; }, 100);
+});
+
+// --- Copy to Clipboard ---
+(function() {
+ function copyToClipboard(text, button) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => showCopyFeedback(button)).catch(() => fallbackCopy(text, button));
+ } else fallbackCopy(text, button);
+ }
+ function fallbackCopy(text, button) {
+ const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 99999);
+ try { document.execCommand('copy'); showCopyFeedback(button); } catch(e) {} document.body.removeChild(ta);
+ }
+ function showCopyFeedback(button) {
+ if (!button) return; const icon = button.querySelector('i');
+ if (icon) { icon.classList.remove('fa-copy'); icon.classList.add('fa-check'); }
+ button.classList.add('text-green-600');
+ setTimeout(() => { if (icon) { icon.classList.remove('fa-check'); icon.classList.add('fa-copy'); } button.classList.remove('text-green-600'); }, 2000);
+ }
+ document.addEventListener('click', function(ev) {
+ const copyBtn = ev.target.closest('.copy-btn');
+ if (copyBtn) { const tid = copyBtn.getAttribute('data-copy-target'); const input = document.getElementById(tid); if (input) { ev.preventDefault(); input.select(); input.setSelectionRange(0, 99999); copyToClipboard(input.value, copyBtn); } }
+ const apiBtn = ev.target.id === 'copyApiToken' ? ev.target : ev.target.closest('#copyApiToken');
+ if (apiBtn) { const input = document.getElementById('apiTokenInput'); if (input) { ev.preventDefault(); input.select(); input.setSelectionRange(0, 99999); copyToClipboard(input.value, apiBtn); } }
+ });
+})();
+
+// --- Admin dashboard charts and recent activity ---
+(function() {
+ function initCharts() {
+ if (typeof Chart === 'undefined') { let a = 0; const c = setInterval(() => { a++; if (typeof Chart !== 'undefined') { clearInterval(c); doCharts(); } else if (a >= 30) clearInterval(c); }, 100); return; }
+ doCharts();
+ }
+ function doCharts() {
+ function makeChart(id, type, label, bg, border, fill) {
+ const canvas = document.getElementById(id); if (!canvas) return;
+ const data = JSON.parse(canvas.dataset.chartData || canvas.dataset.history || '[]'); if (!data.length) return;
+ new Chart(canvas, { type, data: { labels: data.map(i => i.time), datasets: [{ label, data: data.map(i => i.count || i.value), backgroundColor: bg, borderColor: border, borderWidth: type === 'bar' ? 1 : 2, fill, tension: 0.4 }] }, options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }, plugins: { legend: { display: false } } } });
+ }
+ function makePctChart(id, label, bg, border) {
+ const canvas = document.getElementById(id); if (!canvas) return;
+ const data = JSON.parse(canvas.dataset.history || '[]'); if (!data.length) return;
+ new Chart(canvas, { type: 'line', data: { labels: data.map(i => i.time), datasets: [{ label, data: data.map(i => i.value), backgroundColor: bg, borderColor: border, borderWidth: 2, fill: true, tension: 0.4 }] }, options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } } }, plugins: { legend: { display: false } } } });
+ }
+ makeChart('downloadsChart','bar','Downloads','rgba(34,197,94,0.7)','rgba(34,197,94,1)',false);
+ makeChart('apiHitsChart','line','API Hits','rgba(168,85,247,0.2)','rgba(168,85,247,1)',true);
+ makeChart('downloadsMinuteChart','line','Downloads','rgba(34,197,94,0.2)','rgba(34,197,94,1)',true);
+ makeChart('apiHitsMinuteChart','line','API Hits','rgba(168,85,247,0.2)','rgba(168,85,247,1)',true);
+ makePctChart('cpuHistory24hChart','CPU %','rgba(249,115,22,0.2)','rgba(249,115,22,1)');
+ makePctChart('ramHistory24hChart','RAM %','rgba(6,182,212,0.2)','rgba(6,182,212,1)');
+ makePctChart('cpuHistory30dChart','CPU %','rgba(249,115,22,0.2)','rgba(249,115,22,1)');
+ makePctChart('ramHistory30dChart','RAM %','rgba(6,182,212,0.2)','rgba(6,182,212,1)');
+ }
+ const hasCharts = document.getElementById('downloadsChart') || document.getElementById('cpuHistory24hChart');
+ if (hasCharts) initCharts();
+
+ // Recent activity refresh
+ const activityContainer = document.getElementById('recent-activity-container');
+ if (activityContainer) {
+ const refreshUrl = activityContainer.getAttribute('data-refresh-url');
+ if (refreshUrl) {
+ function escHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
+ function refreshActivity() {
+ fetch(refreshUrl, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin' })
+ .then(r => r.json()).then(data => {
+ if (!data.success || !data.activities) return;
+ activityContainer.innerHTML = '';
+ if (!data.activities.length) { activityContainer.innerHTML = 'No recent activity
'; return; }
+ data.activities.forEach(a => {
+ const div = document.createElement('div'); div.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors';
+ div.innerHTML = '
'+escHtml(a.message)+'
'+escHtml(a.created_at)+'
';
+ activityContainer.appendChild(div);
+ });
+ const ts = document.getElementById('activity-last-updated');
+ if (ts) ts.innerHTML = ' Last updated: ' + new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
+ }).catch(() => {});
+ }
+ setInterval(refreshActivity, 20 * 60 * 1000);
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshActivity(); });
+ }
+ }
+})();
+
+// --- Admin user edit datetime picker ---
+(function() {
+ if (!document.getElementById('rolechangedate')) return;
+ const hidden = document.getElementById('rolechangedate');
+ if (hidden && hidden.value) {
+ const d = new Date(hidden.value);
+ if (!isNaN(d.getTime())) {
+ const el = id => document.getElementById(id);
+ if (el('expiry_year')) el('expiry_year').value = d.getFullYear().toString();
+ if (el('expiry_month')) el('expiry_month').value = String(d.getMonth()+1).padStart(2,'0');
+ if (el('expiry_day')) el('expiry_day').value = String(d.getDate()).padStart(2,'0');
+ if (el('expiry_hour')) el('expiry_hour').value = String(d.getHours()).padStart(2,'0');
+ if (el('expiry_minute')) el('expiry_minute').value = String(d.getMinutes()).padStart(2,'0');
+ }
+ }
+ function updateDateTime() {
+ const y = document.getElementById('expiry_year')?.value, mo = document.getElementById('expiry_month')?.value, d = document.getElementById('expiry_day')?.value, h = document.getElementById('expiry_hour')?.value, mi = document.getElementById('expiry_minute')?.value;
+ if (y && mo && d && h && mi) { document.getElementById('rolechangedate').value = y+'-'+mo+'-'+d+'T'+h+':'+mi+':00'; updatePreview(); }
+ }
+ function updatePreview() {
+ const h = document.getElementById('rolechangedate'), p = document.getElementById('datetime_preview'), disp = document.getElementById('datetime_display');
+ if (!h?.value || !p || !disp) return;
+ const d = new Date(h.value);
+ disp.textContent = d.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit'});
+ p.classList.remove('hidden');
+ }
+ ['expiry_year','expiry_month','expiry_day','expiry_hour','expiry_minute'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.addEventListener('change', updateDateTime);
+ });
+
+ // Expiry quick actions
+ document.addEventListener('click', function(ev) {
+ const target = ev.target.closest('[data-expiry-action]');
+ if (!target) return;
+ ev.preventDefault();
+ const action = target.getAttribute('data-expiry-action');
+ const days = parseInt(target.getAttribute('data-days') || '0');
+ const hours = parseInt(target.getAttribute('data-hours') || '0');
+ if (action === 'set') {
+ let base; const y = document.getElementById('expiry_year')?.value, mo = document.getElementById('expiry_month')?.value, d = document.getElementById('expiry_day')?.value, h = document.getElementById('expiry_hour')?.value, mi = document.getElementById('expiry_minute')?.value;
+ const orig = document.getElementById('original_user_expiry')?.value;
+ if (y && mo && d && h && mi) base = new Date(y, parseInt(mo)-1, d, h, mi);
+ else if (orig) base = new Date(orig);
+ else base = new Date();
+ base.setDate(base.getDate() + days); base.setHours(base.getHours() + hours);
+ document.getElementById('expiry_year').value = base.getFullYear().toString();
+ document.getElementById('expiry_month').value = String(base.getMonth()+1).padStart(2,'0');
+ document.getElementById('expiry_day').value = String(base.getDate()).padStart(2,'0');
+ document.getElementById('expiry_hour').value = String(base.getHours()).padStart(2,'0');
+ document.getElementById('expiry_minute').value = String(base.getMinutes()).padStart(2,'0');
+ updateDateTime();
+ } else if (action === 'end-of-day') {
+ const eod = new Date(); eod.setHours(23,59,0,0);
+ document.getElementById('expiry_year').value = eod.getFullYear().toString();
+ document.getElementById('expiry_month').value = String(eod.getMonth()+1).padStart(2,'0');
+ document.getElementById('expiry_day').value = String(eod.getDate()).padStart(2,'0');
+ document.getElementById('expiry_hour').value = '23'; document.getElementById('expiry_minute').value = '59';
+ updateDateTime();
+ } else if (action === 'clear') {
+ ['expiry_year','expiry_month','expiry_day','expiry_hour','expiry_minute'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
+ document.getElementById('rolechangedate').value = '';
+ const p = document.getElementById('datetime_preview'); if (p) p.classList.add('hidden');
+ }
+ });
+})();
+
+// --- TinyMCE initialization ---
+// NOTE: TinyMCE is now loaded directly in blade templates with proper CSP nonce
+// See admin/content/add.blade.php for example. This avoids CSP blocking of dynamically loaded scripts.
+// Keeping this code commented for reference:
+/*
+(function() {
+ function initTinyMCE() {
+ const body = document.getElementById('body');
+ const editors = document.querySelectorAll('.tinymce-editor');
+ if (!body && !editors.length) return;
+
+ function loadAndInit() {
+ if (typeof tinymce !== 'undefined') { doInit(); return; }
+ let apiKey = 'no-api-key';
+ const meta = document.querySelector('meta[name="tinymce-api-key"]');
+ if (meta && meta.content) apiKey = meta.content;
+ else if (window.NNTmuxConfig?.tinymceApiKey) apiKey = window.NNTmuxConfig.tinymceApiKey;
+ else if (body?.dataset.tinymceApiKey) apiKey = body.dataset.tinymceApiKey;
+ else { for (const e of editors) { if (e.dataset.tinymceApiKey) { apiKey = e.dataset.tinymceApiKey; break; } } }
+
+ const script = document.createElement('script');
+ script.src = 'https://cdn.tiny.cloud/1/' + apiKey + '/tinymce/7/tinymce.min.js';
+ script.referrerPolicy = 'origin';
+ script.onload = doInit;
+ script.onerror = function() { console.error('Failed to load TinyMCE'); };
+ document.head.appendChild(script);
+ }
+
+ function doInit() {
+ const dark = document.documentElement.classList.contains('dark');
+ tinymce.init({
+ selector: '#body, .tinymce-editor',
+ height: 500,
+ menubar: true,
+ skin: dark ? 'oxide-dark' : 'oxide',
+ content_css: dark ? 'dark' : 'default',
+ plugins: ['advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview', 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'insertdatetime', 'media', 'table', 'help', 'wordcount', 'emoticons'],
+ toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media table emoticons | removeformat code fullscreen | help',
+ toolbar_mode: 'sliding',
+ content_style: 'body{font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6}',
+ branding: false,
+ promotion: false,
+ resize: true,
+ valid_elements: '*[*]',
+ extended_valid_elements: '*[*]',
+ setup: function(editor) {
+ editor.on('change blur keyup submit', function() { editor.save(); });
+ }
+ });
+ }
+
+ loadAndInit();
+ }
+
+ // Run on DOMContentLoaded or immediately if already loaded
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initTinyMCE);
+ } else {
+ initTinyMCE();
+ }
+})();
+*/
+
+// --- Admin: Invitations select-all, deleted users, regex validation ---
+(function() {
+ // Release reports select-all
+ const selectAllReports = document.getElementById('select-all');
+ if (selectAllReports) {
+ const boxes = document.querySelectorAll('.report-checkbox');
+ if (boxes.length > 0) {
+ selectAllReports.addEventListener('change', function() { boxes.forEach(cb => { cb.checked = this.checked; }); });
+ boxes.forEach(cb => cb.addEventListener('change', () => {
+ const all = Array.from(boxes).every(c => c.checked);
+ const some = Array.from(boxes).some(c => c.checked);
+ selectAllReports.checked = all; selectAllReports.indeterminate = some && !all;
+ }));
+ }
+ }
+
+ // Invitations
+ const selectAll = document.getElementById('select_all');
+ if (selectAll) {
+ const boxes = document.querySelectorAll('.invitation-checkbox');
+ selectAll.addEventListener('change', function() { boxes.forEach(cb => { cb.checked = this.checked; }); });
+ boxes.forEach(cb => cb.addEventListener('change', () => {
+ const all = Array.from(boxes).every(c => c.checked);
+ const some = Array.from(boxes).some(c => c.checked);
+ selectAll.checked = all; selectAll.indeterminate = some && !all;
+ }));
+ }
+
+ // Deleted users select-all
+ const selectAllUsers = document.getElementById('selectAll');
+ if (selectAllUsers) {
+ const boxes = document.querySelectorAll('.user-checkbox');
+ selectAllUsers.addEventListener('change', function() { boxes.forEach(cb => { cb.checked = this.checked; }); });
+ boxes.forEach(cb => cb.addEventListener('change', () => {
+ const all = Array.from(boxes).every(c => c.checked);
+ const some = Array.from(boxes).some(c => c.checked);
+ selectAllUsers.checked = all; selectAllUsers.indeterminate = some && !all;
+ }));
+ }
+
+ // Bulk action form
+ const bulkForm = document.getElementById('bulkActionForm');
+ if (bulkForm) {
+ bulkForm.addEventListener('submit', function(ev) {
+ ev.preventDefault();
+ const action = document.getElementById('bulkAction')?.value;
+ const checked = document.querySelectorAll('.user-checkbox:checked');
+ const errEl = document.getElementById('validationError'), errMsg = document.getElementById('validationErrorMessage');
+ if (errEl) errEl.classList.add('hidden');
+ if (!action) { if (errEl && errMsg) { errMsg.textContent = 'Please select an action.'; errEl.classList.remove('hidden'); } return; }
+ if (!checked.length) { if (errEl && errMsg) { errMsg.textContent = 'Please select at least one user.'; errEl.classList.remove('hidden'); } return; }
+ const text = action === 'restore' ? 'restore' : 'permanently delete';
+ showConfirm({ title: action === 'restore' ? 'Restore Users' : 'Delete Users', message: 'Are you sure you want to ' + text + ' ' + checked.length + ' user(s)?', type: action === 'restore' ? 'success' : 'danger', confirmText: action === 'restore' ? 'Restore' : 'Delete', onConfirm: () => bulkForm.submit() });
+ });
+ }
+
+ // Tmux crap types toggle
+ const checkedRadio = document.querySelector('input[name="fix_crap_opt"]:checked');
+ const crapContainer = document.getElementById('crap_types_container');
+ if (crapContainer) {
+ if (checkedRadio) crapContainer.style.display = checkedRadio.value === 'Custom' ? 'block' : 'none';
+ document.querySelectorAll('input[name="fix_crap_opt"]').forEach(r => r.addEventListener('change', function() { crapContainer.style.display = this.value === 'Custom' ? 'block' : 'none'; }));
+ }
+})();
+
+// --- User list scroll sync ---
+(function() {
+ const top = document.getElementById('topScroll'), bot = document.getElementById('bottomScroll'), content = document.getElementById('topScrollContent'), table = bot?.querySelector('table');
+ if (!top || !bot || !content || !table) return;
+ const sync = () => { content.style.width = table.scrollWidth + 'px'; }; sync();
+ window.addEventListener('resize', sync);
+ top.addEventListener('scroll', function() { if (!top._s) { bot._s = true; bot.scrollLeft = top.scrollLeft; bot._s = false; } });
+ bot.addEventListener('scroll', function() { if (!bot._s) { top._s = true; top.scrollLeft = bot.scrollLeft; top._s = false; } });
+})();
+
+// --- My Movies confirm ---
+document.querySelectorAll('.confirm_action').forEach(el => { el.addEventListener('click', function(ev) { if (!confirm('Are you sure you want to remove this movie from your watchlist?')) ev.preventDefault(); }); });
+
+// --- Escape key for modals ---
+document.addEventListener('keydown', function(e) {
+ if (e.key !== 'Escape') return;
+ if (typeof closeNfoModal === 'function') closeNfoModal();
+ if (typeof closePreviewModal === 'function') closePreviewModal();
+ if (typeof closeMediainfoModal === 'function') closeMediainfoModal();
+ if (typeof closeFilelistModal === 'function') closeFilelistModal();
+ if (typeof closeImageModal === 'function') closeImageModal();
+ if (typeof hideVerifyModal === 'function') hideVerifyModal();
+ // Close admin release reports modals
+ const descModal = document.getElementById('reportDescriptionModal');
+ if (descModal && !descModal.classList.contains('hidden')) descModal.classList.add('hidden');
+ const revertModal = document.getElementById('revertConfirmModal');
+ if (revertModal && !revertModal.classList.contains('hidden')) revertModal.classList.add('hidden');
+});
diff --git a/resources/js/alpine/components/filelist-modal.js b/resources/js/alpine/components/filelist-modal.js
new file mode 100644
index 000000000..91a583936
--- /dev/null
+++ b/resources/js/alpine/components/filelist-modal.js
@@ -0,0 +1,76 @@
+/**
+ * Alpine.data('filelistModal') - File list modal
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+}
+
+function formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+}
+
+Alpine.data('filelistModal', () => ({
+ open: false,
+ loading: false,
+ html: '',
+
+ show(guid) {
+ this.open = true;
+ this.loading = true;
+ this.html = '';
+
+ fetch('/api/release/' + guid + '/filelist')
+ .then(r => { if (!r.ok) throw new Error('Failed to load file list'); return r.json(); })
+ .then(data => {
+ if (!data.files || data.files.length === 0) {
+ this.html = 'No files available
';
+ } else {
+ this.html = this._buildHtml(data);
+ }
+ this.loading = false;
+ })
+ .catch(err => {
+ this.html = '' + escapeHtml(err.message) + '
';
+ this.loading = false;
+ });
+ },
+
+ close() { this.open = false; this.html = ''; },
+
+ _buildHtml(data) {
+ let html = '';
+ html += '
';
+ html += '
' + escapeHtml(data.release.searchname) + ' ';
+ html += '
Total Files: ' + data.total + '
';
+ html += '
';
+ html += '
';
+ html += 'File Name ';
+ html += 'Size ';
+ html += ' ';
+
+ data.files.forEach((file, i) => {
+ const rowClass = i % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900';
+ const name = file.title || file.name || 'Unknown';
+ const size = file.size ? formatFileSize(file.size) : 'N/A';
+ html += '';
+ html += '' + escapeHtml(name) + ' ';
+ html += '' + size + ' ';
+ });
+
+ html += '
';
+ return html;
+ },
+
+ init() {
+ const self = this;
+ window.showFilelist = function(guid) { self.show(guid); };
+ window.closeFilelistModal = function() { self.close(); };
+ }
+}));
diff --git a/resources/js/alpine/components/image-modal.js b/resources/js/alpine/components/image-modal.js
new file mode 100644
index 000000000..b34430ea0
--- /dev/null
+++ b/resources/js/alpine/components/image-modal.js
@@ -0,0 +1,27 @@
+/**
+ * Alpine.data('imageModal') - Image preview modal (details page)
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('imageModal', () => ({
+ open: false,
+ imageUrl: '',
+ imageTitle: 'Image Preview',
+
+ openModal(url, title) {
+ this.imageUrl = url || '';
+ this.imageTitle = title || 'Image Preview';
+ this.open = true;
+ },
+
+ close() {
+ this.open = false;
+ this.imageUrl = '';
+ },
+
+ init() {
+ const self = this;
+ window.openImageModal = function(url) { self.openModal(url); };
+ window.closeImageModal = function() { self.close(); };
+ }
+}));
diff --git a/resources/js/alpine/components/mediainfo-modal.js b/resources/js/alpine/components/mediainfo-modal.js
new file mode 100644
index 000000000..2b10987c7
--- /dev/null
+++ b/resources/js/alpine/components/mediainfo-modal.js
@@ -0,0 +1,103 @@
+/**
+ * Alpine.data('mediainfoModal') - Media information modal
+ */
+import Alpine from '@alpinejs/csp';
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+}
+
+function formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+}
+
+Alpine.data('mediainfoModal', () => ({
+ open: false,
+ loading: false,
+ html: '',
+
+ show(releaseId) {
+ this.open = true;
+ this.loading = true;
+ this.html = '';
+
+ fetch('/api/release/' + releaseId + '/mediainfo')
+ .then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
+ .then(data => {
+ if (!data.video && !data.audio && !data.subs) {
+ this.html = 'No media information available
';
+ } else {
+ this.html = this._buildHtml(data);
+ }
+ this.loading = false;
+ })
+ .catch(err => {
+ this.html = '' + escapeHtml(err.message) + '
';
+ this.loading = false;
+ });
+ },
+
+ close() { this.open = false; this.html = ''; },
+
+ _buildHtml(data) {
+ let html = '';
+
+ if (data.video) {
+ html += '
';
+ html += '
Video Information';
+ html += '
';
+ const v = data.video;
+ if (v.containerformat) html += this._dl('Container', v.containerformat);
+ if (v.videocodec) html += this._dl('Codec', v.videocodec);
+ if (v.videowidth && v.videoheight) html += this._dl('Resolution', v.videowidth + 'x' + v.videoheight);
+ if (v.videoaspect) html += this._dl('Aspect Ratio', v.videoaspect);
+ if (v.videoframerate) html += this._dl('Frame Rate', v.videoframerate + ' fps');
+ if (v.videoduration) {
+ const m = Math.round(parseInt(v.videoduration) / 1000 / 60);
+ if (m > 0) html += this._dl('Duration', m + ' minutes');
+ }
+ html += ' ';
+ }
+
+ if (data.audio && data.audio.length > 0) {
+ html += '
';
+ html += '
Audio Information';
+ data.audio.forEach((a, i) => {
+ if (i > 0) html += '
';
+ html += '
';
+ if (a.audioformat) html += this._dl('Format', a.audioformat);
+ if (a.audiochannels) html += this._dl('Channels', a.audiochannels);
+ if (a.audiobitrate) html += this._dl('Bit Rate', a.audiobitrate);
+ if (a.audiolanguage) html += this._dl('Language', a.audiolanguage);
+ if (a.audiosamplerate) html += this._dl('Sample Rate', a.audiosamplerate);
+ html += ' ';
+ });
+ html += '
';
+ }
+
+ if (data.subs) {
+ html += '
';
+ html += '
Subtitles';
+ html += '
' + escapeHtml(data.subs) + '
';
+ }
+
+ html += '
';
+ return html;
+ },
+
+ _dl(label, value) {
+ return '
' + escapeHtml(label) + ' ' + escapeHtml(String(value)) + ' ';
+ },
+
+ init() {
+ const self = this;
+ window.showMediainfo = function(id) { self.show(id); };
+ window.closeMediainfoModal = function() { self.close(); };
+ window.formatFileSize = formatFileSize;
+ }
+}));
diff --git a/resources/js/alpine/components/mobile-nav.js b/resources/js/alpine/components/mobile-nav.js
new file mode 100644
index 000000000..65ba5afd2
--- /dev/null
+++ b/resources/js/alpine/components/mobile-nav.js
@@ -0,0 +1,47 @@
+/**
+ * Alpine.data('mobileNav') - Mobile navigation panel + search toggle
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('mobileNav', () => ({
+ navOpen: false,
+ searchOpen: false,
+
+ toggleNav() {
+ this.navOpen = !this.navOpen;
+ if (this.navOpen) this.searchOpen = false;
+ },
+
+ toggleSearch() {
+ this.searchOpen = !this.searchOpen;
+ if (this.searchOpen) this.navOpen = false;
+ },
+
+ closeAll() {
+ this.navOpen = false;
+ this.searchOpen = false;
+ },
+
+ init() {
+ // Close on resize past lg breakpoint
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= 1024) this.closeAll();
+ });
+ }
+}));
+
+Alpine.data('mobileNavSection', () => ({
+ open: false,
+
+ toggle() {
+ this.open = !this.open;
+ }
+}));
+
+Alpine.data('mobileSidebar', () => ({
+ open: false,
+
+ toggle() {
+ this.open = !this.open;
+ }
+}));
diff --git a/resources/js/alpine/components/movies-layout.js b/resources/js/alpine/components/movies-layout.js
new file mode 100644
index 000000000..e986fac24
--- /dev/null
+++ b/resources/js/alpine/components/movies-layout.js
@@ -0,0 +1,64 @@
+/**
+ * Alpine.data('moviesLayout') - Movies page layout toggle (1/2 columns)
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('moviesLayout', () => ({
+ layout: 2,
+
+ init() {
+ const grid = document.getElementById('moviesGrid');
+ if (grid) this.layout = parseInt(grid.dataset.userLayout) || 2;
+ this._applyLayout();
+ },
+
+ toggle() {
+ this.layout = this.layout === 2 ? 1 : 2;
+ this._applyLayout();
+
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ if (csrf) {
+ fetch('/movies/update-layout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
+ body: JSON.stringify({ layout: this.layout })
+ }).catch(() => {});
+ }
+ },
+
+ buttonText() { return this.layout === 1 ? '1 Column' : '2 Columns'; },
+ buttonIcon() { return this.layout === 1 ? 'fas fa-th-list mr-2' : 'fas fa-th-large mr-2'; },
+
+ _applyLayout() {
+ const grid = document.getElementById('moviesGrid');
+ if (!grid) return;
+ const images = grid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700');
+ const containers = grid.querySelectorAll('.release-card-container');
+
+ if (this.layout === 1) {
+ grid.classList.remove('lg:grid-cols-2');
+ grid.classList.add('grid-cols-1');
+ images.forEach(img => { img.classList.remove('w-32', 'h-48'); img.classList.add('w-48', 'h-72'); });
+ containers.forEach(c => {
+ c.classList.remove('space-y-2');
+ c.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
+ const info = c.querySelector('.release-info-wrapper');
+ if (info) info.classList.add('flex-1', 'min-w-0');
+ const acts = c.querySelector('.release-actions');
+ if (acts) { acts.classList.remove('flex-wrap'); acts.classList.add('flex-shrink-0', 'flex-row', 'items-center'); }
+ });
+ } else {
+ grid.classList.remove('grid-cols-1');
+ grid.classList.add('lg:grid-cols-2');
+ images.forEach(img => { img.classList.remove('w-48', 'h-72'); img.classList.add('w-32', 'h-48'); });
+ containers.forEach(c => {
+ c.classList.add('space-y-2');
+ c.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
+ const info = c.querySelector('.release-info-wrapper');
+ if (info) info.classList.remove('flex-1', 'min-w-0');
+ const acts = c.querySelector('.release-actions');
+ if (acts) { acts.classList.add('flex-wrap', 'items-center'); acts.classList.remove('flex-shrink-0', 'flex-row'); }
+ });
+ }
+ }
+}));
diff --git a/resources/js/alpine/components/nfo-modal.js b/resources/js/alpine/components/nfo-modal.js
new file mode 100644
index 000000000..33a42ed27
--- /dev/null
+++ b/resources/js/alpine/components/nfo-modal.js
@@ -0,0 +1,48 @@
+/**
+ * Alpine.data('nfoModal') - NFO file viewer modal
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('nfoModal', () => ({
+ open: false,
+ content: '',
+ loading: false,
+ error: false,
+
+ openNfo(guid) {
+ if (!guid) return;
+ this.open = true;
+ this.loading = true;
+ this.error = false;
+ this.content = '';
+
+ const baseUrl = document.querySelector('meta[name="app-url"]')?.content || '';
+ fetch(baseUrl + '/nfo/' + guid + '?modal=1')
+ .then(response => {
+ if (!response.ok) throw new Error('NFO not found');
+ return response.text();
+ })
+ .then(html => {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ this.content = doc.querySelector('pre')?.textContent || doc.body.textContent;
+ this.loading = false;
+ })
+ .catch(() => {
+ this.error = true;
+ this.loading = false;
+ });
+ },
+
+ close() {
+ this.open = false;
+ this.content = '';
+ },
+
+ init() {
+ // Backward compat
+ const self = this;
+ window.openNfoModal = function(guid) { self.openNfo(guid); };
+ window.closeNfoModal = function() { self.close(); };
+ }
+}));
diff --git a/resources/js/alpine/components/password-toggle.js b/resources/js/alpine/components/password-toggle.js
new file mode 100644
index 000000000..fc5d13a42
--- /dev/null
+++ b/resources/js/alpine/components/password-toggle.js
@@ -0,0 +1,20 @@
+/**
+ * Alpine.data('passwordToggle') - Toggle password field visibility
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('passwordToggle', () => ({
+ visible: false,
+
+ toggle() {
+ this.visible = !this.visible;
+ const field = this.$refs.field;
+ if (field) {
+ field.type = this.visible ? 'text' : 'password';
+ }
+ },
+
+ iconClass() {
+ return this.visible ? 'fa-eye-slash' : 'fa-eye';
+ }
+}));
diff --git a/resources/js/alpine/components/preview-modal.js b/resources/js/alpine/components/preview-modal.js
new file mode 100644
index 000000000..c8d43e606
--- /dev/null
+++ b/resources/js/alpine/components/preview-modal.js
@@ -0,0 +1,44 @@
+/**
+ * Alpine.data('previewModal') - Preview/sample image modal for browse pages
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('previewModal', () => ({
+ open: false,
+ title: 'Preview Image',
+ imageUrl: '',
+ imageError: false,
+ imageLoaded: false,
+
+ show(guid, type) {
+ type = type || 'preview';
+ this.title = type === 'sample' ? 'Sample Image' : 'Preview Image';
+ this.imageUrl = '/covers/' + type + '/' + guid + '_thumb.jpg';
+ this.imageError = false;
+ this.imageLoaded = false;
+ this.open = true;
+ },
+
+ onImageError() {
+ this.imageError = true;
+ },
+
+ onImageLoad() {
+ this.imageLoaded = true;
+ },
+
+ close() {
+ this.open = false;
+ this.imageUrl = '';
+ },
+
+ errorMessage() {
+ return this.title.replace(' Image', '') + ' image not available';
+ },
+
+ init() {
+ const self = this;
+ window.showPreviewImage = function(guid, type) { self.show(guid, type); };
+ window.closePreviewModal = function() { self.close(); };
+ }
+}));
diff --git a/resources/js/alpine/components/profile-edit.js b/resources/js/alpine/components/profile-edit.js
new file mode 100644
index 000000000..3d582ed36
--- /dev/null
+++ b/resources/js/alpine/components/profile-edit.js
@@ -0,0 +1,65 @@
+/**
+ * Alpine.data('profileEdit') - Profile edit page (2FA toggle, theme radios)
+ * Alpine.data('profilePage') - Profile page (progress bars)
+ * Alpine.data('copyToClipboard') - Copy button with visual feedback
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('profileEdit', () => ({
+ show2faForm: false,
+
+ toggle2fa() {
+ this.show2faForm = !this.show2faForm;
+ },
+
+ cancel2fa() {
+ this.show2faForm = false;
+ const pwd = document.getElementById('disable_2fa_password');
+ if (pwd) pwd.value = '';
+ }
+}));
+
+Alpine.data('profilePage', () => ({
+ init() {
+ // Animate progress bars
+ this.$el.querySelectorAll('.progress-bar').forEach(bar => {
+ const width = bar.dataset.width;
+ if (width) setTimeout(() => { bar.style.width = width + '%'; }, 100);
+ });
+ }
+}));
+
+Alpine.data('copyToClipboard', () => ({
+ copied: false,
+
+ copy(targetId) {
+ const input = document.getElementById(targetId);
+ if (!input) return;
+ input.select();
+ input.setSelectionRange(0, 99999);
+
+ const text = input.value;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => this._showFeedback()).catch(() => this._fallbackCopy(text));
+ } else {
+ this._fallbackCopy(text);
+ }
+ },
+
+ _fallbackCopy(text) {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ ta.setSelectionRange(0, 99999);
+ try { document.execCommand('copy'); this._showFeedback(); } catch (e) { console.error('Failed to copy:', e); }
+ document.body.removeChild(ta);
+ },
+
+ _showFeedback() {
+ this.copied = true;
+ setTimeout(() => { this.copied = false; }, 2000);
+ }
+}));
diff --git a/resources/js/alpine/components/quality-filter.js b/resources/js/alpine/components/quality-filter.js
new file mode 100644
index 000000000..485073068
--- /dev/null
+++ b/resources/js/alpine/components/quality-filter.js
@@ -0,0 +1,55 @@
+/**
+ * Alpine.data('qualityFilter') - Resolution and source filter buttons
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('qualityFilter', () => ({
+ activeResolution: 'all',
+ activeSource: 'all',
+ totalReleases: 0,
+ visibleCount: 0,
+
+ init() {
+ this.totalReleases = this.$el.querySelectorAll('.release-item').length;
+ this.visibleCount = this.totalReleases;
+ },
+
+ setResolution(filter) {
+ this.activeResolution = filter;
+ this._applyFilters();
+ },
+
+ setSource(filter) {
+ this.activeSource = filter;
+ this._applyFilters();
+ },
+
+ isActiveResolution(f) { return this.activeResolution === f; },
+ isActiveSource(f) { return this.activeSource === f; },
+
+ countText() {
+ if (this.activeResolution === 'all' && this.activeSource === 'all') return '(' + this.totalReleases + ' total)';
+ return '(' + this.visibleCount + ' of ' + this.totalReleases + ')';
+ },
+
+ _applyFilters() {
+ let visible = 0;
+ this.$el.querySelectorAll('.release-item').forEach(item => {
+ const name = (item.getAttribute('data-release-name') || '').toLowerCase();
+ let matchR = true, matchS = true;
+
+ if (this.activeResolution !== 'all') matchR = name.includes(this.activeResolution.toLowerCase());
+ if (this.activeSource !== 'all') {
+ const s = this.activeSource.toLowerCase();
+ if (s === 'bluray') matchS = name.includes('bluray') || name.includes('blu-ray') || name.includes('bdrip') || name.includes('brrip');
+ else if (s === 'web-dl') matchS = name.includes('web-dl') || name.includes('webdl') || name.includes('web.dl');
+ else if (s === 'webrip') matchS = name.includes('webrip') || name.includes('web-rip') || name.includes('web.rip');
+ else matchS = name.includes(s);
+ }
+
+ if (matchR && matchS) { item.style.display = ''; visible++; }
+ else item.style.display = 'none';
+ });
+ this.visibleCount = visible;
+ }
+}));
diff --git a/resources/js/alpine/components/release-report.js b/resources/js/alpine/components/release-report.js
new file mode 100644
index 000000000..1bc3392d3
--- /dev/null
+++ b/resources/js/alpine/components/release-report.js
@@ -0,0 +1,111 @@
+/**
+ * Alpine.data('releaseReport') - Release report modal per-release
+ * Alpine.data('adminReleaseReports') - Admin release reports page
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('releaseReport', () => ({
+ open: false,
+ hasReported: false,
+ isSubmitting: false,
+ reason: '',
+ description: '',
+ errorMsg: '',
+ successMsg: '',
+
+ openModal() {
+ if (this.hasReported) return;
+ this.open = true;
+ this.reason = '';
+ this.description = '';
+ this.errorMsg = '';
+ this.successMsg = '';
+ },
+
+ close() {
+ this.open = false;
+ },
+
+ charCount() { return this.description.length + '/1000 characters'; },
+ canSubmit() { return !!this.reason && !this.isSubmitting; },
+
+ submit(releaseId) {
+ if (this.isSubmitting || !this.reason) return;
+ this.isSubmitting = true;
+ this.errorMsg = '';
+ this.successMsg = '';
+
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ fetch('/release-report', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
+ body: JSON.stringify({ release_id: releaseId, reason: this.reason, description: this.description })
+ })
+ .then(r => r.json().then(data => ({ ok: r.ok, data })))
+ .then(result => {
+ if (result.ok && result.data.success) {
+ this.successMsg = result.data.message;
+ this.hasReported = true;
+ setTimeout(() => this.close(), 2000);
+ } else {
+ this.errorMsg = result.data.message || 'An error occurred. Please try again.';
+ }
+ })
+ .catch(() => { this.errorMsg = 'Network error. Please try again.'; })
+ .finally(() => { this.isSubmitting = false; });
+ }
+}));
+
+Alpine.data('adminReleaseReports', () => ({
+ descModalOpen: false,
+ descContent: '',
+ descReason: '',
+ descReporter: '',
+ revertModalOpen: false,
+ revertActionUrl: '',
+ revertStatus: '',
+ allChecked: false,
+
+ showDescription(description, reason, reporter) {
+ this.descContent = description || 'No additional details provided.';
+ this.descReason = reason || 'Unknown';
+ this.descReporter = reporter || 'Unknown';
+ this.descModalOpen = true;
+ },
+
+ closeDescription() { this.descModalOpen = false; },
+
+ showRevert(actionUrl, status) {
+ this.revertActionUrl = actionUrl;
+ this.revertStatus = status;
+ this.revertModalOpen = true;
+ },
+
+ closeRevert() { this.revertModalOpen = false; },
+
+ submitRevert() {
+ const form = this.$refs.revertForm;
+ if (form) { form.action = this.revertActionUrl; form.submit(); }
+ },
+
+ toggleAll() {
+ const boxes = this.$el.querySelectorAll('.report-checkbox');
+ boxes.forEach(cb => { cb.checked = this.allChecked; });
+ },
+
+ onCheckboxChange() {
+ const boxes = this.$el.querySelectorAll('.report-checkbox');
+ const checked = this.$el.querySelectorAll('.report-checkbox:checked');
+ this.allChecked = boxes.length > 0 && checked.length === boxes.length;
+ },
+
+ validateBulkAction(e) {
+ const action = this.$el.querySelector('select[name="action"]')?.value;
+ const checkedCount = this.$el.querySelectorAll('.report-checkbox:checked').length;
+ if (!action) { e.preventDefault(); alert('Please select an action.'); return; }
+ if (checkedCount === 0) { e.preventDefault(); alert('Please select at least one report.'); return; }
+ if (action === 'delete') {
+ if (!confirm('Are you sure you want to DELETE ' + checkedCount + ' release(s)? This action cannot be undone.')) e.preventDefault();
+ }
+ }
+}));
diff --git a/resources/js/alpine/components/search-autocomplete.js b/resources/js/alpine/components/search-autocomplete.js
new file mode 100644
index 000000000..68f0a51db
--- /dev/null
+++ b/resources/js/alpine/components/search-autocomplete.js
@@ -0,0 +1,88 @@
+/**
+ * Alpine.data('searchAutocomplete') - Search input with autocomplete dropdown
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('searchAutocomplete', (inputId, dropdownId, formId, itemClass, maxItems) => ({
+ query: '',
+ suggestions: [],
+ currentIndex: -1,
+ open: false,
+ _debounceTimer: null,
+
+ init() {
+ // If IDs passed as data attributes, use those
+ this._inputId = inputId || this.$el.dataset.inputId;
+ this._dropdownId = dropdownId || this.$el.dataset.dropdownId;
+ this._formId = formId || this.$el.dataset.formId;
+ this._itemClass = itemClass || this.$el.dataset.itemClass || 'autocomplete-item';
+ this._maxItems = maxItems || parseInt(this.$el.dataset.maxItems) || 10;
+ },
+
+ onInput() {
+ clearTimeout(this._debounceTimer);
+ const q = this.query.trim();
+ if (q.length < 2) { this.hide(); return; }
+
+ this._debounceTimer = setTimeout(() => {
+ fetch('/api/search/autocomplete?q=' + encodeURIComponent(q))
+ .then(r => r.json())
+ .then(data => {
+ if (data.success && data.suggestions && data.suggestions.length > 0) {
+ this.suggestions = data.suggestions.slice(0, this._maxItems);
+ this.currentIndex = -1;
+ this.open = true;
+ } else {
+ this.hide();
+ }
+ })
+ .catch(() => this.hide());
+ }, 200);
+ },
+
+ onKeydown(e) {
+ if (!this.open) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ this.currentIndex = Math.min(this.currentIndex + 1, this.suggestions.length - 1);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ this.currentIndex = Math.max(this.currentIndex - 1, 0);
+ } else if (e.key === 'Enter' && this.currentIndex >= 0) {
+ e.preventDefault();
+ this.select(this.currentIndex);
+ } else if (e.key === 'Escape') {
+ this.hide();
+ }
+ },
+
+ select(index) {
+ this.query = this.suggestions[index];
+ this.hide();
+ // Submit form
+ const form = this.$refs.form || document.getElementById(this._formId);
+ if (form) form.submit();
+ },
+
+ hover(index) {
+ this.currentIndex = index;
+ },
+
+ hide() {
+ this.open = false;
+ this.suggestions = [];
+ this.currentIndex = -1;
+ },
+
+ isSelected(index) {
+ return this.currentIndex === index;
+ }
+}));
+
+// Sort dropdown
+Alpine.data('sortDropdown', () => ({
+ open: false,
+
+ toggle() { this.open = !this.open; },
+ close() { this.open = false; }
+}));
diff --git a/resources/js/alpine/components/sidebar-toggle.js b/resources/js/alpine/components/sidebar-toggle.js
new file mode 100644
index 000000000..799c781c9
--- /dev/null
+++ b/resources/js/alpine/components/sidebar-toggle.js
@@ -0,0 +1,12 @@
+/**
+ * Alpine.data('sidebarToggle') - Sidebar section collapse/expand
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('sidebarToggle', () => ({
+ open: false,
+
+ toggle() {
+ this.open = !this.open;
+ }
+}));
diff --git a/resources/js/alpine/components/tab-switcher.js b/resources/js/alpine/components/tab-switcher.js
new file mode 100644
index 000000000..694670a25
--- /dev/null
+++ b/resources/js/alpine/components/tab-switcher.js
@@ -0,0 +1,82 @@
+/**
+ * Alpine.data('tabSwitcher') - Generic tab switching
+ * Alpine.data('seasonSwitcher') - TV series season tabs
+ * Alpine.data('profileTabs') - Profile page tabs with Chart.js support
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('tabSwitcher', () => ({
+ activeTab: '',
+
+ init() {
+ // Set default to first tab trigger if none active
+ const first = this.$el.querySelector('[data-tab]');
+ if (first) this.activeTab = first.getAttribute('data-tab');
+ },
+
+ switchTab(tabId) {
+ this.activeTab = tabId;
+ },
+
+ isActive(tabId) {
+ return this.activeTab === tabId;
+ }
+}));
+
+Alpine.data('seasonSwitcher', () => ({
+ activeSeason: '',
+
+ init() {
+ const first = this.$el.querySelector('[data-season]');
+ if (first) this.activeSeason = first.getAttribute('data-season');
+ // Backward compat
+ const self = this;
+ window.switchSeason = function(n) { self.activeSeason = String(n); };
+ },
+
+ switchSeason(n) {
+ this.activeSeason = String(n);
+ },
+
+ isActive(n) {
+ return this.activeSeason === String(n);
+ }
+}));
+
+Alpine.data('profileTabs', () => ({
+ activeTab: 'general',
+ _chartsInitialized: false,
+
+ init() {
+ const hash = window.location.hash.substring(1);
+ if (hash) this.activeTab = hash;
+ },
+
+ switchTab(tabId) {
+ this.activeTab = tabId;
+ history.pushState(null, null, '#' + tabId);
+
+ // Initialize charts when API tab is first shown
+ if (tabId === 'api' && !this._chartsInitialized) {
+ this._chartsInitialized = true;
+ this._waitForChartJs();
+ }
+ },
+
+ isActive(tabId) {
+ return this.activeTab === tabId;
+ },
+
+ _waitForChartJs() {
+ let attempts = 0;
+ const check = setInterval(() => {
+ attempts++;
+ if (typeof Chart !== 'undefined') {
+ clearInterval(check);
+ if (typeof initializeProfileCharts === 'function') initializeProfileCharts();
+ } else if (attempts >= 20) {
+ clearInterval(check);
+ }
+ }, 100);
+ }
+}));
diff --git a/resources/js/alpine/components/theme-toggle.js b/resources/js/alpine/components/theme-toggle.js
new file mode 100644
index 000000000..36ad276b0
--- /dev/null
+++ b/resources/js/alpine/components/theme-toggle.js
@@ -0,0 +1,31 @@
+/**
+ * Alpine.data('themeToggle') - Theme toggle button (cycles light/dark/system)
+ * Used on the header theme button and profile edit radio buttons.
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('themeToggle', () => ({
+ cycle() {
+ Alpine.store('theme').cycle();
+ }
+}));
+
+Alpine.data('themeRadio', () => ({
+ _updating: false,
+
+ init() {
+ // Watch for external theme changes and sync radio buttons
+ this.$watch('$store.theme.current', () => {
+ if (this._updating) return;
+ this._updating = true;
+ this.$nextTick(() => { this._updating = false; });
+ });
+ },
+
+ select(value) {
+ if (this._updating) return;
+ this._updating = true;
+ Alpine.store('theme').set(value);
+ this.$nextTick(() => { this._updating = false; });
+ }
+}));
diff --git a/resources/js/alpine/components/toast-notification.js b/resources/js/alpine/components/toast-notification.js
new file mode 100644
index 000000000..a5d6dfed6
--- /dev/null
+++ b/resources/js/alpine/components/toast-notification.js
@@ -0,0 +1,19 @@
+/**
+ * Alpine.data('toastContainer') - Toast notification container
+ * Renders toast items from the global $store.toast
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.data('toastContainer', () => ({
+ items() {
+ return Alpine.store('toast').items;
+ },
+
+ dismiss(id) {
+ Alpine.store('toast').dismiss(id);
+ },
+
+ iconFor(type) {
+ return Alpine.store('toast').iconFor(type);
+ }
+}));
diff --git a/resources/js/alpine/index.js b/resources/js/alpine/index.js
new file mode 100644
index 000000000..6549c73c4
--- /dev/null
+++ b/resources/js/alpine/index.js
@@ -0,0 +1,63 @@
+/**
+ * Alpine.js CSP-safe initialization
+ * Registers all stores and components, then starts Alpine.
+ */
+import Alpine from '@alpinejs/csp';
+
+// Make Alpine globally available
+window.Alpine = Alpine;
+
+// --- Stores (must register before components that use them) ---
+import './stores/theme.js';
+import './stores/toast.js';
+import './stores/cart.js';
+
+// --- Core UI components ---
+import './components/theme-toggle.js';
+import './components/dropdown.js';
+import './components/sidebar-toggle.js';
+import './components/admin-submenu.js';
+import './components/mobile-nav.js';
+import './components/password-toggle.js';
+
+// --- Modal components ---
+import './components/confirm-modal.js';
+import './components/nfo-modal.js';
+import './components/image-modal.js';
+import './components/preview-modal.js';
+import './components/mediainfo-modal.js';
+import './components/filelist-modal.js';
+
+// --- Feature components ---
+import './components/tab-switcher.js';
+import './components/cart-button.js';
+import './components/cart-page.js';
+import './components/search-autocomplete.js';
+import './components/quality-filter.js';
+import './components/content-toggle.js';
+import './components/movies-layout.js';
+
+// --- Page-specific components ---
+import './components/release-report.js';
+import './components/profile-edit.js';
+import './components/auth-page.js';
+import './components/toast-notification.js';
+
+// --- Admin components ---
+import './components/admin/dashboard.js';
+import './components/admin/groups.js';
+import './components/admin/features.js';
+
+// --- Event bridge for existing Blade templates ---
+// Provides document-level event delegation for CSS-class-based hooks
+// until Blade templates are updated with x-data directives
+import './components/event-bridge.js';
+
+// --- Shared utility (backward compat) ---
+window.escapeHtml = function(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, m => map[m]);
+};
+
+// Start Alpine
+Alpine.start();
diff --git a/resources/js/alpine/stores/cart.js b/resources/js/alpine/stores/cart.js
new file mode 100644
index 000000000..8a6c1439d
--- /dev/null
+++ b/resources/js/alpine/stores/cart.js
@@ -0,0 +1,17 @@
+/**
+ * Alpine.store('cart') - Global cart count state
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.store('cart', {
+ count: 0,
+
+ init() {
+ const el = document.querySelector('.cart-count');
+ if (el) this.count = parseInt(el.textContent) || 0;
+ },
+
+ setCount(n) {
+ this.count = n;
+ }
+});
diff --git a/resources/js/alpine/stores/theme.js b/resources/js/alpine/stores/theme.js
new file mode 100644
index 000000000..213d3e5ca
--- /dev/null
+++ b/resources/js/alpine/stores/theme.js
@@ -0,0 +1,88 @@
+/**
+ * Alpine.store('theme') - Global theme state
+ * Manages light/dark/system theme with OS preference detection and persistence.
+ */
+import Alpine from '@alpinejs/csp';
+
+const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+Alpine.store('theme', {
+ current: 'light',
+
+ init() {
+ const meta = document.querySelector('meta[name="theme-preference"]');
+ const isAuth = document.querySelector('meta[name="user-authenticated"]');
+ this.current = (isAuth && isAuth.content === 'true')
+ ? (meta ? meta.content : 'light')
+ : (localStorage.getItem('theme') || 'light');
+
+ this.apply();
+ this._listenOS();
+ },
+
+ /** Cycle light -> dark -> system -> light */
+ cycle() {
+ const next = this.current === 'light' ? 'dark'
+ : this.current === 'dark' ? 'system' : 'light';
+ this.set(next);
+ },
+
+ /** Set and persist a specific theme */
+ set(theme) {
+ this.current = theme;
+ this.apply();
+ this._save(theme);
+ },
+
+ /** Apply the current theme to */
+ apply() {
+ const html = document.documentElement;
+ if (this.current === 'system') {
+ html.classList.toggle('dark', mediaQuery.matches);
+ } else {
+ html.classList.toggle('dark', this.current === 'dark');
+ }
+ // Update meta tag
+ const meta = document.querySelector('meta[name="theme-preference"]');
+ if (meta) meta.content = this.current;
+ },
+
+ icon() {
+ return this.current === 'dark' ? 'fa-moon'
+ : this.current === 'system' ? 'fa-desktop' : 'fa-sun';
+ },
+
+ label() {
+ return this.current === 'dark' ? 'Dark'
+ : this.current === 'system' ? 'System' : 'Light';
+ },
+
+ title() {
+ return this.current === 'dark' ? 'Theme: Dark'
+ : this.current === 'system' ? 'Theme: System (Auto)' : 'Theme: Light';
+ },
+
+ /** Listen for OS-level theme changes */
+ _listenOS() {
+ mediaQuery.addEventListener('change', () => {
+ if (this.current === 'system') this.apply();
+ });
+ },
+
+ /** Persist to server (authenticated) or localStorage (guest) */
+ _save(theme) {
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
+ const url = document.querySelector('meta[name="update-theme-url"]')?.content;
+ const isAuth = document.querySelector('meta[name="user-authenticated"]');
+
+ if (isAuth && isAuth.content === 'true' && url && csrf) {
+ fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
+ body: JSON.stringify({ theme_preference: theme })
+ }).catch(err => console.error('Error saving theme:', err));
+ } else {
+ localStorage.setItem('theme', theme);
+ }
+ }
+});
diff --git a/resources/js/alpine/stores/toast.js b/resources/js/alpine/stores/toast.js
new file mode 100644
index 000000000..daa1035e1
--- /dev/null
+++ b/resources/js/alpine/stores/toast.js
@@ -0,0 +1,68 @@
+/**
+ * Alpine.store('toast') - Global toast notification state
+ * Provides a showToast() method accessible from any Alpine component.
+ */
+import Alpine from '@alpinejs/csp';
+
+Alpine.store('toast', {
+ items: [],
+ _nextId: 0,
+
+ init() {
+ // Process server-side flash messages on load
+ this._processFlashMessages();
+ },
+
+ /** Show a toast notification */
+ show(message, type) {
+ type = type || 'success';
+ const id = ++this._nextId;
+ this.items.push({ id, message, type, removing: false });
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => this.dismiss(id), 5000);
+ },
+
+ /** Dismiss a toast by id */
+ dismiss(id) {
+ const item = this.items.find(t => t.id === id);
+ if (item) {
+ item.removing = true;
+ setTimeout(() => {
+ this.items = this.items.filter(t => t.id !== id);
+ }, 300);
+ }
+ },
+
+ /** Get icon class for a toast type */
+ iconFor(type) {
+ if (type === 'success') return 'fa-check-circle';
+ if (type === 'error') return 'fa-exclamation-circle';
+ if (type === 'warning') return 'fa-exclamation-triangle';
+ return 'fa-info-circle';
+ },
+
+ /** Process flash messages from the server */
+ _processFlashMessages() {
+ const el = document.getElementById('flash-messages-data');
+ if (!el) return;
+
+ const messages = JSON.parse(el.dataset.messages || '{}');
+
+ if (messages.success) this.show(messages.success, 'success');
+ if (messages.error) {
+ if (Array.isArray(messages.error)) {
+ messages.error.forEach(e => this.show(e, 'error'));
+ } else {
+ this.show(messages.error, 'error');
+ }
+ }
+ if (messages.warning) this.show(messages.warning, 'warning');
+ if (messages.info) this.show(messages.info, 'info');
+ }
+});
+
+// Keep backward-compatible global showToast for non-Alpine code
+window.showToast = function(message, type) {
+ Alpine.store('toast').show(message, type);
+};
diff --git a/resources/js/app.js b/resources/js/app.js
index 284b71c42..6b1617513 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,19 +1,19 @@
import './bootstrap';
-// Import CSP-safe styles and scripts
+// Import CSP-safe styles
import '../css/csp-safe.css';
-import './csp-safe.js';
+
+// Import Alpine.js CSP-safe components (replaces old csp-safe.js modules)
+import './alpine/index.js';
// Theme initialization - optimized to prevent flash of unstyled content
(function() {
'use strict';
- // Use requestAnimationFrame for better performance
const applyTheme = function() {
const metaTheme = document.querySelector('meta[name="theme-preference"]');
const themePreference = metaTheme?.content || 'light';
if (themePreference === 'system') {
- // Use OS preference with proper media query listener
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (mediaQuery.matches) {
document.documentElement.classList.add('dark');
@@ -38,4 +38,3 @@ import './csp-safe.js';
// FontAwesome icons loaded via CSS webfonts in app.css
// Do NOT import the JS bundle (all.min.js) — it adds ~1.4MB to the build
// and duplicates what the CSS webfont approach already provides.
-
diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js
deleted file mode 100644
index b3a6a898b..000000000
--- a/resources/js/csp-safe.js
+++ /dev/null
@@ -1,111 +0,0 @@
-// CSP-Safe JavaScript - Modular Orchestrator
-// All functionality has been extracted into ES modules under ./modules/
-
-// Core modules
-import { escapeHtml } from './modules/utils.js';
-import { initThemeSystem, applyTheme, saveThemePreference } from './modules/theme.js';
-import { initToastNotifications, initFlashMessages } from './modules/toast.js';
-import { initEventDelegation } from './modules/event-delegation.js';
-
-// UI modules
-import {
- initNfoModal,
- initConfirmDialogs,
- initImageModal,
- initPreviewModal,
- initMediainfoAndFilelist,
- initDetailsPageImageModal,
- initModalStyles
-} from './modules/modals.js';
-import { initAdminMenu, initSidebarToggle, initDropdownMenus, initMobileEnhancements } from './modules/navigation.js';
-import { initTabSwitcher, initSeasonSwitcher, initProfileTabs, initBinaryBlacklist } from './modules/tabs.js';
-import { initSelectRedirects, initLogoutForms, initPasswordVisibilityToggles, initRegexManagement, initImageFallbacks } from './modules/forms.js';
-
-// Feature modules
-import { initCartFunctionality } from './modules/cart.js';
-import { initSearchAutocomplete } from './modules/search.js';
-import { initProfilePage, initProfileEdit, initThemeManagement, initCopyToClipboard } from './modules/profile.js';
-import { initContentToggle, initContentDelete, initMoviesLayoutToggle, initQualityFilter, initMyMovies } from './modules/content.js';
-import { initReleaseReportButtons, initAdminReleaseReports } from './modules/releases.js';
-import { initAuthPages } from './modules/auth.js';
-
-// Admin modules
-import { initAdminDashboardCharts, initRecentActivityRefresh } from './modules/admin/dashboard.js';
-import { initAdminGroups } from './modules/admin/groups.js';
-import { initAdminUserEdit, initAdminSpecificFeatures, initTinyMCE, initVerifyUserModal, initUserListScrollSync } from './modules/admin/features.js';
-
-// Make shared utilities available globally for CSP compliance
-window.escapeHtml = escapeHtml;
-
-// Main initialization
-document.addEventListener('DOMContentLoaded', function() {
- // Initialize theme system first
- initThemeSystem();
-
- // Core functionality
- initEventDelegation();
- initToastNotifications();
-
- // Modals
- initNfoModal();
- initConfirmDialogs();
- initImageModal();
- initPreviewModal();
- initMediainfoAndFilelist();
-
- // Navigation
- initAdminMenu();
- initSidebarToggle();
- initDropdownMenus();
- initMobileEnhancements();
-
- // Forms and tabs
- initSelectRedirects();
- initLogoutForms();
- initPasswordVisibilityToggles();
- initRegexManagement();
- initImageFallbacks();
- initTabSwitcher();
- initSeasonSwitcher();
- initBinaryBlacklist();
- initProfileTabs();
-
- // Features
- initCartFunctionality();
- initSearchAutocomplete();
- initContentToggle();
- initContentDelete();
-
- // Admin
- initAdminUserEdit();
- initAdminDashboardCharts();
- initAdminGroups();
- initTinyMCE();
- initAdminSpecificFeatures();
- initRecentActivityRefresh();
-
- // Page-specific functionality
- initMyMovies();
- initAuthPages();
- initProfileEdit();
- initDetailsPageImageModal();
- initMoviesLayoutToggle();
- initProfilePage();
- initCopyToClipboard();
- initVerifyUserModal();
- initQualityFilter();
- initUserListScrollSync();
-});
-
-// Theme management and flash messages (run immediately or on DOMContentLoaded)
-(function() {
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', function() {
- initThemeManagement();
- initFlashMessages();
- });
- } else {
- initThemeManagement();
- initFlashMessages();
- }
-})();
diff --git a/resources/js/modules/admin/dashboard.js b/resources/js/modules/admin/dashboard.js
deleted file mode 100644
index ccd128e5c..000000000
--- a/resources/js/modules/admin/dashboard.js
+++ /dev/null
@@ -1,483 +0,0 @@
-/**
- * Admin Dashboard Charts and Recent Activity
- */
-
-function initAdminDashboardCharts() {
- // Only initialize if Chart.js is available and we're on a page with charts
- if (typeof Chart === 'undefined') {
- // If Chart.js is not loaded yet, wait for it
- let attempts = 0;
- const maxAttempts = 30; // Try for up to 3 seconds
- const checkChartJs = setInterval(() => {
- attempts++;
- if (typeof Chart !== 'undefined') {
- clearInterval(checkChartJs);
- initializeAllDashboardCharts();
- } else if (attempts >= maxAttempts) {
- clearInterval(checkChartJs);
- console.warn('Chart.js not loaded - charts will not be displayed');
- }
- }, 100);
- return;
- }
-
- initializeAllDashboardCharts();
-}
-
-function initializeAllDashboardCharts() {
- // Check if any chart canvas elements exist
- const hasUserStatsCharts = document.getElementById('downloadsChart') ||
- document.getElementById('apiHitsChart') ||
- document.getElementById('downloadsMinuteChart') ||
- document.getElementById('apiHitsMinuteChart');
-
- const hasSystemCharts = document.getElementById('cpuHistory24hChart') ||
- document.getElementById('ramHistory24hChart') ||
- document.getElementById('cpuHistory30dChart') ||
- document.getElementById('ramHistory30dChart');
-
- if (!hasUserStatsCharts && !hasSystemCharts) {
- return; // Not on admin dashboard or charts not present
- }
-
- // Initialize user statistics charts
- initializeUserStatCharts();
-
- // Initialize system metrics charts
- initializeSystemMetricsCharts();
-}
-
-function initializeUserStatCharts() {
- // Downloads Chart (Last 7 Days - Hourly)
- const downloadsCanvas = document.getElementById('downloadsChart');
- if (downloadsCanvas) {
- const data = JSON.parse(downloadsCanvas.dataset.chartData || '[]');
- if (data.length > 0) {
- new Chart(downloadsCanvas, {
- type: 'bar',
- data: {
- labels: data.map(item => item.time),
- datasets: [{
- label: 'Downloads',
- data: data.map(item => item.count),
- backgroundColor: 'rgba(34, 197, 94, 0.7)',
- borderColor: 'rgba(34, 197, 94, 1)',
- borderWidth: 1
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- ticks: {
- precision: 0
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // API Hits Chart (Last 7 Days - Hourly)
- const apiHitsCanvas = document.getElementById('apiHitsChart');
- if (apiHitsCanvas) {
- const data = JSON.parse(apiHitsCanvas.dataset.chartData || '[]');
- if (data.length > 0) {
- new Chart(apiHitsCanvas, {
- type: 'line',
- data: {
- labels: data.map(item => item.time),
- datasets: [{
- label: 'API Hits',
- data: data.map(item => item.count),
- backgroundColor: 'rgba(168, 85, 247, 0.2)',
- borderColor: 'rgba(168, 85, 247, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- ticks: {
- precision: 0
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // Downloads Per Minute Chart (Last 60 Minutes)
- const downloadsMinuteCanvas = document.getElementById('downloadsMinuteChart');
- if (downloadsMinuteCanvas) {
- const data = JSON.parse(downloadsMinuteCanvas.dataset.chartData || '[]');
- if (data.length > 0) {
- new Chart(downloadsMinuteCanvas, {
- type: 'line',
- data: {
- labels: data.map(item => item.time),
- datasets: [{
- label: 'Downloads',
- data: data.map(item => item.count),
- backgroundColor: 'rgba(34, 197, 94, 0.2)',
- borderColor: 'rgba(34, 197, 94, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- ticks: {
- precision: 0
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // API Hits Per Minute Chart (Last 60 Minutes)
- const apiHitsMinuteCanvas = document.getElementById('apiHitsMinuteChart');
- if (apiHitsMinuteCanvas) {
- const data = JSON.parse(apiHitsMinuteCanvas.dataset.chartData || '[]');
- if (data.length > 0) {
- new Chart(apiHitsMinuteCanvas, {
- type: 'line',
- data: {
- labels: data.map(item => item.time),
- datasets: [{
- label: 'API Hits',
- data: data.map(item => item.count),
- backgroundColor: 'rgba(168, 85, 247, 0.2)',
- borderColor: 'rgba(168, 85, 247, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- ticks: {
- precision: 0
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-}
-
-function initializeSystemMetricsCharts() {
- // CPU Usage 24h Chart
- const cpuHistory24hCanvas = document.getElementById('cpuHistory24hChart');
- if (cpuHistory24hCanvas) {
- const history = JSON.parse(cpuHistory24hCanvas.dataset.history || '[]');
- if (history.length > 0) {
- new Chart(cpuHistory24hCanvas, {
- type: 'line',
- data: {
- labels: history.map(item => item.time),
- datasets: [{
- label: 'CPU Usage %',
- data: history.map(item => item.value),
- backgroundColor: 'rgba(249, 115, 22, 0.2)',
- borderColor: 'rgba(249, 115, 22, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- max: 100,
- ticks: {
- callback: function(value) {
- return value + '%';
- }
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // RAM Usage 24h Chart
- const ramHistory24hCanvas = document.getElementById('ramHistory24hChart');
- if (ramHistory24hCanvas) {
- const history = JSON.parse(ramHistory24hCanvas.dataset.history || '[]');
- if (history.length > 0) {
- new Chart(ramHistory24hCanvas, {
- type: 'line',
- data: {
- labels: history.map(item => item.time),
- datasets: [{
- label: 'RAM Usage %',
- data: history.map(item => item.value),
- backgroundColor: 'rgba(6, 182, 212, 0.2)',
- borderColor: 'rgba(6, 182, 212, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- max: 100,
- ticks: {
- callback: function(value) {
- return value + '%';
- }
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // CPU Usage 30d Chart
- const cpuHistory30dCanvas = document.getElementById('cpuHistory30dChart');
- if (cpuHistory30dCanvas) {
- const history = JSON.parse(cpuHistory30dCanvas.dataset.history || '[]');
- if (history.length > 0) {
- new Chart(cpuHistory30dCanvas, {
- type: 'line',
- data: {
- labels: history.map(item => item.time),
- datasets: [{
- label: 'CPU Usage %',
- data: history.map(item => item.value),
- backgroundColor: 'rgba(249, 115, 22, 0.2)',
- borderColor: 'rgba(249, 115, 22, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- max: 100,
- ticks: {
- callback: function(value) {
- return value + '%';
- }
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-
- // RAM Usage 30d Chart
- const ramHistory30dCanvas = document.getElementById('ramHistory30dChart');
- if (ramHistory30dCanvas) {
- const history = JSON.parse(ramHistory30dCanvas.dataset.history || '[]');
- if (history.length > 0) {
- new Chart(ramHistory30dCanvas, {
- type: 'line',
- data: {
- labels: history.map(item => item.time),
- datasets: [{
- label: 'RAM Usage %',
- data: history.map(item => item.value),
- backgroundColor: 'rgba(6, 182, 212, 0.2)',
- borderColor: 'rgba(6, 182, 212, 1)',
- borderWidth: 2,
- fill: true,
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: true,
- scales: {
- y: {
- beginAtZero: true,
- max: 100,
- ticks: {
- callback: function(value) {
- return value + '%';
- }
- }
- }
- },
- plugins: {
- legend: {
- display: false
- }
- }
- }
- });
- }
- }
-}
-
-function initRecentActivityRefresh() {
- const activityContainer = document.getElementById('recent-activity-container');
- if (!activityContainer) {
- return; // Not on admin dashboard page
- }
-
- const refreshUrl = activityContainer.getAttribute('data-refresh-url');
- if (!refreshUrl) {
- return;
- }
-
- // Function to refresh activity data
- function refreshRecentActivity() {
- fetch(refreshUrl, {
- method: 'GET',
- headers: {
- 'X-Requested-With': 'XMLHttpRequest',
- 'Accept': 'application/json'
- },
- credentials: 'same-origin'
- })
- .then(response => {
- if (!response.ok) {
- throw new Error('Network response was not ok');
- }
- return response.json();
- })
- .then(data => {
- if (data.success && data.activities) {
- updateActivityDisplay(data.activities);
- updateLastRefreshTime();
- }
- })
- .catch(error => {
- console.error('Error refreshing recent activity:', error);
- });
- }
-
- // Function to update the activity display
- function updateActivityDisplay(activities) {
- const container = document.getElementById('recent-activity-container');
- if (!container) return;
-
- // Clear existing content
- container.innerHTML = '';
-
- if (activities.length === 0) {
- container.innerHTML = 'No recent activity
';
- return;
- }
-
- // Add each activity item
- activities.forEach(activity => {
- const activityItem = document.createElement('div');
- activityItem.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors';
-
- activityItem.innerHTML = `
-
-
-
-
-
${escapeHtml(activity.message)}
-
${escapeHtml(activity.created_at)}
-
- `;
-
- container.appendChild(activityItem);
- });
- }
-
- // Function to update last refresh time
- function updateLastRefreshTime() {
- const lastUpdatedElement = document.getElementById('activity-last-updated');
- if (lastUpdatedElement) {
- const now = new Date();
- const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- lastUpdatedElement.innerHTML = ` Last updated: ${timeString}`;
- }
- }
-
- // Helper function to escape HTML to prevent XSS
- function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- // Set up auto-refresh every 20 minutes (1200000 milliseconds)
- const refreshInterval = 20 * 60 * 1000; // 20 minutes in milliseconds
- setInterval(refreshRecentActivity, refreshInterval);
-
- // Also refresh on page visibility change (when user comes back to tab)
- document.addEventListener('visibilitychange', function() {
- if (!document.hidden) {
- refreshRecentActivity();
- }
- });
-}
-
-export { initAdminDashboardCharts, initRecentActivityRefresh };
diff --git a/resources/js/modules/admin/features.js b/resources/js/modules/admin/features.js
deleted file mode 100644
index 6a565129b..000000000
--- a/resources/js/modules/admin/features.js
+++ /dev/null
@@ -1,1233 +0,0 @@
-/**
- * Admin Features - TinyMCE, Image Preview, Invitations, Regex, Tmux Colors,
- * Deleted Users, User Edit, Verify Modal, User List Scroll Sync, Tmux Edit
- */
-
-import { escapeHtml } from '../utils.js';
-
-function initTinyMCE() {
- // Only initialize if TinyMCE editor element exists (check for #body or .tinymce-editor)
- if (!document.getElementById('body') && !document.querySelector('.tinymce-editor')) {
- return;
- }
-
- // Function to dynamically load TinyMCE from CDN
- function loadTinyMCEScript() {
- return new Promise(function(resolve, reject) {
- // Check if already loaded
- if (typeof tinymce !== 'undefined') {
- resolve();
- return;
- }
-
- // Get API key from textarea data attribute or create meta tag
- const bodyTextarea = document.getElementById('body');
- const tinymceEditors = document.querySelectorAll('.tinymce-editor');
- let apiKey = 'no-api-key';
-
- // Try to get from existing meta tag first
- let apiKeyMeta = document.querySelector('meta[name="tinymce-api-key"]');
-
- if (!apiKeyMeta) {
- // Create meta tag dynamically if it doesn't exist
- // Get API key from window config or use default
- if (window.NNTmuxConfig && window.NNTmuxConfig.tinymceApiKey) {
- apiKey = window.NNTmuxConfig.tinymceApiKey;
- } else if (bodyTextarea && bodyTextarea.dataset.tinymceApiKey) {
- apiKey = bodyTextarea.dataset.tinymceApiKey;
- } else if (tinymceEditors.length > 0) {
- // Check if any tinymce-editor has the API key
- for (let i = 0; i < tinymceEditors.length; i++) {
- if (tinymceEditors[i].dataset.tinymceApiKey) {
- apiKey = tinymceEditors[i].dataset.tinymceApiKey;
- break;
- }
- }
- }
-
- // Create and append meta tag
- apiKeyMeta = document.createElement('meta');
- apiKeyMeta.setAttribute('name', 'tinymce-api-key');
- apiKeyMeta.setAttribute('content', apiKey);
- document.head.appendChild(apiKeyMeta);
- } else {
- apiKey = apiKeyMeta.content;
- }
-
- // Create script element
- const script = document.createElement('script');
- script.src = 'https://cdn.tiny.cloud/1/' + apiKey + '/tinymce/8/tinymce.min.js';
- script.referrerPolicy = 'origin';
-
- script.onload = function() {
- console.log('TinyMCE script loaded from CDN');
- resolve();
- };
-
- script.onerror = function() {
- console.error('Failed to load TinyMCE script from CDN');
- reject(new Error('Failed to load TinyMCE'));
- };
-
- // Append to head
- document.head.appendChild(script);
- });
- }
-
- // Function to detect if dark mode is active
- function isDarkMode() {
- return document.documentElement.classList.contains('dark') ||
- (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
- }
-
- // Function to get TinyMCE config
- function getTinyMCEConfig() {
- const darkMode = isDarkMode();
- const apiKeyMeta = document.querySelector('meta[name="tinymce-api-key"]');
- const apiKey = apiKeyMeta ? apiKeyMeta.content : 'no-api-key';
-
- return {
- selector: '#body, .tinymce-editor',
- height: 500,
- menubar: true,
- skin: darkMode ? 'oxide-dark' : 'oxide',
- content_css: darkMode ? 'dark' : 'default',
- plugins: [
- 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'preview',
- 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
- 'insertdatetime', 'media', 'table', 'help', 'wordcount', 'emoticons'
- ],
- toolbar: 'undo redo | blocks fontfamily fontsize | ' +
- 'bold italic underline strikethrough | forecolor backcolor | ' +
- 'alignleft aligncenter alignright alignjustify | ' +
- 'bullist numlist outdent indent | ' +
- 'link image media table emoticons | ' +
- 'removeformat code fullscreen | help',
- toolbar_mode: 'sliding',
- content_style: 'body { font-family: Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.6; }',
- branding: false,
- promotion: false,
- resize: true,
- statusbar: true,
- elementpath: true,
- automatic_uploads: false,
- file_picker_types: 'image',
- font_family_formats: 'Arial=arial,helvetica,sans-serif; Courier New=courier new,courier,monospace; Georgia=georgia,palatino,serif; Tahoma=tahoma,arial,helvetica,sans-serif; Times New Roman=times new roman,times,serif; Verdana=verdana,geneva,sans-serif',
- font_size_formats: '8pt 10pt 12pt 14pt 16pt 18pt 24pt 36pt 48pt',
- autolink_pattern: /^(https?:\/\/|www\.|(?!www\.)[a-z0-9\-]+\.[a-z]{2,13})/i,
- link_default_protocol: 'https',
- link_assume_external_targets: true,
- link_target_list: [
- {title: 'None', value: ''},
- {title: 'New window', value: '_blank'},
- {title: 'Same window', value: '_self'}
- ],
- block_formats: 'Paragraph=p; Heading 1=h1; Heading 2=h2; Heading 3=h3; Heading 4=h4; Heading 5=h5; Heading 6=h6; Preformatted=pre; Blockquote=blockquote',
- valid_elements: '*[*]',
- extended_valid_elements: '*[*]',
- valid_children: '+body[style]',
- paste_as_text: false,
- paste_block_drop: false,
- paste_data_images: true,
- image_advtab: true,
- image_caption: true,
- image_description: true,
- image_dimensions: true,
- image_title: true,
- table_default_attributes: {
- border: '1'
- },
- table_default_styles: {
- 'border-collapse': 'collapse',
- 'width': '100%'
- },
- emoticons_database: 'emojis',
- setup: function(editor) {
- // Auto-save on content change
- editor.on('change', function() {
- editor.save();
- });
- // Auto-save on blur (when editor loses focus)
- editor.on('blur', function() {
- editor.save();
- });
- // Auto-save on keyup (for continuous saving)
- editor.on('keyup', function() {
- editor.save();
- });
- // Save before form submission
- editor.on('submit', function() {
- editor.save();
- });
- }
- };
- }
-
- // Function to actually initialize TinyMCE once it's loaded
- function doInitTinyMCE() {
- // Initialize TinyMCE
- tinymce.init(getTinyMCEConfig()).then(function(editors) {
- if (editors && editors.length > 0) {
- console.log('TinyMCE initialized successfully for ' + editors.length + ' editor(s)');
-
- // Add form submission handler to sync content for all editors
- editors.forEach(function(editor) {
- const textarea = document.getElementById(editor.id);
- if (textarea && textarea.form) {
- // Remove any existing listeners to avoid duplicates
- const form = textarea.form;
- if (!form.hasAttribute('data-tinymce-handler')) {
- form.addEventListener('submit', function(e) {
- // Sync all TinyMCE editors before form submission
- tinymce.triggerSave();
- console.log('TinyMCE content synced to textareas before form submission');
- });
- form.setAttribute('data-tinymce-handler', 'true');
- }
- }
- });
- }
- }).catch(function(error) {
- console.error('TinyMCE initialization failed:', error);
- });
-
- // Watch for theme changes and reinitialize TinyMCE
- const observer = new MutationObserver(function(mutations) {
- mutations.forEach(function(mutation) {
- if (mutation.attributeName === 'class') {
- // Get all active TinyMCE editors
- const allEditors = tinymce.editors;
- if (allEditors && allEditors.length > 0) {
- // Save content from all editors
- const editorContents = {};
- allEditors.forEach(function(editor) {
- editorContents[editor.id] = editor.getContent();
- });
-
- // Remove all editors
- tinymce.remove();
-
- // Reinitialize with new theme
- tinymce.init(getTinyMCEConfig()).then(function(editors) {
- // Restore content to each editor
- if (editors && editors.length > 0) {
- editors.forEach(function(editor) {
- if (editorContents[editor.id]) {
- editor.setContent(editorContents[editor.id]);
- }
- });
- }
- });
- }
- }
- });
- });
-
- // Start observing theme changes on the html element
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['class']
- });
- }
-
- // Load TinyMCE script dynamically, then initialize
- loadTinyMCEScript()
- .then(function() {
- console.log('TinyMCE available, initializing editor...');
- doInitTinyMCE();
- })
- .catch(function(error) {
- console.error('Failed to load TinyMCE:', error);
- // Show error message to user for all TinyMCE textareas
- const textareas = document.querySelectorAll('#body, .tinymce-editor');
- textareas.forEach(function(textarea) {
- if (textarea && textarea.parentElement) {
- const errorDiv = document.createElement('div');
- errorDiv.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded';
- errorDiv.innerHTML = ' TinyMCE editor failed to load. Please refresh the page or check your internet connection.';
- textarea.parentElement.insertBefore(errorDiv, textarea.nextSibling);
- }
- });
- });
-}
-
-// Admin Image Preview functionality
-function initAdminImagePreview() {
- // Handle admin page image previews (for cover images, etc.)
- // This uses the existing image modal functionality
- document.addEventListener('click', function(e) {
- const imageTrigger = e.target.closest('[data-admin-image-preview]');
- if (imageTrigger) {
- e.preventDefault();
- const imageUrl = imageTrigger.getAttribute('data-admin-image-preview');
- if (imageUrl && typeof openImageModal === 'function') {
- openImageModal(imageUrl);
- }
- }
- });
-
- // Preview image on file input change (for admin edit pages)
- document.querySelectorAll('input[type="file"][accept*="image"]').forEach(function(fileInput) {
- fileInput.addEventListener('change', function(e) {
- const file = e.target.files[0];
- if (file && file.type.startsWith('image/')) {
- const reader = new FileReader();
- reader.onload = function(event) {
- // Find preview container
- const previewContainer = fileInput.closest('div').querySelector('.image-preview');
- if (previewContainer) {
- let previewImg = previewContainer.querySelector('img');
- if (!previewImg) {
- previewImg = document.createElement('img');
- previewImg.className = 'w-full h-auto rounded-lg';
- previewContainer.innerHTML = '';
- previewContainer.appendChild(previewImg);
- }
- previewImg.src = event.target.result;
- }
- };
- reader.readAsDataURL(file);
- }
- });
- });
-}
-
-// Admin Invitations Select All functionality
-function initAdminInvitationsSelectAll() {
- const selectAllCheckbox = document.getElementById('select_all');
- if (!selectAllCheckbox) {
- return; // Not on invitations page
- }
-
- const invitationCheckboxes = document.querySelectorAll('.invitation-checkbox');
-
- // Handle select all checkbox change
- selectAllCheckbox.addEventListener('change', function() {
- invitationCheckboxes.forEach(checkbox => {
- checkbox.checked = this.checked;
- });
- });
-
- // Handle individual checkbox changes to update select all state
- invitationCheckboxes.forEach(checkbox => {
- checkbox.addEventListener('change', function() {
- const allChecked = Array.from(invitationCheckboxes).every(cb => cb.checked);
- const someChecked = Array.from(invitationCheckboxes).some(cb => cb.checked);
- selectAllCheckbox.checked = allChecked;
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
- });
- });
-
- // Initialize the select all state on page load
- const allChecked = invitationCheckboxes.length > 0 && Array.from(invitationCheckboxes).every(cb => cb.checked);
- selectAllCheckbox.checked = allChecked;
-}
-
-// Admin Regex Form Validation
-function initAdminRegexFormValidation() {
- const regexForm = document.getElementById('regexForm');
- if (!regexForm) {
- return; // Not on a regex edit page
- }
-
- // Validate regex pattern on form submit
- regexForm.addEventListener('submit', function(e) {
- const regexInputs = regexForm.querySelectorAll('input[name*="regex"], textarea[name*="regex"]');
- let isValid = true;
- const errors = [];
-
- regexInputs.forEach(input => {
- const value = input.value.trim();
- if (value && input.hasAttribute('required')) {
- // Basic regex validation - check if it looks like a valid regex pattern
- // Regex should typically start with a delimiter (/, #, ~, etc.)
- // and have matching delimiters
- if (value.length > 0) {
- const firstChar = value[0];
- const delimiters = ['/', '#', '~', '%', '@', '!',];
-
- if (delimiters.includes(firstChar)) {
- // Check if regex has matching closing delimiter
- let delimiterCount = 0;
- let flags = '';
- for (let i = 0; i < value.length; i++) {
- if (value[i] === firstChar && i > 0) {
- delimiterCount++;
- // Check if there are flags after this delimiter
- flags = value.substring(i + 1);
- break;
- }
- }
-
- if (delimiterCount === 0) {
- isValid = false;
- errors.push(`Regex pattern "${input.name}" is missing closing delimiter "${firstChar}"`);
- input.classList.add('border-red-500');
- } else {
- input.classList.remove('border-red-500');
- // Validate flags if present
- if (flags && !/^[gimsux]*$/.test(flags)) {
- isValid = false;
- errors.push(`Invalid regex flags in "${input.name}". Allowed: g, i, m, s, u, x`);
- input.classList.add('border-red-500');
- }
- }
- } else if (value.length > 0) {
- // Regex doesn't start with a delimiter, might still be valid but warn
- input.classList.add('border-yellow-500');
- }
- }
- }
- });
-
- if (!isValid && errors.length > 0) {
- e.preventDefault();
- const errorMsg = errors.join('\n');
- if (typeof showToast === 'function') {
- showToast('Please fix regex validation errors', 'error');
- } else {
- alert(errorMsg);
- }
- }
- });
-
- // Real-time validation feedback for regex inputs
- const regexInputs = regexForm.querySelectorAll('input[name*="regex"], textarea[name*="regex"]');
- regexInputs.forEach(input => {
- input.addEventListener('blur', function() {
- const value = this.value.trim();
- if (value) {
- const firstChar = value[0];
- const delimiters = ['/', '#', '~', '%', '@', '!',];
-
- if (delimiters.includes(firstChar)) {
- let delimiterCount = 0;
- for (let i = 1; i < value.length; i++) {
- if (value[i] === firstChar) {
- delimiterCount++;
- break;
- }
- }
-
- if (delimiterCount === 0) {
- this.classList.add('border-red-500');
- this.classList.remove('border-green-500');
- } else {
- this.classList.remove('border-red-500');
- this.classList.add('border-green-500');
- }
- } else {
- this.classList.remove('border-red-500', 'border-green-500');
- }
- } else {
- this.classList.remove('border-red-500', 'border-green-500');
- }
- });
- });
-}
-
-// Admin Tmux Select Colors functionality
-function initAdminTmuxSelectColors() {
- // This function is for future tmux color selection functionality
- // Currently, no color selection is needed in tmux settings
- // If color pickers are added to tmux-edit page in the future, add them here
-
- // Check if we're on a page that might have color inputs
- const colorInputs = document.querySelectorAll('input[type="color"], select[data-color-select]');
- if (colorInputs.length > 0) {
- colorInputs.forEach(function(input) {
- // Add color preview functionality if needed
- if (input.type === 'color') {
- input.addEventListener('change', function() {
- // Update preview or related elements if needed
- const preview = input.closest('.form-group')?.querySelector('.color-preview');
- if (preview) {
- preview.style.backgroundColor = input.value;
- }
- });
- }
- });
- }
-}
-
-// Initialize all admin-specific features
-function initAdminSpecificFeatures() {
- initAdminImagePreview();
- initAdminInvitationsSelectAll();
- initAdminRegexFormValidation();
- initAdminTmuxSelectColors();
- initAdminDeletedUsers();
-
- // Close delete modal when clicking outside
- const deleteModal = document.getElementById('deleteModal');
- if (deleteModal) {
- deleteModal.addEventListener('click', function(e) {
- if (e.target === this) {
- closeDeleteModal();
- }
- });
- }
-
- // Close verify modal when clicking outside
- const verifyUserModal = document.getElementById('verifyUserModal');
- if (verifyUserModal) {
- verifyUserModal.addEventListener('click', function(event) {
- if (event.target === this) {
- hideVerifyModal();
- }
- });
- }
-}
-
-// Admin Deleted Users page functionality
-function initAdminDeletedUsers() {
- // Select all checkbox
- const selectAllCheckbox = document.getElementById('selectAll');
- if (selectAllCheckbox) {
- selectAllCheckbox.addEventListener('change', function() {
- const checkboxes = document.querySelectorAll('.user-checkbox');
- checkboxes.forEach(checkbox => {
- checkbox.checked = this.checked;
- });
- });
- }
-
- // Update selectAll state when individual checkboxes change
- const userCheckboxes = document.querySelectorAll('.user-checkbox');
- userCheckboxes.forEach(checkbox => {
- checkbox.addEventListener('change', function() {
- const allChecked = Array.from(userCheckboxes).every(cb => cb.checked);
- const someChecked = Array.from(userCheckboxes).some(cb => cb.checked);
- if (selectAllCheckbox) {
- selectAllCheckbox.checked = allChecked;
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
- }
- });
- });
-
- // Bulk action form submit handler
- const bulkActionForm = document.getElementById('bulkActionForm');
- if (bulkActionForm) {
- bulkActionForm.addEventListener('submit', function(e) {
- e.preventDefault();
-
- const action = document.getElementById('bulkAction')?.value;
- const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
- const validationError = document.getElementById('validationError');
- const validationErrorMessage = document.getElementById('validationErrorMessage');
-
- // Hide any previous validation errors
- if (validationError) {
- validationError.classList.add('hidden');
- }
-
- // Validate action selected
- if (!action) {
- if (validationError && validationErrorMessage) {
- validationErrorMessage.textContent = 'Please select an action from the dropdown.';
- validationError.classList.remove('hidden');
- }
- return false;
- }
-
- // Validate at least one user selected
- if (checkedBoxes.length === 0) {
- if (validationError && validationErrorMessage) {
- validationErrorMessage.textContent = 'Please select at least one user.';
- validationError.classList.remove('hidden');
- }
- return false;
- }
-
- const count = checkedBoxes.length;
- const actionText = action === 'restore' ? 'restore' : 'permanently delete';
- const type = action === 'restore' ? 'success' : 'danger';
- const title = action === 'restore' ? 'Restore Users' : 'Delete Users';
-
- showConfirm({
- title: title,
- message: `Are you sure you want to ${actionText} ${count} user${count > 1 ? 's' : ''}?`,
- type: type,
- confirmText: action === 'restore' ? 'Restore' : 'Delete',
- onConfirm: function() {
- bulkActionForm.submit();
- }
- });
- });
- }
-
- // Event delegation for restore buttons
- document.addEventListener('click', function(e) {
- const restoreBtn = e.target.closest('.restore-user-btn');
- if (restoreBtn) {
- e.preventDefault();
- const userId = restoreBtn.dataset.userId;
- const username = restoreBtn.dataset.username;
- if (userId && username) {
- restoreUser(userId, username);
- }
- }
-
- const deleteBtn = e.target.closest('.delete-user-btn');
- if (deleteBtn) {
- e.preventDefault();
- const userId = deleteBtn.dataset.userId;
- const username = deleteBtn.dataset.username;
- if (userId && username) {
- permanentDeleteUser(userId, username);
- }
- }
- });
-
- // Bulk action confirmation (kept for backward compatibility)
- window.confirmBulkAction = function(event) {
- event?.preventDefault();
-
- const action = document.getElementById('bulkAction')?.value;
- const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
- const validationError = document.getElementById('validationError');
- const validationErrorMessage = document.getElementById('validationErrorMessage');
-
- if (!action) {
- if (validationError && validationErrorMessage) {
- validationErrorMessage.textContent = 'Please select an action from the dropdown.';
- validationError.classList.remove('hidden');
- }
- return false;
- }
-
- if (checkedBoxes.length === 0) {
- if (validationError && validationErrorMessage) {
- validationErrorMessage.textContent = 'Please select at least one user.';
- validationError.classList.remove('hidden');
- }
- return false;
- }
-
- // Hide validation error if showing
- if (validationError) {
- validationError.classList.add('hidden');
- }
-
- const count = checkedBoxes.length;
- const actionText = action === 'restore' ? 'restore' : 'permanently delete';
- const type = action === 'restore' ? 'success' : 'danger';
- const title = action === 'restore' ? 'Restore Users' : 'Delete Users';
-
- showConfirm({
- title: title,
- message: `Are you sure you want to ${actionText} ${count} user${count > 1 ? 's' : ''}?`,
- type: type,
- confirmText: action === 'restore' ? 'Restore' : 'Delete',
- onConfirm: function() {
- document.getElementById('bulkActionForm')?.submit();
- }
- });
-
- return false;
- };
-}
-
-// Individual user restore/delete actions for deleted users page
-window.restoreUser = function(userId, username) {
- showConfirm({
- title: 'Restore User',
- message: `Are you sure you want to restore user '${username}'?`,
- type: 'success',
- confirmText: 'Restore',
- onConfirm: function() {
- const form = document.getElementById('individualActionForm');
- if (form) {
- const baseUrl = window.location.origin;
- form.action = `${baseUrl}/admin/deleted-users/restore/${userId}`;
- form.submit();
- }
- }
- });
-};
-
-window.permanentDeleteUser = function(userId, username) {
- showConfirm({
- title: 'Permanently Delete User',
- message: `Are you sure you want to PERMANENTLY delete user '${username}'?`,
- details: 'This action cannot be undone!',
- type: 'danger',
- confirmText: 'Delete Permanently',
- onConfirm: function() {
- const form = document.getElementById('individualActionForm');
- if (form) {
- const baseUrl = window.location.origin;
- // Use the correct route: permanent-delete
- form.action = `${baseUrl}/admin/deleted-users/permanent-delete/${userId}`;
- form.submit();
- }
- }
- });
-};
-
-function initAdminUserEdit() {
- // Only run if we're on the admin user edit page
- if (!document.getElementById('rolechangedate')) {
- return;
- }
-
- initializeDateTimePicker();
- setupTypeToSelect();
- setupExpiryDateHandlers();
-}
-
-// Initialize the datetime picker with existing values
-function initializeDateTimePicker() {
- const hiddenInput = document.getElementById('rolechangedate');
- if (!hiddenInput) return;
-
- if (hiddenInput.value) {
- const date = new Date(hiddenInput.value);
- if (!isNaN(date.getTime())) {
- document.getElementById('expiry_year').value = date.getFullYear().toString();
- document.getElementById('expiry_month').value = String(date.getMonth() + 1).padStart(2, '0');
-
- // Update valid days before setting the day value
- if (typeof updateValidDays === 'function') {
- updateValidDays();
- }
-
- document.getElementById('expiry_day').value = String(date.getDate()).padStart(2, '0');
- document.getElementById('expiry_hour').value = String(date.getHours()).padStart(2, '0');
- document.getElementById('expiry_minute').value = String(date.getMinutes()).padStart(2, '0');
- updateDateTimePreview();
- }
- }
-
- // Add change listeners to all selectors
- ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'].forEach(id => {
- const element = document.getElementById(id);
- if (element) {
- element.addEventListener('change', updateDateTime);
- }
- });
-
- // Add listeners for year and month changes to update valid days
- const yearSelect = document.getElementById('expiry_year');
- const monthSelect = document.getElementById('expiry_month');
- if (yearSelect && monthSelect) {
- yearSelect.addEventListener('change', updateValidDays);
- monthSelect.addEventListener('change', updateValidDays);
- }
-}
-
-// Update valid days based on selected month and year
-function updateValidDays() {
- const yearSelect = document.getElementById('expiry_year');
- const monthSelect = document.getElementById('expiry_month');
- const daySelect = document.getElementById('expiry_day');
-
- if (!yearSelect || !monthSelect || !daySelect) return;
-
- const year = parseInt(yearSelect.value);
- const month = parseInt(monthSelect.value);
-
- if (!year || !month) return;
-
- // Get number of days in the selected month
- const daysInMonth = new Date(year, month, 0).getDate();
-
- // Store current selection
- const currentDay = parseInt(daySelect.value);
-
- // Clear and rebuild day options
- daySelect.innerHTML = '-- ';
-
- for (let d = 1; d <= daysInMonth; d++) {
- const option = document.createElement('option');
- option.value = String(d).padStart(2, '0');
- option.textContent = d;
- daySelect.appendChild(option);
- }
-
- // Restore selection if still valid
- if (currentDay && currentDay <= daysInMonth) {
- daySelect.value = String(currentDay).padStart(2, '0');
- } else if (currentDay > daysInMonth) {
- // If previously selected day is now invalid, clear it and show warning
- daySelect.value = '';
-
- // Flash the day selector to indicate it needs attention
- daySelect.classList.add('border-yellow-500', 'dark:border-yellow-400', 'bg-yellow-50', 'dark:bg-yellow-900/20');
- setTimeout(() => {
- daySelect.classList.remove('border-yellow-500', 'dark:border-yellow-400', 'bg-yellow-50', 'dark:bg-yellow-900/20');
- }, 1500);
- }
-}
-
-// Setup type-to-select functionality for all dropdowns
-function setupTypeToSelect() {
- const selects = ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'];
-
- selects.forEach(selectId => {
- const select = document.getElementById(selectId);
- if (!select) return;
-
- let typedValue = '';
- let typingTimer;
-
- select.addEventListener('keypress', function(e) {
- clearTimeout(typingTimer);
-
- // Add typed character
- typedValue += e.key;
-
- // Find matching option
- const options = Array.from(this.options);
- const match = options.find(opt =>
- opt.value.startsWith(typedValue) ||
- opt.text.toLowerCase().startsWith(typedValue.toLowerCase())
- );
-
- if (match) {
- this.value = match.value;
- updateDateTime();
-
- // Visual feedback
- this.classList.add('ring-2', 'ring-green-500', 'dark:ring-green-400');
- setTimeout(() => {
- this.classList.remove('ring-2', 'ring-green-500', 'dark:ring-green-400');
- }, 300);
- }
-
- // Clear typed value after 1 second
- typingTimer = setTimeout(() => {
- typedValue = '';
- }, 1000);
- });
- });
-}
-
-// Update the hidden input and preview when any selector changes
-function updateDateTime() {
- const year = document.getElementById('expiry_year')?.value;
- const month = document.getElementById('expiry_month')?.value;
- const day = document.getElementById('expiry_day')?.value;
- const hour = document.getElementById('expiry_hour')?.value;
- const minute = document.getElementById('expiry_minute')?.value;
-
- if (year && month && day && hour && minute) {
- const dateTimeStr = `${year}-${month}-${day}T${hour}:${minute}:00`;
- document.getElementById('rolechangedate').value = dateTimeStr;
- updateDateTimePreview();
-
- // Show success flash on all filled selectors
- ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'].forEach(id => {
- const el = document.getElementById(id);
- if (el && el.value) {
- el.classList.add('border-green-500', 'dark:border-green-400');
- setTimeout(() => {
- el.classList.remove('border-green-500', 'dark:border-green-400');
- }, 500);
- }
- });
- } else {
- const preview = document.getElementById('datetime_preview');
- if (preview) {
- preview.classList.add('hidden');
- }
- }
-}
-
-// Update the datetime preview display
-function updateDateTimePreview() {
- const hiddenInput = document.getElementById('rolechangedate');
- const preview = document.getElementById('datetime_preview');
- const display = document.getElementById('datetime_display');
-
- if (!hiddenInput || !preview || !display) return;
-
- if (hiddenInput.value) {
- const date = new Date(hiddenInput.value);
- const options = {
- weekday: 'short',
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- };
- display.textContent = date.toLocaleDateString('en-US', options);
- preview.classList.remove('hidden');
-
- // Check if date is in past or expiring soon
- const now = new Date();
- const diff = date - now;
- if (diff < 0) {
- display.classList.add('text-red-600', 'dark:text-red-400');
- display.classList.remove('text-blue-600', 'dark:text-blue-400', 'text-yellow-600', 'dark:text-yellow-400');
- } else if (diff < 7 * 24 * 60 * 60 * 1000) {
- display.classList.add('text-yellow-600', 'dark:text-yellow-400');
- display.classList.remove('text-blue-600', 'dark:text-blue-400', 'text-red-600', 'dark:text-red-400');
- } else {
- display.classList.add('text-blue-600', 'dark:text-blue-400');
- display.classList.remove('text-red-600', 'dark:text-red-400', 'text-yellow-600', 'dark:text-yellow-400');
- }
- } else {
- preview.classList.add('hidden');
- }
-}
-
-// Setup event handlers for expiry date quick actions
-function setupExpiryDateHandlers() {
- // Event delegation for all expiry date buttons
- document.addEventListener('click', function(e) {
- const target = e.target.closest('[data-expiry-action]');
- if (!target) return;
-
- e.preventDefault();
- const action = target.getAttribute('data-expiry-action');
- const days = parseInt(target.getAttribute('data-days') || '0', 10);
- const hours = parseInt(target.getAttribute('data-hours') || '0', 10);
-
- if (action === 'set') {
- setExpiryDateTime(days, hours);
- } else if (action === 'end-of-day') {
- setEndOfDay();
- } else if (action === 'clear') {
- clearExpiryDate();
- }
- });
-}
-
-// Function to set expiry date and time by adding days and hours (stackable)
-function setExpiryDateTime(days, hours) {
- let baseDate;
- let isAddingTime = false;
-
- // First, check if user has an existing expiry date (from the original user data)
- const originalUserExpiry = document.getElementById('original_user_expiry')?.value;
-
- // Check if there's already a selected datetime in the form
- const year = document.getElementById('expiry_year')?.value;
- const month = document.getElementById('expiry_month')?.value;
- const day = document.getElementById('expiry_day')?.value;
- const hour = document.getElementById('expiry_hour')?.value;
- const minute = document.getElementById('expiry_minute')?.value;
-
- if (year && month && day && hour && minute) {
- // Use existing selected datetime as base (user is modifying an already set date)
- baseDate = new Date(year, parseInt(month) - 1, day, hour, minute);
- isAddingTime = true;
- showExpiryToast('Added ' + (days > 0 ? days + ' day' + (days !== 1 ? 's' : '') : '') + (days > 0 && hours > 0 ? ' and ' : '') + (hours > 0 ? hours + ' hour' + (hours !== 1 ? 's' : '') : ''), 'info');
- } else if (originalUserExpiry && originalUserExpiry !== '') {
- // User has an existing expiry date - add time from that date (proper stacking)
- baseDate = new Date(originalUserExpiry);
- isAddingTime = true;
- const originalDate = formatDateTimeForDisplay(new Date(originalUserExpiry));
- showExpiryToast('Adding time from current expiry: ' + originalDate, 'info');
- } else {
- // No existing date - start from current time
- baseDate = new Date();
- showExpiryToast('Setting expiry date from now', 'success');
- }
-
- // Add the days and hours
- baseDate.setDate(baseDate.getDate() + days);
- baseDate.setHours(baseDate.getHours() + hours);
-
- // Update all selectors
- document.getElementById('expiry_year').value = baseDate.getFullYear().toString();
- document.getElementById('expiry_month').value = String(baseDate.getMonth() + 1).padStart(2, '0');
-
- // Update valid days before setting the day value
- if (typeof updateValidDays === 'function') {
- updateValidDays();
- }
-
- document.getElementById('expiry_day').value = String(baseDate.getDate()).padStart(2, '0');
- document.getElementById('expiry_hour').value = String(baseDate.getHours()).padStart(2, '0');
- document.getElementById('expiry_minute').value = String(baseDate.getMinutes()).padStart(2, '0');
-
- updateDateTime();
-
- // Add animated visual feedback
- ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'].forEach(id => {
- const el = document.getElementById(id);
- if (el) {
- el.classList.add('ring-2', 'ring-green-500', 'dark:ring-green-400', 'scale-105');
- setTimeout(() => {
- el.classList.remove('ring-2', 'ring-green-500', 'dark:ring-green-400', 'scale-105');
- }, 600);
- }
- });
-}
-
-// Function to set expiry to end of current day (23:59)
-function setEndOfDay() {
- const endOfDay = new Date();
- endOfDay.setHours(23, 59, 0, 0);
-
- document.getElementById('expiry_year').value = endOfDay.getFullYear().toString();
- document.getElementById('expiry_month').value = String(endOfDay.getMonth() + 1).padStart(2, '0');
-
- // Update valid days before setting the day value
- if (typeof updateValidDays === 'function') {
- updateValidDays();
- }
-
- document.getElementById('expiry_day').value = String(endOfDay.getDate()).padStart(2, '0');
- document.getElementById('expiry_hour').value = '23';
- document.getElementById('expiry_minute').value = '59';
-
- updateDateTime();
-
- ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'].forEach(id => {
- const el = document.getElementById(id);
- if (el) {
- el.classList.add('ring-2', 'ring-indigo-500', 'dark:ring-indigo-400', 'scale-105');
- setTimeout(() => {
- el.classList.remove('ring-2', 'ring-indigo-500', 'dark:ring-indigo-400', 'scale-105');
- }, 600);
- }
- });
-
- showExpiryToast('Expiry set to end of today (23:59)', 'info');
-}
-
-// Function to clear expiry date
-function clearExpiryDate() {
- document.getElementById('expiry_year').value = '';
- document.getElementById('expiry_month').value = '';
- document.getElementById('expiry_day').value = '';
- document.getElementById('expiry_hour').value = '';
- document.getElementById('expiry_minute').value = '';
- document.getElementById('rolechangedate').value = '';
- const preview = document.getElementById('datetime_preview');
- if (preview) {
- preview.classList.add('hidden');
- }
-
- // Add a visual feedback with gray pulse
- ['expiry_year', 'expiry_month', 'expiry_day', 'expiry_hour', 'expiry_minute'].forEach(id => {
- const el = document.getElementById(id);
- if (el) {
- el.classList.add('ring-2', 'ring-gray-500', 'dark:ring-gray-400', 'scale-105');
- setTimeout(() => {
- el.classList.remove('ring-2', 'ring-gray-500', 'dark:ring-gray-400', 'scale-105');
- }, 600);
- }
- });
-
- showExpiryToast('Expiry date cleared - role is now permanent', 'info');
-}
-
-// Format date for display
-function formatDateTimeForDisplay(date) {
- const options = {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- };
- return date.toLocaleDateString('en-US', options);
-}
-
-// Show toast notification specific to expiry date changes
-function showExpiryToast(message, type) {
- type = type || 'success';
-
- // Remove existing toast if any
- const existingToast = document.getElementById('expiryToast');
- if (existingToast) {
- existingToast.remove();
- }
-
- // Create toast
- const toast = document.createElement('div');
- toast.id = 'expiryToast';
- toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 z-50 transform transition-all duration-300 translate-y-0 opacity-100';
-
- if (type === 'success') {
- toast.classList.add('bg-green-500', 'dark:bg-green-600', 'text-white');
- toast.innerHTML = '' + escapeHtml(message) + ' ';
- } else if (type === 'info') {
- toast.classList.add('bg-blue-500', 'dark:bg-blue-600', 'text-white');
- toast.innerHTML = '' + escapeHtml(message) + ' ';
- }
-
- document.body.appendChild(toast);
-
- // Animate in
- setTimeout(() => {
- toast.style.transform = 'translateY(0)';
- toast.style.opacity = '1';
- }, 10);
-
- // Remove after 3 seconds with fade out
- setTimeout(() => {
- toast.style.transform = 'translateY(20px)';
- toast.style.opacity = '0';
- setTimeout(() => toast.remove(), 300);
- }, 3000);
-}
-
-function initVerifyUserModal() {
- let verifyForm = null;
-
- // Show verify modal
- window.showVerifyModal = function(event, form) {
- event.preventDefault();
- verifyForm = form;
- const modal = document.getElementById('verifyUserModal');
- if (modal) {
- modal.classList.remove('hidden');
- modal.classList.add('flex');
- }
- };
-
- // Hide verify modal
- window.hideVerifyModal = function() {
- const modal = document.getElementById('verifyUserModal');
- if (modal) {
- modal.classList.add('hidden');
- modal.classList.remove('flex');
- }
- verifyForm = null;
- };
-
- // Submit verify form
- window.submitVerifyForm = function() {
- if (verifyForm) {
- verifyForm.submit();
- } else {
- // Fallback: find form by data attribute
- const formId = document.querySelector('[data-show-verify-modal]')?.getAttribute('data-form-id');
- if (formId) {
- const form = document.querySelector(`form[action*="admin.verify"] input[value="${formId}"]`)?.closest('form');
- if (form) {
- form.submit();
- }
- }
- }
- hideVerifyModal();
- };
-
- // Event delegation for verify modal
- document.addEventListener('click', function(e) {
- if (e.target.hasAttribute('data-show-verify-modal') || e.target.closest('[data-show-verify-modal]')) {
- const element = e.target.hasAttribute('data-show-verify-modal') ? e.target : e.target.closest('[data-show-verify-modal]');
- const form = element.closest('form');
- if (form) {
- showVerifyModal(e, form);
- }
- }
-
- if (e.target.hasAttribute('data-close-verify-modal') || e.target.closest('[data-close-verify-modal]')) {
- e.preventDefault();
- hideVerifyModal();
- }
-
- if (e.target.hasAttribute('data-submit-verify-form') || e.target.closest('[data-submit-verify-form]')) {
- e.preventDefault();
- submitVerifyForm();
- }
- });
-
- // Close on Escape key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- const modal = document.getElementById('verifyUserModal');
- if (modal && !modal.classList.contains('hidden')) {
- hideVerifyModal();
- }
- }
- });
-}
-
-function initUserListScrollSync() {
- const topScroll = document.getElementById('topScroll');
- const bottomScroll = document.getElementById('bottomScroll');
- const topScrollContent = document.getElementById('topScrollContent');
- const table = bottomScroll ? bottomScroll.querySelector('table') : null;
-
- if (!topScroll || !bottomScroll || !topScrollContent || !table) {
- return; // Elements not found, likely not on user list page
- }
-
- // Set the width of the top scroll content to match the table width
- function updateTopScrollWidth() {
- topScrollContent.style.width = table.scrollWidth + 'px';
- }
-
- // Initial width setup
- updateTopScrollWidth();
-
- // Update on window resize
- window.addEventListener('resize', updateTopScrollWidth);
-
- // Synchronize scrolling from top to bottom
- topScroll.addEventListener('scroll', function() {
- if (!topScroll.isSyncing) {
- bottomScroll.isSyncing = true;
- bottomScroll.scrollLeft = topScroll.scrollLeft;
- bottomScroll.isSyncing = false;
- }
- });
-
- // Synchronize scrolling from bottom to top
- bottomScroll.addEventListener('scroll', function() {
- if (!bottomScroll.isSyncing) {
- topScroll.isSyncing = true;
- topScroll.scrollLeft = bottomScroll.scrollLeft;
- topScroll.isSyncing = false;
- }
- });
-}
-
-// Tmux Edit - Remove Crap Releases Toggle
-function toggleCrapTypes(value) {
- const container = document.getElementById('crap_types_container');
- if (container) {
- if (value === 'Custom') {
- container.style.display = 'block';
- // Add a slight animation effect
- setTimeout(() => {
- container.style.opacity = '1';
- }, 10);
- } else {
- container.style.opacity = '0.5';
- setTimeout(() => {
- container.style.display = 'none';
- container.style.opacity = '1';
- }, 200);
- }
- }
-}
-
-// Initialize tmux edit page functionality
-function initTmuxEdit() {
- // Initialize crap types toggle on page load
- const checkedRadio = document.querySelector('input[name="fix_crap_opt"]:checked');
- if (checkedRadio) {
- toggleCrapTypes(checkedRadio.value);
- }
-
- // Add event listeners to all radio buttons
- document.querySelectorAll('input[name="fix_crap_opt"]').forEach(function(radio) {
- radio.addEventListener('change', function() {
- toggleCrapTypes(this.value);
- });
- });
-}
-
-// Initialize on DOMContentLoaded if on tmux-edit page
-if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', function() {
- if (document.getElementById('crap_types_container')) {
- initTmuxEdit();
- }
- });
-} else {
- if (document.getElementById('crap_types_container')) {
- initTmuxEdit();
- }
-}
-
-export { initAdminUserEdit, initAdminSpecificFeatures, initTinyMCE, initVerifyUserModal, initUserListScrollSync };
diff --git a/resources/js/modules/admin/groups.js b/resources/js/modules/admin/groups.js
deleted file mode 100644
index 129145ba7..000000000
--- a/resources/js/modules/admin/groups.js
+++ /dev/null
@@ -1,497 +0,0 @@
-/**
- * Admin Groups Management
- */
-
-import { escapeHtml } from '../utils.js';
-
-function initAdminGroups() {
- // Get AJAX URL and CSRF token from page
- const container = document.querySelector('[data-ajax-url]');
- const ajaxUrl = container ? container.dataset.ajaxUrl : '/admin/ajax';
- const csrfToken = container ? container.dataset.csrfToken : document.querySelector('meta[name="csrf-token"]')?.content;
-
- // Toggle group active/inactive status
- window.ajax_group_status = function(id, status) {
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'toggle_group_active_status',
- group_id: id,
- group_status: status
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const groupCell = document.getElementById('group-' + id);
- if (groupCell && data.newStatus !== undefined) {
- const isActive = data.newStatus == 1;
- groupCell.innerHTML = isActive
- ? `
- Active
- `
- : `
- Inactive
- `;
- }
- if (typeof showToast === 'function') {
- showToast(data.message || 'Group status updated', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error updating group status', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error updating group status', 'error');
- }
- });
- };
-
- // Toggle backfill status
- window.ajax_backfill_status = function(id, status) {
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'toggle_group_backfill',
- group_id: id,
- backfill: status
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const backfillCell = document.getElementById('backfill-' + id);
- if (backfillCell && data.newStatus !== undefined) {
- const isEnabled = data.newStatus == 1;
- backfillCell.innerHTML = isEnabled
- ? `
- Enabled
- `
- : `
- Disabled
- `;
- }
- if (typeof showToast === 'function') {
- showToast(data.message || 'Backfill status updated', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error updating backfill status', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error updating backfill status', 'error');
- }
- });
- };
-
- // Reset group
- window.ajax_group_reset = function(id) {
- showConfirm({
- title: 'Reset Group',
- message: 'Are you sure you want to reset this group?',
- details: 'This will reset the article pointers back to the current state.',
- type: 'warning',
- confirmText: 'Reset',
- cancelText: 'Cancel',
- onConfirm: function() {
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'reset_group',
- group_id: id
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Group reset successfully', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error resetting group', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error resetting group', 'error');
- }
- });
- }
- });
- };
-
- // Delete group
- window.confirmGroupDelete = function(id) {
- showConfirm({
- title: 'Delete Group',
- message: 'Are you sure you want to delete this group?',
- details: 'This action cannot be undone.',
- type: 'danger',
- confirmText: 'Delete',
- cancelText: 'Cancel',
- onConfirm: function() {
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'delete_group',
- group_id: id
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const row = document.getElementById('grouprow-' + id);
- if (row) {
- row.style.transition = 'opacity 0.3s';
- row.style.opacity = '0';
- setTimeout(() => row.remove(), 300);
- }
- if (typeof showToast === 'function') {
- showToast(data.message || 'Group deleted successfully', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error deleting group', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error deleting group', 'error');
- }
- });
- }
- });
- };
-
- // Purge group
- window.confirmGroupPurge = function(id) {
- showConfirm({
- title: 'Purge Group',
- message: 'Are you sure you want to purge this group?',
- details: 'This will delete all releases and binaries for this group. This action cannot be undone!',
- type: 'danger',
- confirmText: 'Purge',
- cancelText: 'Cancel',
- onConfirm: function() {
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'purge_group',
- group_id: id
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Group purged successfully', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error purging group', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error purging group', 'error');
- }
- });
- }
- });
- };
-
- // Reset all groups
- window.ajax_group_reset_all = function() {
- hideResetAllModal();
-
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'reset_all_groups'
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || 'All groups reset successfully', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error resetting all groups', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error resetting all groups', 'error');
- }
- });
- };
-
- // Purge all groups
- window.ajax_group_purge_all = function() {
- hidePurgeAllModal();
-
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'purge_all_groups'
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || 'All groups purged successfully', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error purging all groups', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error purging all groups', 'error');
- }
- });
- };
-
- // Modal helpers
- window.showResetAllModal = function() {
- const modal = document.getElementById('resetAllModal');
- if (modal) {
- modal.classList.remove('hidden');
- }
- };
-
- window.hideResetAllModal = function() {
- const modal = document.getElementById('resetAllModal');
- if (modal) {
- modal.classList.add('hidden');
- }
- };
-
- window.showPurgeAllModal = function() {
- const modal = document.getElementById('purgeAllModal');
- if (modal) {
- modal.classList.remove('hidden');
- }
- };
-
- window.hidePurgeAllModal = function() {
- const modal = document.getElementById('purgeAllModal');
- if (modal) {
- modal.classList.add('hidden');
- }
- };
-
- // Reset Selected Modal helpers
- window.showResetSelectedModal = function() {
- const modal = document.getElementById('resetSelectedModal');
- const countSpan = document.getElementById('reset-selected-count');
- const listDiv = document.getElementById('reset-selected-list');
-
- if (modal) {
- const selectedGroups = getSelectedGroups();
- if (selectedGroups.length === 0) {
- if (typeof showToast === 'function') {
- showToast('No groups selected', 'warning');
- }
- return;
- }
-
- if (countSpan) {
- countSpan.textContent = selectedGroups.length;
- }
-
- if (listDiv) {
- listDiv.innerHTML = selectedGroups.map(g =>
- `${escapeHtml(g.name)}
`
- ).join('');
- }
-
- modal.classList.remove('hidden');
- }
- };
-
- window.hideResetSelectedModal = function() {
- const modal = document.getElementById('resetSelectedModal');
- if (modal) {
- modal.classList.add('hidden');
- }
- };
-
- // Get selected groups
- window.getSelectedGroups = function() {
- const checkboxes = document.querySelectorAll('.group-checkbox:checked');
- const groups = [];
- checkboxes.forEach(cb => {
- groups.push({
- id: cb.dataset.groupId,
- name: cb.dataset.groupName
- });
- });
- return groups;
- };
-
- // Toggle select all groups
- window.toggleSelectAllGroups = function(checkbox) {
- const isChecked = checkbox.checked;
- const groupCheckboxes = document.querySelectorAll('.group-checkbox');
- groupCheckboxes.forEach(cb => {
- cb.checked = isChecked;
- });
- updateSelectionUI();
- };
-
- // Update selection UI (counter, button visibility)
- window.updateSelectionUI = function() {
- const selectedGroups = getSelectedGroups();
- const count = selectedGroups.length;
- const counter = document.getElementById('selection-counter');
- const countSpan = document.getElementById('selected-count');
- const resetSelectedBtn = document.getElementById('reset-selected-btn');
- const selectAllCheckbox = document.getElementById('select-all-groups');
- const allCheckboxes = document.querySelectorAll('.group-checkbox');
-
- if (counter && countSpan) {
- if (count > 0) {
- counter.classList.remove('hidden');
- countSpan.textContent = count;
- } else {
- counter.classList.add('hidden');
- }
- }
-
- if (resetSelectedBtn) {
- if (count > 0) {
- resetSelectedBtn.classList.remove('hidden');
- } else {
- resetSelectedBtn.classList.add('hidden');
- }
- }
-
- // Update select-all checkbox state
- if (selectAllCheckbox && allCheckboxes.length > 0) {
- const allChecked = Array.from(allCheckboxes).every(cb => cb.checked);
- const someChecked = Array.from(allCheckboxes).some(cb => cb.checked);
- selectAllCheckbox.checked = allChecked;
- selectAllCheckbox.indeterminate = someChecked && !allChecked;
- }
- };
-
- // Reset selected groups
- window.ajax_group_reset_selected = function() {
- hideResetSelectedModal();
-
- const selectedGroups = getSelectedGroups();
- if (selectedGroups.length === 0) {
- if (typeof showToast === 'function') {
- showToast('No groups selected', 'warning');
- }
- return;
- }
-
- const groupIds = selectedGroups.map(g => g.id);
-
- fetch(ajaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: new URLSearchParams({
- action: 'reset_selected_groups',
- group_ids: JSON.stringify(groupIds)
- })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || `${selectedGroups.length} group(s) reset successfully`, 'success');
- }
- // Clear selection
- document.querySelectorAll('.group-checkbox:checked').forEach(cb => {
- cb.checked = false;
- });
- document.getElementById('select-all-groups').checked = false;
- updateSelectionUI();
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Error resetting selected groups', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Error resetting selected groups', 'error');
- }
- });
- };
-
- // Initialize checkbox change listeners
- document.addEventListener('change', function(e) {
- if (e.target.classList.contains('group-checkbox')) {
- updateSelectionUI();
- }
- });
-}
-
-export { initAdminGroups };
diff --git a/resources/js/modules/auth.js b/resources/js/modules/auth.js
deleted file mode 100644
index dee15be19..000000000
--- a/resources/js/modules/auth.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Auth pages module
- * Extracted from csp-safe.js
- */
-
-// Auth pages functionality
-export function initAuthPages() {
- // Auto-hide success messages after 5 seconds on login page
- if (window.location.pathname.includes('/login')) {
- setTimeout(function() {
- const alerts = document.querySelectorAll('.bg-green-50, .bg-blue-50');
- alerts.forEach(function(alert) {
- alert.style.transition = 'opacity 0.5s ease-out';
- alert.style.opacity = '0';
- setTimeout(() => alert.remove(), 500);
- });
- }, 5000);
- }
-
- // 2FA verification - Auto-submit when 6 digits are entered
- const otpInput = document.getElementById('one_time_password');
- if (otpInput) {
- otpInput.addEventListener('input', function(e) {
- // Remove non-numeric characters
- this.value = this.value.replace(/[^0-9]/g, '');
-
- // Auto-submit when 6 digits are entered
- if (this.value.length === 6) {
- // Small delay to show the complete code before submitting
- setTimeout(() => {
- this.form.submit();
- }, 300);
- }
- });
-
- // Focus on input when page loads
- otpInput.focus();
- }
-}
diff --git a/resources/js/modules/cart.js b/resources/js/modules/cart.js
deleted file mode 100644
index a7389b563..000000000
--- a/resources/js/modules/cart.js
+++ /dev/null
@@ -1,475 +0,0 @@
-/**
- * Cart functionality module
- * Extracted from csp-safe.js
- */
-
-export function initCartFunctionality() {
- // Handle individual add to cart button clicks (for details page, browse page, etc.)
- document.addEventListener('click', function(e) {
- const cartBtn = e.target.closest('.add-to-cart');
-
- if (cartBtn) {
- e.preventDefault();
-
- const guid = cartBtn.dataset.guid;
- const iconElement = cartBtn.querySelector('.icon_cart');
-
- if (!guid) {
- console.error('No GUID found for cart item');
- return;
- }
-
- // Prevent double-clicking
- if (iconElement && iconElement.classList.contains('icon_cart_clicked')) {
- return;
- }
-
- // Send AJAX request to add item to cart
- fetch('/cart/add', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: JSON.stringify({ id: guid })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- // Visual feedback
- if (iconElement) {
- iconElement.classList.remove('fa-shopping-basket');
- iconElement.classList.add('fa-check', 'icon_cart_clicked');
-
- // Reset icon after 2 seconds
- setTimeout(() => {
- iconElement.classList.remove('fa-check', 'icon_cart_clicked');
- iconElement.classList.add('fa-shopping-basket');
- }, 2000);
- }
-
- // Update cart count if element exists
- const cartCount = document.querySelector('.cart-count');
- if (cartCount && data.cartCount) {
- cartCount.textContent = data.cartCount;
- }
-
- // Show success notification
- if (typeof showToast === 'function') {
- showToast('Added to cart successfully!', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast('Failed to add item to cart', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error adding to cart:', error);
- if (typeof showToast === 'function') {
- showToast('An error occurred', 'error');
- }
- });
- }
- });
-
- // Cart page specific functionality
- const checkAll = document.getElementById('check-all');
- const checkboxes = document.querySelectorAll('.cart-checkbox');
-
- if (checkAll && checkboxes.length > 0) {
- // Function to update the check-all checkbox state
- function updateCheckAllState() {
- const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
- checkAll.checked = checkedCount === checkboxes.length;
- checkAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
- }
-
- // Handle check-all checkbox change
- checkAll.addEventListener('change', function() {
- const isChecked = this.checked;
- checkboxes.forEach(checkbox => {
- checkbox.checked = isChecked;
- });
- });
-
- // Handle individual checkbox changes
- checkboxes.forEach(checkbox => {
- checkbox.addEventListener('change', updateCheckAllState);
- });
-
- // Initialize the check-all state on page load
- updateCheckAllState();
-
- // Download selected
- document.querySelectorAll('.nzb_multi_operations_download_cart').forEach(btn => {
- btn.addEventListener('click', function(e) {
- e.preventDefault();
-
- const selected = Array.from(checkboxes)
- .filter(cb => cb.checked)
- .map(cb => cb.value);
-
- if (selected.length === 0) {
- if (typeof showToast === 'function') {
- showToast('Please select at least one item', 'error');
- } else {
- alert('Please select at least one item');
- }
- return;
- }
-
- // If only one release is selected, download it directly
- if (selected.length === 1) {
- window.location.href = '/getnzb/' + selected[0];
- if (typeof showToast === 'function') {
- showToast('Downloading NZB...', 'success');
- }
- } else {
- // For multiple releases, download as zip
- const guids = selected.join(',');
- window.location.href = '/getnzb?id=' + encodeURIComponent(guids) + '&zip=1';
- if (typeof showToast === 'function') {
- showToast(`Downloading ${selected.length} NZBs as zip file...`, 'success');
- }
- }
- });
- });
-
- // Delete selected
- document.querySelectorAll('.nzb_multi_operations_cartdelete').forEach(btn => {
- btn.addEventListener('click', function(e) {
- e.preventDefault();
-
- const selected = Array.from(checkboxes)
- .filter(cb => cb.checked)
- .map(cb => cb.value);
-
- if (selected.length === 0) {
- if (typeof showToast === 'function') {
- showToast('Please select at least one item', 'error');
- } else {
- alert('Please select at least one item');
- }
- return;
- }
-
- showConfirm({
- title: 'Delete from Cart',
- message: `Are you sure you want to delete ${selected.length} item${selected.length > 1 ? 's' : ''} from your cart?`,
- type: 'danger',
- confirmText: 'Delete',
- onConfirm: function() {
- // Delete via AJAX
- fetch('/cart/delete/' + selected.join(','), {
- method: 'POST',
- headers: {
- 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
- 'Content-Type': 'application/json',
- }
- })
- .then(response => {
- if (response.ok) {
- if (typeof showToast === 'function') {
- showToast('Items deleted successfully', 'success');
- }
- setTimeout(() => window.location.reload(), 1000);
- } else {
- if (typeof showToast === 'function') {
- showToast('Failed to delete items', 'error');
- } else {
- alert('Failed to delete items');
- }
- }
- })
- .catch(error => {
- console.error('Error:', error);
- if (typeof showToast === 'function') {
- showToast('Failed to delete items', 'error');
- } else {
- alert('Failed to delete items');
- }
- });
- }
- });
- });
- });
-
- // Individual delete confirmation
- document.querySelectorAll('.cart-delete-link').forEach(function(link) {
- link.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const releaseName = this.getAttribute('data-release-name');
- const deleteUrl = this.getAttribute('data-delete-url');
-
- showConfirm({
- title: 'Remove from Cart',
- message: `Are you sure you want to remove "${releaseName}" from your cart?`,
- type: 'warning',
- confirmText: 'Remove',
- onConfirm: function() {
- if (typeof showToast === 'function') {
- showToast('Removing item from cart...', 'info');
- }
-
- setTimeout(() => {
- window.location.href = deleteUrl;
- }, 500);
- }
- });
- });
- });
- }
-
- // Select all checkbox functionality
- const selectAllCheckbox = document.getElementById('chkSelectAll');
- if (selectAllCheckbox) {
- selectAllCheckbox.addEventListener('change', function() {
- const checkboxes = document.querySelectorAll('.chkRelease');
- checkboxes.forEach(checkbox => checkbox.checked = this.checked);
- });
- }
-
- // Multi-operations download
- const multiDownloadBtn = document.querySelector('.nzb_multi_operations_download');
- if (multiDownloadBtn) {
- multiDownloadBtn.addEventListener('click', function() {
- const selected = Array.from(document.querySelectorAll('.chkRelease:checked')).map(cb => cb.value);
- if (selected.length === 0) {
- if (typeof showToast === 'function') {
- showToast('Please select at least one release', 'error');
- }
- return;
- }
-
- // If only one release is selected, download it directly
- if (selected.length === 1) {
- window.location.href = '/getnzb/' + selected[0];
- if (typeof showToast === 'function') {
- showToast('Downloading NZB...', 'success');
- }
- } else {
- // For multiple releases, download as zip
- const guids = selected.join(',');
- window.location.href = '/getnzb?id=' + encodeURIComponent(guids) + '&zip=1';
- if (typeof showToast === 'function') {
- showToast(`Downloading ${selected.length} NZBs as zip file...`, 'success');
- }
- }
- });
- }
-
- // Multi-operations cart
- const multiCartBtn = document.querySelector('.nzb_multi_operations_cart');
- if (multiCartBtn) {
- multiCartBtn.addEventListener('click', function() {
- const selected = Array.from(document.querySelectorAll('.chkRelease:checked')).map(cb => cb.value);
- if (selected.length === 0) {
- if (typeof showToast === 'function') {
- showToast('Please select at least one release', 'error');
- }
- return;
- }
-
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- fetch('/cart/add', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: JSON.stringify({ id: selected.join(',') })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast(data.message || `Added ${selected.length} item${selected.length > 1 ? 's' : ''} to cart`, 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast('Failed to add items to cart', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error adding to cart:', error);
- if (typeof showToast === 'function') {
- showToast('An error occurred', 'error');
- }
- });
- });
- }
-
- // Multi-operations delete (Admin only)
- const multiDeleteBtn = document.querySelector('.nzb_multi_operations_delete');
- if (multiDeleteBtn) {
- console.log('Delete button found and event listener being attached');
- multiDeleteBtn.addEventListener('click', function(e) {
- e.preventDefault();
- console.log('Delete button clicked');
-
- const checkboxes = document.querySelectorAll('.chkRelease:checked');
- const selected = Array.from(checkboxes);
-
- console.log('Selected releases:', selected.length);
-
- if (selected.length === 0) {
- if (typeof showToast === 'function') {
- showToast('Please select at least one release to delete', 'error');
- } else {
- alert('Please select at least one release to delete');
- }
- return;
- }
-
- const confirmMessage = `Are you sure you want to delete ${selected.length} release${selected.length > 1 ? 's' : ''}? This action cannot be undone.`;
-
- // Use styled confirmation modal
- showConfirm({
- title: 'Delete Releases',
- message: confirmMessage,
- type: 'danger',
- confirmText: 'Delete',
- onConfirm: function() {
- console.log('User confirmed deletion');
-
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
- if (!csrfToken) {
- console.error('CSRF token not found');
- if (typeof showToast === 'function') {
- showToast('Security token not found. Please refresh the page.', 'error');
- } else {
- alert('Security token not found. Please refresh the page.');
- }
- return;
- }
-
- let deletedCount = 0;
- let errorCount = 0;
-
- if (typeof showToast === 'function') {
- showToast(`Deleting ${selected.length} release${selected.length > 1 ? 's' : ''}...`, 'info');
- }
-
- // Delete releases one by one
- const deletePromises = selected.map(checkbox => {
- const guid = checkbox.value;
- const row = checkbox.closest('tr');
-
- console.log('Attempting to delete release:', guid);
-
- return fetch('/admin/release-delete/' + guid, {
- method: 'POST',
- headers: {
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest',
- 'Content-Type': 'application/json'
- }
- })
- .then(response => {
- console.log('Delete response status:', response.status, 'for guid:', guid);
- if (response.ok) {
- deletedCount++;
- // Fade out and remove the row
- if (row) {
- row.style.transition = 'opacity 0.3s';
- row.style.opacity = '0';
- setTimeout(() => row.remove(), 300);
- }
- return true;
- } else {
- errorCount++;
- console.error('Failed to delete release:', guid, 'Status:', response.status);
- return response.text().then(text => {
- console.error('Error response:', text);
- return false;
- });
- }
- })
- .catch(error => {
- console.error('Error deleting release:', guid, error);
- errorCount++;
- return false;
- });
- });
-
- // Wait for all deletes to complete
- Promise.all(deletePromises).then(() => {
- console.log('All delete operations completed. Deleted:', deletedCount, 'Errors:', errorCount);
-
- // Uncheck the select all checkbox
- const selectAllCheckbox = document.getElementById('chkSelectAll');
- if (selectAllCheckbox) {
- selectAllCheckbox.checked = false;
- }
-
- // Show final result
- if (deletedCount > 0 && errorCount === 0) {
- if (typeof showToast === 'function') {
- showToast(`Successfully deleted ${deletedCount} release${deletedCount > 1 ? 's' : ''}`, 'success');
- }
- } else if (deletedCount > 0 && errorCount > 0) {
- if (typeof showToast === 'function') {
- showToast(`Deleted ${deletedCount} release${deletedCount > 1 ? 's' : ''}, ${errorCount} failed`, 'error');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast('Failed to delete releases', 'error');
- }
- }
-
- // Reload page if all items on current page were deleted
- const remainingRows = document.querySelectorAll('.chkRelease');
- if (remainingRows.length === 0) {
- setTimeout(() => window.location.reload(), 1500);
- }
- });
- }
- });
- });
- }
-}
-
-// Add to Cart function (standalone for backward compatibility)
-window.addToCart = function(guid) {
- if (!guid) {
- console.error('No GUID provided to addToCart');
- return;
- }
-
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- fetch('/cart/add', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: JSON.stringify({ id: guid })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- if (typeof showToast === 'function') {
- showToast('Added to cart successfully!', 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast('Failed to add item to cart', 'error');
- }
- }
- })
- .catch(error => {
- console.error('Error adding to cart:', error);
- if (typeof showToast === 'function') {
- showToast('An error occurred', 'error');
- }
- });
-};
diff --git a/resources/js/modules/content.js b/resources/js/modules/content.js
deleted file mode 100644
index c23d796ab..000000000
--- a/resources/js/modules/content.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * Content management module
- * Extracted from csp-safe.js
- */
-import { escapeHtml } from './utils.js';
-
-// Admin Content List - Toggle Enable/Disable
-export function initContentToggle() {
- document.addEventListener('click', function(e) {
- const toggleBtn = e.target.closest('.content-toggle-status');
- if (!toggleBtn) return;
-
- e.preventDefault();
- const contentId = toggleBtn.getAttribute('data-content-id');
- const currentStatus = parseInt(toggleBtn.getAttribute('data-current-status'));
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- if (!contentId || !csrfToken) {
- if (typeof showToast === 'function') {
- showToast('Error: Missing required data', 'error');
- }
- return;
- }
-
- // Disable button during request
- toggleBtn.disabled = true;
- toggleBtn.style.opacity = '0.6';
-
- fetch('/admin/content-toggle-status', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: JSON.stringify({ id: contentId })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const newStatus = data.status;
- const row = toggleBtn.closest('tr');
-
- // Find the status cell (6th td in the row - 0-indexed = 5)
- const statusCell = row.cells[5];
-
- // Update the status badge
- if (newStatus === 1) {
- statusCell.innerHTML = `
-
- Enabled
-
- `;
- } else {
- statusCell.innerHTML = `
-
- Disabled
-
- `;
- }
-
- // Update the toggle button itself
- toggleBtn.setAttribute('data-current-status', newStatus);
- toggleBtn.title = newStatus === 1 ? 'Disable' : 'Enable';
-
- // Update button color classes
- if (newStatus === 1) {
- toggleBtn.className = 'content-toggle-status text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300';
- toggleBtn.innerHTML = ' ';
- } else {
- toggleBtn.className = 'content-toggle-status text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300';
- toggleBtn.innerHTML = ' ';
- }
-
- // Re-enable button
- toggleBtn.disabled = false;
- toggleBtn.style.opacity = '1';
-
- if (typeof showToast === 'function') {
- showToast(data.message, 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Failed to toggle content status', 'error');
- }
- toggleBtn.disabled = false;
- toggleBtn.style.opacity = '1';
- }
- })
- .catch(error => {
- console.error('Error toggling content status:', error);
- if (typeof showToast === 'function') {
- showToast('An error occurred while toggling content status', 'error');
- }
- toggleBtn.disabled = false;
- toggleBtn.style.opacity = '1';
- });
- });
-}
-
-// Admin Content List - Delete Content
-export function initContentDelete() {
- document.addEventListener('click', function(e) {
- const deleteBtn = e.target.closest('.content-delete');
- if (!deleteBtn) return;
-
- e.preventDefault();
- const contentId = deleteBtn.getAttribute('data-content-id');
- const contentTitle = deleteBtn.getAttribute('data-content-title');
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- if (!contentId || !csrfToken) {
- if (typeof showToast === 'function') {
- showToast('Error: Missing required data', 'error');
- }
- return;
- }
-
- // Show styled confirmation modal
- showConfirm({
- title: 'Delete Content',
- message: `Are you sure you want to delete "${contentTitle}"?`,
- details: 'This action cannot be undone.',
- type: 'danger',
- confirmText: 'Delete',
- cancelText: 'Cancel',
- onConfirm: function() {
- // Disable button during request
- deleteBtn.disabled = true;
- deleteBtn.style.opacity = '0.6';
-
- fetch('/admin/content-delete', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken,
- 'X-Requested-With': 'XMLHttpRequest'
- },
- body: JSON.stringify({ id: contentId })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- // Remove the row from the table
- const row = deleteBtn.closest('tr');
- if (row) {
- row.style.transition = 'opacity 0.3s';
- row.style.opacity = '0';
- setTimeout(() => {
- row.remove();
-
- // Check if table is empty now
- const tbody = document.querySelector('tbody');
- if (tbody && tbody.children.length === 0) {
- location.reload(); // Reload to show "no content found" message
- }
- }, 300);
- }
-
- if (typeof showToast === 'function') {
- showToast(data.message, 'success');
- }
- } else {
- if (typeof showToast === 'function') {
- showToast(data.message || 'Failed to delete content', 'error');
- }
- deleteBtn.disabled = false;
- deleteBtn.style.opacity = '1';
- }
- })
- .catch(error => {
- console.error('Error deleting content:', error);
- if (typeof showToast === 'function') {
- showToast('An error occurred while deleting content', 'error');
- }
- deleteBtn.disabled = false;
- deleteBtn.style.opacity = '1';
- });
- }
- });
- });
-}
-
-// Movies page layout toggle functionality
-export function initMoviesLayoutToggle() {
- const toggleButton = document.getElementById('layoutToggle');
- const toggleText = document.getElementById('layoutToggleText');
- const moviesGrid = document.getElementById('moviesGrid');
-
- if (!toggleButton || !toggleText || !moviesGrid) {
- return; // Not on movies page
- }
-
- // Get current layout from data attribute
- let currentLayout = parseInt(moviesGrid.dataset.userLayout) || 2;
-
- // Apply current layout
- applyLayout(currentLayout);
-
- // Handle toggle button click
- toggleButton.addEventListener('click', function() {
- // Toggle between 1 and 2 column layouts
- currentLayout = currentLayout === 2 ? 1 : 2;
-
- // Save preference to server
- const csrfToken = document.querySelector('meta[name="csrf-token"]');
- if (!csrfToken || !csrfToken.content) {
- console.error('CSRF token not found');
- applyLayout(currentLayout); // Apply layout anyway
- return;
- }
-
- fetch('/movies/update-layout', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken.content
- },
- body: JSON.stringify({ layout: currentLayout })
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- applyLayout(currentLayout);
- }
- })
- .catch(error => {
- console.error('Error saving layout preference:', error);
- // Apply layout anyway even if save fails
- applyLayout(currentLayout);
- });
- });
-
- function applyLayout(layout) {
- const images = moviesGrid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700');
- const releaseContainers = moviesGrid.querySelectorAll('.release-card-container');
-
- if (layout === 1) {
- // 1 Column Layout
- moviesGrid.classList.remove('lg:grid-cols-2');
- moviesGrid.classList.add('grid-cols-1');
- if (toggleText) toggleText.textContent = '1 Column';
- const icon = toggleButton.querySelector('i');
- if (icon) icon.className = 'fas fa-th-list mr-2';
-
- // Make images larger
- images.forEach(img => {
- img.classList.remove('w-32', 'h-48');
- img.classList.add('w-48', 'h-72');
- });
-
- // Adjust release card containers to horizontal layout
- releaseContainers.forEach(container => {
- container.classList.remove('space-y-2');
- container.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
-
- const infoWrapper = container.querySelector('.release-info-wrapper');
- if (infoWrapper) {
- infoWrapper.classList.add('flex-1', 'min-w-0');
- }
-
- const actions = container.querySelector('.release-actions');
- if (actions) {
- actions.classList.remove('flex-wrap');
- actions.classList.add('flex-shrink-0', 'flex-row', 'items-center');
- }
- });
- } else {
- // 2 Column Layout
- moviesGrid.classList.remove('grid-cols-1');
- moviesGrid.classList.add('lg:grid-cols-2');
- if (toggleText) toggleText.textContent = '2 Columns';
- const icon = toggleButton.querySelector('i');
- if (icon) icon.className = 'fas fa-th-large mr-2';
-
- // Make images smaller
- images.forEach(img => {
- img.classList.remove('w-48', 'h-72');
- img.classList.add('w-32', 'h-48');
- });
-
- // Adjust release card containers to vertical layout
- releaseContainers.forEach(container => {
- container.classList.add('space-y-2');
- container.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
-
- const infoWrapper = container.querySelector('.release-info-wrapper');
- if (infoWrapper) {
- infoWrapper.classList.remove('flex-1', 'min-w-0');
- }
-
- const actions = container.querySelector('.release-actions');
- if (actions) {
- actions.classList.add('flex-wrap', 'items-center');
- actions.classList.remove('flex-shrink-0', 'flex-row');
- }
- });
- }
- }
-}
-
-export function initQualityFilter() {
- const resolutionButtons = document.querySelectorAll('.resolution-filter-btn');
- const sourceButtons = document.querySelectorAll('.source-filter-btn');
- const releaseItems = document.querySelectorAll('.release-item');
- const releaseCount = document.getElementById('release-count');
-
- if (!releaseItems.length || !releaseCount) {
- return; // Exit if elements don't exist on the page
- }
-
- const totalReleases = releaseItems.length;
- let activeResolution = 'all';
- let activeSource = 'all';
-
- // Function to apply filters
- function applyFilters() {
- let visibleCount = 0;
-
- releaseItems.forEach(item => {
- const releaseName = item.getAttribute('data-release-name');
- if (!releaseName) return;
-
- let matchesResolution = true;
- let matchesSource = true;
-
- // Check resolution filter
- if (activeResolution !== 'all') {
- matchesResolution = releaseName.includes(activeResolution.toLowerCase());
- }
-
- // Check source filter
- if (activeSource !== 'all') {
- const sourceLower = activeSource.toLowerCase();
- // Handle different naming variations
- if (sourceLower === 'bluray') {
- matchesSource = releaseName.includes('bluray') ||
- releaseName.includes('blu-ray') ||
- releaseName.includes('bdrip') ||
- releaseName.includes('brrip');
- } else if (sourceLower === 'web-dl') {
- matchesSource = releaseName.includes('web-dl') ||
- releaseName.includes('webdl') ||
- releaseName.includes('web.dl');
- } else if (sourceLower === 'webrip') {
- matchesSource = releaseName.includes('webrip') ||
- releaseName.includes('web-rip') ||
- releaseName.includes('web.rip');
- } else {
- matchesSource = releaseName.includes(sourceLower);
- }
- }
-
- // Show/hide based on both filters
- if (matchesResolution && matchesSource) {
- item.style.display = '';
- visibleCount++;
- } else {
- item.style.display = 'none';
- }
- });
-
- // Update count display
- if (activeResolution === 'all' && activeSource === 'all') {
- releaseCount.textContent = `(${totalReleases} total)`;
- } else {
- releaseCount.textContent = `(${visibleCount} of ${totalReleases})`;
- }
- }
-
- // Resolution filter buttons
- resolutionButtons.forEach(button => {
- button.addEventListener('click', function() {
- const filter = this.getAttribute('data-filter');
- activeResolution = filter;
-
- // Update active button styling
- resolutionButtons.forEach(btn => {
- btn.classList.remove('active', 'bg-blue-600', 'text-white');
- btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
- });
- this.classList.add('active', 'bg-blue-600', 'text-white');
- this.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
-
- applyFilters();
- });
- });
-
- // Source filter buttons
- sourceButtons.forEach(button => {
- button.addEventListener('click', function() {
- const filter = this.getAttribute('data-filter');
- activeSource = filter;
-
- // Update active button styling
- sourceButtons.forEach(btn => {
- btn.classList.remove('active', 'bg-purple-600', 'text-white');
- btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
- });
- this.classList.add('active', 'bg-purple-600', 'text-white');
- this.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
-
- applyFilters();
- });
- });
-}
-
-export function initMyMovies() {
- // Confirm before removing movies
- document.querySelectorAll('.confirm_action').forEach(element => {
- element.addEventListener('click', function(e) {
- if (!confirm('Are you sure you want to remove this movie from your watchlist?')) {
- e.preventDefault();
- }
- });
- });
-
- // Image fallback for movie covers
- document.querySelectorAll('img[data-fallback-src]').forEach(img => {
- img.addEventListener('error', function() {
- const fallback = this.getAttribute('data-fallback-src');
- if (fallback && this.src !== fallback) {
- this.src = fallback;
- }
- });
- });
-
- // Initialize Bootstrap tooltips if they exist
- if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
- const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
- const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
- }
-}
diff --git a/resources/js/modules/event-delegation.js b/resources/js/modules/event-delegation.js
deleted file mode 100644
index c6cd3c0f8..000000000
--- a/resources/js/modules/event-delegation.js
+++ /dev/null
@@ -1,315 +0,0 @@
-// Event delegation for dynamically added elements - optimized with early returns
-export function initEventDelegation() {
- // Helper function to find element with attribute, checking both target and closest
- function findElementWithAttr(e, attr) {
- if (!e || !e.target) return null;
- if (e.target.hasAttribute(attr)) return e.target;
- return e.target.closest(`[${attr}]`);
- }
-
- // Helper function to find element with class
- function findElementWithClass(e, className) {
- if (!e || !e.target) return null;
- if (e.target.classList && e.target.classList.contains(className)) return e.target;
- return e.target.closest(`.${className}`);
- }
-
- document.addEventListener('click', function(e) {
- // Handle toast close buttons
- const toastClose = findElementWithClass(e, 'toast-close');
- if (toastClose) {
- const toast = toastClose.closest('.toast-notification');
- if (toast) {
- toast.remove();
- }
- return;
- }
-
- // Handle NFO modal close
- if (findElementWithAttr(e, 'data-close-nfo-modal')) {
- e.preventDefault();
- closeNfoModal();
- return;
- }
-
- // Handle NFO modal open
- const nfoOpen = findElementWithAttr(e, 'data-open-nfo');
- if (nfoOpen) {
- e.preventDefault();
- const guid = nfoOpen.getAttribute('data-open-nfo');
- if (guid) {
- openNfoModal(guid);
- }
- return;
- }
-
- // Handle logout
- if (findElementWithAttr(e, 'data-logout')) {
- e.preventDefault();
- const logoutForm = document.getElementById('logout-form');
- if (logoutForm) {
- logoutForm.submit();
- }
- return;
- }
-
- // Handle confirm delete - using styled modal
- const confirmDelete = findElementWithAttr(e, 'data-confirm-delete');
- if (confirmDelete) {
- e.preventDefault();
- e.stopPropagation();
- const form = confirmDelete.closest('form');
-
- showConfirm({
- message: 'Are you sure you want to delete this item?',
- type: 'danger',
- confirmText: 'Delete',
- onConfirm: function() {
- if (form) {
- form.submit();
- } else if (confirmDelete.href) {
- window.location.href = confirmDelete.href;
- }
- }
- });
- return;
- }
-
- // Handle confirm action - using styled modal
- const confirmAction = findElementWithAttr(e, 'data-confirm');
- if (confirmAction) {
- e.preventDefault();
- e.stopPropagation();
- const message = confirmAction.getAttribute('data-confirm');
- const form = confirmAction.closest('form');
-
- showConfirm({
- message: message,
- type: 'danger',
- confirmText: 'Delete',
- onConfirm: function() {
- if (form) {
- form.submit();
- } else if (confirmAction.href) {
- window.location.href = confirmAction.href;
- }
- }
- });
- return;
- }
-
- // Handle download NZB buttons
- if (e.target.classList.contains('download-nzb') || e.target.closest('.download-nzb')) {
- if (typeof showToast === 'function') {
- showToast('Downloading NZB...', 'success');
- }
- }
-
- // Handle season switcher - only for season-tab buttons, not content
- const seasonTab = e.target.classList.contains('season-tab') ? e.target : e.target.closest('.season-tab');
- if (seasonTab) {
- e.preventDefault();
- const seasonNumber = seasonTab.getAttribute('data-season');
- if (seasonNumber && typeof switchSeason === 'function') {
- switchSeason(seasonNumber);
- }
- return;
- }
-
- // Handle binary blacklist delete
- if (e.target.hasAttribute('data-delete-blacklist') || e.target.closest('[data-delete-blacklist]')) {
- const id = e.target.getAttribute('data-delete-blacklist') || e.target.closest('[data-delete-blacklist]').getAttribute('data-delete-blacklist');
- const confirmed = confirm('Are you sure? This will delete the blacklist from this list.');
- if (confirmed && typeof ajax_binaryblacklist_delete === 'function') {
- ajax_binaryblacklist_delete(id);
- }
- e.preventDefault();
- }
-
- // Handle confirmation modal close button
- if (e.target.hasAttribute('data-close-confirmation-modal') || e.target.closest('[data-close-confirmation-modal]')) {
- e.preventDefault();
- closeConfirmationModal();
- }
-
- // Handle confirmation modal confirm button
- if (e.target.hasAttribute('data-confirm-confirmation-modal') || e.target.closest('[data-confirm-confirmation-modal]')) {
- e.preventDefault();
- confirmConfirmationModal();
- }
-
- // Handle admin groups management actions
- const actionTarget = e.target.closest('[data-action]');
- if (actionTarget) {
- const action = actionTarget.dataset.action;
- const groupId = actionTarget.dataset.groupId;
- const status = actionTarget.dataset.status;
-
- switch(action) {
- case 'show-reset-modal':
- showResetAllModal();
- break;
- case 'hide-reset-modal':
- hideResetAllModal();
- break;
- case 'show-purge-modal':
- showPurgeAllModal();
- break;
- case 'hide-purge-modal':
- hidePurgeAllModal();
- break;
- case 'show-reset-selected-modal':
- showResetSelectedModal();
- break;
- case 'hide-reset-selected-modal':
- hideResetSelectedModal();
- break;
- case 'select-all-groups':
- toggleSelectAllGroups(actionTarget);
- break;
- case 'toggle-group-status':
- ajax_group_status(groupId, status);
- break;
- case 'toggle-backfill':
- ajax_backfill_status(groupId, status);
- break;
- case 'reset-group':
- ajax_group_reset(groupId);
- break;
- case 'delete-group':
- confirmGroupDelete(groupId);
- break;
- case 'purge-group':
- confirmGroupPurge(groupId);
- break;
- case 'reset-all':
- ajax_group_reset_all();
- break;
- case 'purge-all':
- ajax_group_purge_all();
- break;
- case 'reset-selected':
- ajax_group_reset_selected();
- break;
- }
- }
-
- // Handle restore user button (both admin/user-list and admin/deleted-users pages)
- const restoreUserBtn = e.target.closest('.restore-user-btn');
- if (restoreUserBtn) {
- e.preventDefault();
- const userId = restoreUserBtn.getAttribute('data-user-id');
- const username = restoreUserBtn.getAttribute('data-username') || 'this user';
-
- showConfirm({
- title: 'Restore User',
- message: `Are you sure you want to restore user "${username}"?`,
- type: 'success',
- confirmText: 'Restore',
- cancelText: 'Cancel',
- onConfirm: function() {
- const form = document.getElementById('individualActionForm');
- if (!form) {
- console.error('Individual action form not found');
- if (typeof showToast === 'function') {
- showToast('Error: Form not found', 'error');
- }
- return;
- }
-
- // Set form action and submit
- form.action = `/admin/deleted-users/restore/${userId}`;
- form.method = 'POST';
- form.submit();
- }
- });
- return;
- }
-
- // Handle permanent delete user button (deleted users page only)
- const deleteUserBtn = e.target.closest('.delete-user-btn');
- if (deleteUserBtn) {
- e.preventDefault();
- const userId = deleteUserBtn.getAttribute('data-user-id');
- const username = deleteUserBtn.getAttribute('data-username') || 'this user';
-
- showConfirm({
- title: 'Permanently Delete User',
- message: `Are you sure you want to permanently delete user "${username}"? This action cannot be undone and will remove all user data.`,
- type: 'danger',
- confirmText: 'Delete Permanently',
- cancelText: 'Cancel',
- onConfirm: function() {
- const form = document.getElementById('individualActionForm');
- if (!form) {
- console.error('Individual action form not found');
- if (typeof showToast === 'function') {
- showToast('Error: Form not found', 'error');
- }
- return;
- }
-
- // Set form action and submit
- form.action = `/admin/deleted-users/permanent-delete/${userId}`;
- form.method = 'POST';
- form.submit();
- }
- });
- return;
- }
-
- // Handle promotion toggle (activate/deactivate)
- const promotionToggleBtn = e.target.closest('.promotion-toggle-btn');
- if (promotionToggleBtn) {
- e.preventDefault();
- const promotionName = promotionToggleBtn.getAttribute('data-promotion-name');
- const isActive = promotionToggleBtn.getAttribute('data-promotion-active') === '1';
- const action = isActive ? 'deactivate' : 'activate';
-
- showConfirm({
- title: `${action.charAt(0).toUpperCase() + action.slice(1)} Promotion`,
- message: `Are you sure you want to ${action} the promotion "${promotionName}"?`,
- type: isActive ? 'warning' : 'success',
- confirmText: action.charAt(0).toUpperCase() + action.slice(1),
- cancelText: 'Cancel',
- onConfirm: function() {
- window.location.href = promotionToggleBtn.href;
- }
- });
- return;
- }
-
- // Handle promotion delete
- const promotionDeleteBtn = e.target.closest('.promotion-delete-btn');
- if (promotionDeleteBtn) {
- e.preventDefault();
- const promotionName = promotionDeleteBtn.getAttribute('data-promotion-name');
- const form = promotionDeleteBtn.closest('form');
-
- showConfirm({
- title: 'Delete Promotion',
- message: `Are you sure you want to delete the promotion "${promotionName}"?`,
- details: 'This action cannot be undone.',
- type: 'danger',
- confirmText: 'Delete',
- cancelText: 'Cancel',
- onConfirm: function() {
- if (form) {
- form.submit();
- }
- }
- });
- return;
- }
- });
-
- // Handle select redirects
- document.addEventListener('change', function(e) {
- if (e.target.hasAttribute('data-redirect-on-change')) {
- const url = e.target.value;
- if (url && url !== '#') {
- window.location.href = url;
- }
- }
- });
-}
diff --git a/resources/js/modules/forms.js b/resources/js/modules/forms.js
deleted file mode 100644
index d2f41df50..000000000
--- a/resources/js/modules/forms.js
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * Form-related functions extracted from csp-safe.js
- */
-
-import { escapeHtml } from './utils.js';
-
-// Select redirects
-export function initSelectRedirects() {
- // Already handled in event delegation
-}
-
-// Logout forms
-export function initLogoutForms() {
- // Already handled in event delegation
-}
-
-// Password visibility toggle
-window.togglePasswordVisibility = function(fieldId) {
- const field = document.getElementById(fieldId);
- const icon = document.getElementById(fieldId + '-eye');
-
- if (!field || !icon) return;
-
- if (field.type === 'password') {
- field.type = 'text';
- icon.classList.remove('fa-eye');
- icon.classList.add('fa-eye-slash');
- } else {
- field.type = 'password';
- icon.classList.remove('fa-eye-slash');
- icon.classList.add('fa-eye');
- }
-};
-
-/**
- * Initialize password visibility toggles for all password fields with toggle buttons
- */
-export function initPasswordVisibilityToggles() {
- // Add click event listeners to all password toggle buttons
- document.querySelectorAll('.password-toggle-btn').forEach(function(button) {
- button.addEventListener('click', function(e) {
- e.preventDefault();
- const fieldId = this.getAttribute('data-field-id');
- if (fieldId) {
- window.togglePasswordVisibility(fieldId);
- }
- });
- });
-}
-
-// Regex Management Functions
-export function initRegexManagement() {
- window.deleteRegex = function(id, deleteUrl) {
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- fetch(deleteUrl + '?id=' + id, {
- method: 'GET',
- headers: {
- 'X-Requested-With': 'XMLHttpRequest',
- 'X-CSRF-TOKEN': csrfToken
- }
- })
- .then(response => response.json())
- .then(data => {
- if (data.success) {
- const row = document.getElementById('row-' + id);
- if (row) {
- row.style.transition = 'opacity 0.3s';
- row.style.opacity = '0';
- setTimeout(() => row.remove(), 300);
- }
- showMessage('Regex deleted successfully', 'success');
- } else {
- showMessage('Error deleting regex', 'error');
- }
- })
- .catch(error => {
- showMessage('Error deleting regex', 'error');
- console.error('Error:', error);
- });
- };
-
- window.showMessage = function(message, type = 'success') {
- const messageDiv = document.getElementById('message');
- if (!messageDiv) return;
-
- const bgColor = type === 'success' ? 'bg-green-50 dark:bg-green-900 border-green-200 dark:border-green-700' : 'bg-red-50 dark:bg-red-900 border-red-200 dark:border-red-700';
- const textColor = type === 'success' ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200';
- const icon = type === 'success' ? 'check-circle' : 'exclamation-circle';
-
- const messageEl = document.createElement('div');
- messageEl.className = 'mt-4 p-4 border rounded-lg ' + bgColor;
- messageEl.innerHTML = `
-
-
- ${escapeHtml(message)}
-
-
-
-
-
- `;
-
- messageDiv.appendChild(messageEl);
-
- // Add close handler
- messageEl.querySelector('.close-message').addEventListener('click', function() {
- messageEl.remove();
- });
-
- // Auto-remove after 5 seconds
- setTimeout(() => {
- messageEl.remove();
- }, 5000);
- };
-
- // Handle delete buttons with proper confirmation
- document.addEventListener('click', function(e) {
- if (e.target.hasAttribute('data-delete-regex') || e.target.closest('[data-delete-regex]')) {
- const element = e.target.hasAttribute('data-delete-regex') ? e.target : e.target.closest('[data-delete-regex]');
- const id = element.getAttribute('data-delete-regex');
- const deleteUrl = element.getAttribute('data-delete-url');
-
- if (confirm('Are you sure you want to delete this regex? This action cannot be undone.')) {
- deleteRegex(id, deleteUrl);
- }
- e.preventDefault();
- }
-
- // Handle release delete buttons
- if (e.target.hasAttribute('data-delete-release') || e.target.closest('[data-delete-release]')) {
- const element = e.target.hasAttribute('data-delete-release') ? e.target : e.target.closest('[data-delete-release]');
- const id = element.getAttribute('data-delete-release');
- const deleteUrl = element.getAttribute('data-delete-url');
-
- showConfirm({
- title: 'Delete Release',
- message: 'Are you sure you want to delete this release? This action cannot be undone.',
- type: 'danger',
- confirmText: 'Delete',
- onConfirm: function() {
- deleteRelease(id, deleteUrl, element);
- }
- });
- e.preventDefault();
- }
- });
-
- // Delete release function
- window.deleteRelease = function(id, deleteUrl, element) {
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
-
- if (!csrfToken) {
- console.error('CSRF token not found');
- if (typeof showToast === 'function') {
- showToast('Security token not found. Please refresh the page.', 'error');
- }
- return;
- }
-
- console.log('Deleting release:', id, 'URL:', deleteUrl);
-
- if (typeof showToast === 'function') {
- showToast('Deleting release...', 'info');
- }
-
- fetch(deleteUrl, {
- method: 'POST',
- headers: {
- 'X-Requested-With': 'XMLHttpRequest',
- 'X-CSRF-TOKEN': csrfToken,
- 'Content-Type': 'application/json',
- 'Accept': 'application/json'
- }
- })
- .then(response => {
- console.log('Delete response status:', response.status);
- if (response.ok) {
- const row = element.closest('tr');
- if (row) {
- row.style.transition = 'opacity 0.3s';
- row.style.opacity = '0';
- setTimeout(() => row.remove(), 300);
- }
- if (typeof showToast === 'function') {
- showToast('Release deleted successfully', 'success');
- } else {
- showMessage('Release deleted successfully', 'success');
- }
- } else {
- return response.text().then(text => {
- console.error('Delete failed with status:', response.status, 'Response:', text);
- if (typeof showToast === 'function') {
- showToast('Error deleting release: ' + response.status, 'error');
- } else {
- showMessage('Error deleting release', 'error');
- }
- });
- }
- })
- .catch(error => {
- console.error('Error deleting release:', error);
- if (typeof showToast === 'function') {
- showToast('Error deleting release: ' + error.message, 'error');
- } else {
- showMessage('Error deleting release', 'error');
- }
- });
- };
-}
-
-// Image Fallbacks
-export function initImageFallbacks() {
- // Handle images with data-fallback-src attribute
- document.querySelectorAll('img[data-fallback-src]').forEach(function(img) {
- img.addEventListener('error', function() {
- const fallbackSrc = this.getAttribute('data-fallback-src');
- if (fallbackSrc && this.src !== fallbackSrc) {
- this.src = fallbackSrc;
- }
- });
- });
-}
diff --git a/resources/js/modules/modals.js b/resources/js/modules/modals.js
deleted file mode 100644
index bebf61080..000000000
--- a/resources/js/modules/modals.js
+++ /dev/null
@@ -1,874 +0,0 @@
-/**
- * Modal-related functions extracted from csp-safe.js
- */
-
-import { escapeHtml } from './utils.js';
-
-// Styled Confirmation Modal (replaces native confirm dialogs)
-let confirmationCallback = null;
-
-window.showConfirm = function(options) {
- // Options: { message, title, details, confirmText, cancelText, type, onConfirm }
- const modal = document.getElementById('confirmationModal');
- if (!modal) return Promise.reject('Modal not found');
-
- const defaults = {
- title: 'Confirm Action',
- message: 'Are you sure you want to proceed?',
- details: '',
- confirmText: 'Confirm',
- cancelText: 'Cancel',
- type: 'info', // 'info', 'warning', 'danger', 'success'
- onConfirm: null
- };
-
- const config = { ...defaults, ...options };
-
- // Set modal content - with null checks
- const titleText = document.getElementById('confirmationModalTitleText');
- const messageText = document.getElementById('confirmationModalMessage');
- const confirmText = document.getElementById('confirmationModalConfirmText');
- const cancelText = document.getElementById('confirmationModalCancelText');
-
- if (titleText) titleText.textContent = config.title;
- if (messageText) messageText.textContent = config.message;
- if (confirmText) confirmText.textContent = config.confirmText;
- if (cancelText) cancelText.textContent = config.cancelText;
-
- // Set icon based on type
- const icon = document.getElementById('confirmationModalIcon');
- const confirmBtn = document.getElementById('confirmationModalConfirmBtn');
-
- // Helper function to set classes (works with both regular elements and SVG)
- function setIconClasses(element, classes) {
- if (!element) return;
- if (element instanceof SVGElement) {
- element.setAttribute('class', classes.join(' '));
- } else {
- element.className = classes.join(' ');
- }
- }
-
- // Set base icon classes
- let iconClasses = ['fas', 'mr-2'];
-
- // Remove all existing classes from button and set base classes
- if (confirmBtn) {
- confirmBtn.className = '';
- confirmBtn.classList.add('px-4', 'py-2', 'text-white', 'rounded-lg', 'transition', 'font-medium');
- }
-
- if (config.type === 'danger') {
- iconClasses.push('fa-exclamation-triangle', 'text-red-600', 'dark:text-red-400');
- if (confirmBtn) {
- confirmBtn.classList.add('bg-red-600', 'dark:bg-red-700', 'hover:bg-red-700', 'dark:hover:bg-red-800');
- }
- } else if (config.type === 'warning') {
- iconClasses.push('fa-exclamation-circle', 'text-yellow-600', 'dark:text-yellow-400');
- if (confirmBtn) {
- confirmBtn.classList.add('bg-yellow-600', 'dark:bg-yellow-700', 'hover:bg-yellow-700', 'dark:hover:bg-yellow-800');
- }
- } else if (config.type === 'success') {
- iconClasses.push('fa-check-circle', 'text-green-600', 'dark:text-green-400');
- if (confirmBtn) {
- confirmBtn.classList.add('bg-green-600', 'dark:bg-green-700', 'hover:bg-green-700', 'dark:hover:bg-green-800');
- }
- } else { // info
- iconClasses.push('fa-info-circle', 'text-blue-600', 'dark:text-blue-400');
- if (confirmBtn) {
- confirmBtn.classList.add('bg-blue-600', 'dark:bg-blue-700', 'hover:bg-blue-700', 'dark:hover:bg-blue-800');
- }
- }
-
- // Apply icon classes
- setIconClasses(icon, iconClasses);
-
- // Handle details
- const detailsDiv = document.getElementById('confirmationModalDetails');
- const detailsText = document.getElementById('confirmationModalDetailsText');
- if (config.details && detailsText && detailsDiv) {
- detailsText.textContent = config.details;
- detailsDiv.classList.remove('hidden');
- } else if (detailsDiv) {
- detailsDiv.classList.add('hidden');
- }
-
- // Store callback for use when confirm is clicked
- const storedOnConfirm = config.onConfirm;
-
- // Show modal
- modal.classList.remove('hidden');
- modal.classList.add('flex');
-
- // Return promise for async/await usage
- return new Promise((resolve, reject) => {
- confirmationCallback = function(confirmed) {
- if (confirmed && storedOnConfirm) {
- storedOnConfirm();
- }
- resolve(confirmed);
- };
- });
-};
-
-window.closeConfirmationModal = function() {
- const modal = document.getElementById('confirmationModal');
- if (modal) {
- modal.classList.add('hidden');
- modal.classList.remove('flex');
- }
- if (confirmationCallback) {
- confirmationCallback(false);
- confirmationCallback = null;
- }
-};
-
-window.confirmConfirmationModal = function() {
- const modal = document.getElementById('confirmationModal');
- if (modal) {
- modal.classList.add('hidden');
- modal.classList.remove('flex');
- }
- if (confirmationCallback) {
- confirmationCallback(true);
- confirmationCallback = null;
- }
-};
-
-// Enhanced confirm function (backward compatible but with styled modal)
-window.confirmStyled = function(message, title = 'Confirm', type = 'info') {
- return new Promise((resolve) => {
- showConfirm({
- message: message,
- title: title,
- type: type,
- onConfirm: () => resolve(true)
- }).then(result => {
- if (!result) resolve(false);
- });
- });
-};
-
-// NFO Modal
-export function initNfoModal() {
- window.openNfoModal = function(guid) {
- const modal = document.getElementById('nfoModal');
- const content = document.getElementById('nfoContent');
-
- if (!modal || !content) return;
-
- // Show modal
- modal.classList.remove('hidden');
- modal.style.display = 'block';
-
- // Reset content with loading message
- content.innerHTML = 'Loading NFO...
';
-
- // Fetch NFO content
- const baseUrl = document.querySelector('meta[name="app-url"]')?.content || '';
- fetch(baseUrl + '/nfo/' + guid + '?modal=1')
- .then(response => {
- if (!response.ok) {
- throw new Error('NFO not found');
- }
- return response.text();
- })
- .then(html => {
- // Extract the NFO content from the response
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const nfoText = doc.querySelector('pre')?.textContent || doc.body.textContent;
- content.textContent = nfoText;
- })
- .catch(error => {
- content.innerHTML = 'Error loading NFO file
';
- console.error('Error loading NFO:', error);
- });
- };
-
- window.closeNfoModal = function() {
- const modal = document.getElementById('nfoModal');
- if (modal) {
- modal.classList.add('hidden');
- modal.style.display = 'none';
- }
- };
-
- // Close modal on Escape key
- document.addEventListener('keydown', function(event) {
- if (event.key === 'Escape') {
- const modal = document.getElementById('nfoModal');
- if (modal && !modal.classList.contains('hidden')) {
- closeNfoModal();
- }
- }
- });
-
- // Add click handlers for NFO badges
- document.addEventListener('click', function(event) {
- if (event.target.closest('.nfo-badge')) {
- event.preventDefault();
- const badge = event.target.closest('.nfo-badge');
- const guid = badge.getAttribute('data-guid');
- if (guid) {
- openNfoModal(guid);
- }
- }
- });
-}
-
-// Confirm dialogs
-export function initConfirmDialogs() {
- window.confirmDelete = function(id) {
- if (confirm('Are you sure you want to delete this item?')) {
- // If there's a form with this ID, submit it
- const form = document.getElementById('delete-form-' + id);
- if (form) {
- form.submit();
- }
- return true;
- }
- return false;
- };
-}
-
-// Image modal
-export function initImageModal() {
- // Handle image modal if needed
- document.querySelectorAll('[data-open-image-modal]').forEach(function(element) {
- element.addEventListener('click', function(e) {
- e.preventDefault();
- const imageUrl = this.getAttribute('data-open-image-modal');
- openImageModal(imageUrl);
- });
- });
-
- window.openImageModal = function(imageUrl) {
- const modal = document.getElementById('imageModal');
- if (modal) {
- const img = modal.querySelector('img');
- if (img) {
- img.src = imageUrl;
- }
- modal.classList.remove('hidden');
- modal.classList.add('flex');
- }
- };
-
- window.closeImageModal = function() {
- const modal = document.getElementById('imageModal');
- if (modal) {
- modal.classList.add('hidden');
- modal.classList.remove('flex');
- }
- };
-}
-
-// Preview Modal for Browse Page
-export function initPreviewModal() {
- window.closePreviewModal = function() {
- const modal = document.getElementById('previewModal');
- if (modal) {
- modal.style.display = 'none';
- modal.classList.add('hidden');
- const img = document.getElementById('previewImage');
- if (img) img.src = '';
- }
- };
-
- window.showPreviewImage = function(guid, type = 'preview') {
- const modal = document.getElementById('previewModal');
- const img = document.getElementById('previewImage');
- const error = document.getElementById('previewError');
- const title = document.getElementById('previewTitle');
-
- if (!modal || !img || !error || !title) return;
-
- modal.style.display = 'flex';
- modal.classList.remove('hidden');
- title.textContent = type === 'sample' ? 'Sample Image' : 'Preview Image';
-
- const imageUrl = '/covers/' + type + '/' + guid + '_thumb.jpg';
- img.src = imageUrl;
- error.classList.add('hidden');
- img.style.display = 'block';
-
- img.onerror = function() {
- error.textContent = (type === 'sample' ? 'Sample' : 'Preview') + ' image not available';
- error.classList.remove('hidden');
- img.style.display = 'none';
- };
-
- img.onload = function() {
- img.style.display = 'block';
- };
- };
-
- // Event listeners for preview badges
- document.querySelectorAll('.preview-badge').forEach(function(badge) {
- badge.addEventListener('click', function() {
- const guid = this.getAttribute('data-guid');
- if (guid) {
- window.showPreviewImage(guid, 'preview');
- }
- });
- });
-
- // Event listeners for sample badges
- document.querySelectorAll('.sample-badge').forEach(function(badge) {
- badge.addEventListener('click', function() {
- const guid = this.getAttribute('data-guid');
- if (guid) {
- window.showPreviewImage(guid, 'sample');
- }
- });
- });
-
- // NFO badge clicks are handled by initNfoModal() which uses event delegation
- // and the correct endpoint /nfo/{guid}?modal=1
-
- // Close preview modal on background click
- const previewModal = document.getElementById('previewModal');
- if (previewModal) {
- previewModal.addEventListener('click', function(e) {
- if (e.target === this) {
- window.closePreviewModal();
- }
- });
-
- // Event listener for close button (using onclick attribute)
- const closeButton = previewModal.querySelector('button[onclick*="closePreviewModal"]');
- if (closeButton) {
- closeButton.addEventListener('click', function(e) {
- e.stopPropagation();
- window.closePreviewModal();
- });
- }
-
- // Also handle any data-close-preview buttons
- const dataCloseButton = previewModal.querySelector('[data-close-preview]');
- if (dataCloseButton) {
- dataCloseButton.addEventListener('click', function(e) {
- e.stopPropagation();
- window.closePreviewModal();
- });
- }
- }
-
- // Close modal on Escape key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- window.closePreviewModal();
- }
- });
-}
-
-// Mediainfo and Filelist Modals
-export function initMediainfoAndFilelist() {
- window.closeMediainfoModal = function() {
- const modal = document.getElementById('mediainfoModal');
- if (modal) {
- modal.style.display = 'none';
- }
- };
-
- window.closeFilelistModal = function() {
- const modal = document.getElementById('filelistModal');
- if (modal) {
- modal.style.display = 'none';
- }
- };
-
- window.formatFileSize = function(bytes) {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
- };
-
- window.showFilelist = function(guid) {
- let modal = document.getElementById('filelistModal');
- if (modal && modal.parentElement !== document.body) {
- document.body.appendChild(modal);
- }
-
- const content = document.getElementById('filelistContent');
- modal.style.display = 'flex';
- modal.style.position = 'fixed';
- modal.style.top = '0';
- modal.style.left = '0';
- modal.style.right = '0';
- modal.style.bottom = '0';
- modal.style.zIndex = '99999';
- modal.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
-
- content.innerHTML = `
-
-
-
Loading file list...
-
- `;
-
- const apiUrl = '/api/release/' + guid + '/filelist';
-
- fetch(apiUrl)
- .then(response => {
- if (!response.ok) {
- throw new Error('Failed to load file list');
- }
- return response.json();
- })
- .then(data => {
- if (!data.files || data.files.length === 0) {
- content.innerHTML = 'No files available
';
- return;
- }
-
- let html = '';
- html += `
-
-
${escapeHtml(data.release.searchname)}
-
Total Files: ${data.total}
-
- `;
-
- html += `
-
-
-
-
- File Name
- Size
-
-
-
- `;
-
- data.files.forEach((file, index) => {
- const rowClass = index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900';
- const fileName = file.title || file.name || 'Unknown';
- const fileSize = file.size ? formatFileSize(file.size) : 'N/A';
-
- html += `
-
- ${escapeHtml(fileName)}
- ${fileSize}
-
- `;
- });
-
- html += `
-
-
-
- `;
-
- html += '
';
- content.innerHTML = html;
- })
- .catch(error => {
- content.innerHTML = `
-
-
-
${escapeHtml(error.message)}
-
- `;
- });
- };
-
- window.showMediainfo = function(releaseId) {
- let modal = document.getElementById('mediainfoModal');
-
- if (modal && modal.parentElement !== document.body) {
- document.body.appendChild(modal);
- }
-
- const content = document.getElementById('mediainfoContent');
-
- modal.style.display = 'flex';
- modal.style.position = 'fixed';
- modal.style.top = '0';
- modal.style.left = '0';
- modal.style.right = '0';
- modal.style.bottom = '0';
- modal.style.zIndex = '99999';
- modal.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
-
- content.innerHTML = `
-
-
-
Loading media information...
-
- `;
-
- const apiUrl = '/api/release/' + releaseId + '/mediainfo';
-
- fetch(apiUrl)
- .then(response => {
- if (!response.ok) {
- throw new Error('Failed to load media information');
- }
- return response.json();
- })
- .then(data => {
- if (!data.video && !data.audio && !data.subs) {
- content.innerHTML = 'No media information available
';
- return;
- }
-
- let html = '';
-
- // Video information
- if (data.video) {
- html += `
-
-
- Video Information
-
-
- `;
-
- if (data.video.containerformat) {
- html += `
-
-
Container
- ${escapeHtml(data.video.containerformat)}
-
- `;
- }
- if (data.video.videocodec) {
- html += `
-
-
Codec
- ${escapeHtml(data.video.videocodec)}
-
- `;
- }
- if (data.video.videowidth && data.video.videoheight) {
- html += `
-
-
Resolution
- ${data.video.videowidth}x${data.video.videoheight}
-
- `;
- }
- if (data.video.videoaspect) {
- html += `
-
-
Aspect Ratio
- ${escapeHtml(data.video.videoaspect)}
-
- `;
- }
- if (data.video.videoframerate) {
- html += `
-
-
Frame Rate
- ${escapeHtml(data.video.videoframerate)} fps
-
- `;
- }
- if (data.video.videoduration) {
- const durationMs = parseInt(data.video.videoduration);
- if (!isNaN(durationMs) && durationMs > 0) {
- const minutes = Math.round(durationMs / 1000 / 60);
- html += `
-
-
Duration
- ${minutes} minutes
-
- `;
- }
- }
-
- html += ' ';
- }
-
- // Audio information
- if (data.audio && data.audio.length > 0) {
- html += `
-
-
- Audio Information
-
- `;
-
- data.audio.forEach((audio, index) => {
- if (index > 0) html += '
';
- html += '
';
-
- if (audio.audioformat) {
- html += `
-
-
Format
- ${escapeHtml(audio.audioformat)}
-
- `;
- }
- if (audio.audiochannels) {
- html += `
-
-
Channels
- ${escapeHtml(audio.audiochannels)}
-
- `;
- }
- if (audio.audiobitrate) {
- html += `
-
-
Bit Rate
- ${escapeHtml(audio.audiobitrate)}
-
- `;
- }
- if (audio.audiolanguage) {
- html += `
-
-
Language
- ${escapeHtml(audio.audiolanguage)}
-
- `;
- }
- if (audio.audiosamplerate) {
- html += `
-
-
Sample Rate
- ${escapeHtml(audio.audiosamplerate)}
-
- `;
- }
-
- html += ' ';
- });
-
- html += '
';
- }
-
- // Subtitle information
- if (data.subs) {
- html += `
-
-
- Subtitles
-
-
${escapeHtml(data.subs)}
-
- `;
- }
-
- html += '
';
- content.innerHTML = html;
- })
- .catch(error => {
- content.innerHTML = `
-
-
-
${escapeHtml(error.message)}
-
- `;
- });
- };
-
- // Event handlers for badges and modals
- document.addEventListener('click', function(e) {
- // Preview badge
- const previewBadge = e.target.closest('.preview-badge');
- if (previewBadge) {
- e.preventDefault();
- const guid = previewBadge.dataset.guid;
- if (typeof showPreviewImage === 'function') {
- showPreviewImage(guid, 'preview');
- }
- }
-
- // Sample badge
- const sampleBadge = e.target.closest('.sample-badge');
- if (sampleBadge) {
- e.preventDefault();
- const guid = sampleBadge.dataset.guid;
- if (typeof showPreviewImage === 'function') {
- showPreviewImage(guid, 'sample');
- }
- }
-
- // Mediainfo badge
- const mediainfoBadge = e.target.closest('.mediainfo-badge');
- if (mediainfoBadge) {
- e.preventDefault();
- const releaseId = mediainfoBadge.dataset.releaseId;
- showMediainfo(releaseId);
- }
-
- // File list badge
- const filelistBadge = e.target.closest('.filelist-badge');
- if (filelistBadge) {
- e.preventDefault();
- const guid = filelistBadge.dataset.guid;
- showFilelist(guid);
- }
-
- // Handle modal close buttons with data attributes
- if (e.target.hasAttribute('data-close-preview-modal') || e.target.closest('[data-close-preview-modal]')) {
- e.preventDefault();
- if (typeof closePreviewModal === 'function') {
- closePreviewModal();
- }
- }
-
- if (e.target.hasAttribute('data-close-mediainfo-modal') || e.target.closest('[data-close-mediainfo-modal]')) {
- e.preventDefault();
- if (typeof closeMediainfoModal === 'function') {
- closeMediainfoModal();
- }
- }
-
- if (e.target.hasAttribute('data-close-filelist-modal') || e.target.closest('[data-close-filelist-modal]')) {
- e.preventDefault();
- if (typeof closeFilelistModal === 'function') {
- closeFilelistModal();
- }
- }
- });
-
- // Close modals on background click
- const previewModal = document.getElementById('previewModal');
- if (previewModal) {
- previewModal.addEventListener('click', function(e) {
- if (e.target === this && typeof closePreviewModal === 'function') {
- closePreviewModal();
- }
- });
- }
-
- const mediainfoModal = document.getElementById('mediainfoModal');
- if (mediainfoModal) {
- mediainfoModal.addEventListener('click', function(e) {
- // Close if clicking the backdrop OR the close button
- if (e.target === this || e.target.closest('[data-close-mediainfo-modal]')) {
- closeMediainfoModal();
- }
- });
- }
-
- const filelistModal = document.getElementById('filelistModal');
- if (filelistModal) {
- filelistModal.addEventListener('click', function(e) {
- // Close if clicking the backdrop OR the close button
- if (e.target === this || e.target.closest('[data-close-filelist-modal]')) {
- closeFilelistModal();
- }
- });
- }
-
- // Close modals on ESC key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- if (typeof closePreviewModal === 'function') closePreviewModal();
- closeMediainfoModal();
- closeFilelistModal();
- }
- });
-}
-
-// Details page image modal
-export function initDetailsPageImageModal() {
- // Handle image modal triggers on release details page
- const imageModalTriggers = document.querySelectorAll('.image-modal-trigger');
- const imageModal = document.getElementById('imageModal');
- const imageModalImage = document.getElementById('imageModalImage');
- const imageModalTitle = document.getElementById('imageModalTitle');
- const closeButtons = document.querySelectorAll('[data-close-image-modal]');
-
- if (!imageModal || !imageModalImage || !imageModalTitle) {
- return; // Not on the details page
- }
-
- // Add click handlers to image triggers
- imageModalTriggers.forEach(function(trigger) {
- trigger.addEventListener('click', function(e) {
- e.preventDefault();
- const imageUrl = this.getAttribute('data-image-url');
- const imageTitle = this.getAttribute('data-image-title') || 'Image Preview';
-
- // Use the image URL as-is (keeping _thumb suffix)
- imageModalImage.src = imageUrl;
- imageModalTitle.textContent = imageTitle;
- imageModal.classList.remove('hidden');
- imageModal.style.display = 'flex';
- });
- });
-
- // Add click handler to close button
- closeButtons.forEach(function(closeBtn) {
- closeBtn.addEventListener('click', function(e) {
- e.preventDefault();
- imageModal.classList.add('hidden');
- imageModal.style.display = 'none';
- });
- });
-
- // Close modal when clicking on backdrop
- imageModal.addEventListener('click', function(e) {
- if (e.target === imageModal) {
- imageModal.classList.add('hidden');
- imageModal.style.display = 'none';
- }
- });
-
- // Close modal with Escape key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape' && !imageModal.classList.contains('hidden')) {
- imageModal.classList.add('hidden');
- imageModal.style.display = 'none';
- }
- });
-}
-
-// Modal styles initialization
-export function initModalStyles() {
- // Preview modal
- const previewModal = document.getElementById('previewModal');
- if (previewModal) {
- previewModal.style.display = 'none';
- previewModal.style.zIndex = '9999';
- }
-
- // NFO modal
- const nfoModal = document.getElementById('nfoModal');
- if (nfoModal) {
- nfoModal.style.display = 'none';
- }
-
- // Image modal
- const imageModal = document.getElementById('imageModal');
- if (imageModal) {
- imageModal.style.display = 'none';
- }
-
- // MediaInfo modal
- const mediainfoModal = document.getElementById('mediainfoModal');
- if (mediainfoModal) {
- mediainfoModal.style.display = 'none';
- mediainfoModal.style.zIndex = '9999';
- }
-
- // Filelist modal
- const filelistModal = document.getElementById('filelistModal');
- if (filelistModal) {
- filelistModal.style.display = 'none';
- filelistModal.style.zIndex = '9999';
- }
-
- // MediaInfo content scroll
- const mediainfoContent = document.getElementById('mediainfoContent');
- if (mediainfoContent) {
- mediainfoContent.style.maxHeight = 'calc(90vh - 80px)';
- }
-
- // Filelist content scroll
- const filelistContent = document.getElementById('filelistContent');
- if (filelistContent) {
- filelistContent.style.maxHeight = 'calc(90vh - 80px)';
- }
-}
diff --git a/resources/js/modules/navigation.js b/resources/js/modules/navigation.js
deleted file mode 100644
index 55df9c859..000000000
--- a/resources/js/modules/navigation.js
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * Navigation-related functions extracted from csp-safe.js
- */
-
-import { applyTheme, saveThemePreference } from './theme.js';
-
-// Admin Menu
-export function initAdminMenu() {
- window.toggleAdminSubmenu = function(id) {
- const submenu = document.getElementById(id);
- const icon = document.getElementById(id + '-icon');
- if (submenu) {
- submenu.classList.toggle('hidden');
- }
- if (icon) {
- icon.classList.toggle('rotate-180');
- }
- };
-
- // Add click handlers for admin menu buttons
- document.addEventListener('click', function(e) {
- const menuButton = e.target.closest('[data-toggle-submenu]');
- if (menuButton) {
- const menuId = menuButton.getAttribute('data-toggle-submenu');
- if (menuId) {
- toggleAdminSubmenu(menuId);
- }
- }
- });
-
- // Theme toggle button handler - cycles through light -> dark -> system
- const themeToggle = document.getElementById('theme-toggle');
- if (themeToggle) {
- themeToggle.addEventListener('click', function() {
- const metaTheme = document.querySelector('meta[name="theme-preference"]');
- const currentTheme = metaTheme ? metaTheme.content : localStorage.getItem('theme') || 'light';
- let nextTheme;
-
- // Cycle through: light -> dark -> system -> light
- if (currentTheme === 'light') {
- nextTheme = 'dark';
- } else if (currentTheme === 'dark') {
- nextTheme = 'system';
- } else {
- nextTheme = 'light';
- }
-
- applyTheme(nextTheme);
- saveThemePreference(nextTheme);
- });
- }
-
-}
-
-// Sidebar Toggle functionality for regular user sidebar
-export function initSidebarToggle() {
- const sidebarToggles = document.querySelectorAll('.sidebar-toggle');
-
- sidebarToggles.forEach(toggle => {
- toggle.addEventListener('click', function() {
- const targetId = this.getAttribute('data-target');
- const submenu = document.getElementById(targetId);
- const chevron = this.querySelector('.fa-chevron-down');
-
- if (submenu) {
- submenu.classList.toggle('hidden');
- if (chevron) {
- chevron.classList.toggle('rotate-180');
- }
- }
- });
- });
-
- // Handle logout link in sidebar
- const sidebarLogoutLink = document.querySelector('[data-logout]');
- const sidebarLogoutForm = document.getElementById('sidebar-logout-form');
-
- if (sidebarLogoutLink && sidebarLogoutForm) {
- sidebarLogoutLink.addEventListener('click', function(e) {
- e.preventDefault();
- sidebarLogoutForm.submit();
- });
- }
-}
-
-// Dropdown Menus for Header Navigation
-export function initDropdownMenus() {
- const dropdownContainers = document.querySelectorAll('.dropdown-container');
-
- dropdownContainers.forEach(function(container) {
- const toggle = container.querySelector('.dropdown-toggle');
- const menu = container.querySelector('.dropdown-menu');
-
- if (!toggle || !menu) return;
-
- let closeTimeout;
-
- // Ensure menu is hidden initially
- menu.style.display = 'none';
-
- // Toggle on click
- toggle.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const isCurrentlyOpen = menu.style.display === 'block';
-
- // Close all other dropdowns
- dropdownContainers.forEach(function(otherContainer) {
- if (otherContainer !== container) {
- const otherMenu = otherContainer.querySelector('.dropdown-menu');
- if (otherMenu) {
- otherMenu.style.display = 'none';
- }
- }
- });
-
- // Toggle this dropdown
- if (isCurrentlyOpen) {
- menu.style.display = 'none';
- } else {
- menu.style.display = 'block';
- }
- });
-
- // Keep open on hover over the container
- container.addEventListener('mouseenter', function() {
- clearTimeout(closeTimeout);
- });
-
- // Close after delay when leaving the container
- container.addEventListener('mouseleave', function() {
- closeTimeout = setTimeout(function() {
- menu.style.display = 'none';
- }, 300);
- });
-
- // Prevent closing when hovering over the menu itself
- menu.addEventListener('mouseenter', function() {
- clearTimeout(closeTimeout);
- });
- });
-
- // Close dropdowns when clicking outside
- document.addEventListener('click', function(e) {
- if (!e.target.closest('.dropdown-container')) {
- dropdownContainers.forEach(function(container) {
- const menu = container.querySelector('.dropdown-menu');
- if (menu) {
- menu.style.display = 'none';
- }
- });
- }
- });
-
- // Handle nested submenus (e.g., Foreign languages dropdown)
- const submenuContainers = document.querySelectorAll('.submenu-container');
- submenuContainers.forEach(function(container) {
- const submenu = container.querySelector('.submenu');
- if (!submenu) return;
-
- let submenuCloseTimeout;
-
- container.addEventListener('mouseenter', function() {
- clearTimeout(submenuCloseTimeout);
- submenu.style.display = 'block';
- });
-
- container.addEventListener('mouseleave', function() {
- submenuCloseTimeout = setTimeout(function() {
- submenu.style.display = 'none';
- }, 200);
- });
-
- submenu.addEventListener('mouseenter', function() {
- clearTimeout(submenuCloseTimeout);
- });
- });
-
- // Mobile menu toggle - opens/closes the mobile nav panel
- const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
- const mobileNavPanel = document.getElementById('mobile-nav-panel');
- const mobileMenuIconOpen = document.getElementById('mobile-menu-icon-open');
- const mobileMenuIconClose = document.getElementById('mobile-menu-icon-close');
-
- if (mobileMenuToggle && mobileNavPanel) {
- mobileMenuToggle.addEventListener('click', function() {
- const isOpen = !mobileNavPanel.classList.contains('hidden');
-
- if (isOpen) {
- mobileNavPanel.classList.add('hidden');
- mobileMenuToggle.setAttribute('aria-expanded', 'false');
- } else {
- mobileNavPanel.classList.remove('hidden');
- mobileMenuToggle.setAttribute('aria-expanded', 'true');
- // Close mobile search when opening nav
- const mobileSearchForm = document.getElementById('mobile-search-form');
- if (mobileSearchForm) mobileSearchForm.classList.add('hidden');
- }
-
- // Toggle hamburger / X icons
- if (mobileMenuIconOpen && mobileMenuIconClose) {
- mobileMenuIconOpen.classList.toggle('hidden');
- mobileMenuIconClose.classList.toggle('hidden');
- }
- });
- }
-
- // Mobile nav accordion toggles (category expand/collapse)
- document.querySelectorAll('.mobile-nav-toggle').forEach(function(toggle) {
- toggle.addEventListener('click', function() {
- const section = this.closest('.mobile-nav-section');
- if (!section) return;
-
- const submenu = section.querySelector('.mobile-nav-submenu');
- const chevron = this.querySelector('.mobile-nav-chevron');
-
- if (submenu) {
- submenu.classList.toggle('hidden');
- }
- if (chevron) {
- chevron.classList.toggle('rotate-180');
- }
- });
- });
-
- // Mobile search toggle
- const mobileSearchToggle = document.getElementById('mobile-search-toggle');
- const mobileSearchForm = document.getElementById('mobile-search-form');
- if (mobileSearchToggle && mobileSearchForm) {
- mobileSearchToggle.addEventListener('click', function(e) {
- e.preventDefault();
- mobileSearchForm.classList.toggle('hidden');
- // Close mobile nav when opening search
- if (mobileNavPanel && !mobileSearchForm.classList.contains('hidden')) {
- mobileNavPanel.classList.add('hidden');
- if (mobileMenuIconOpen && mobileMenuIconClose) {
- mobileMenuIconOpen.classList.remove('hidden');
- mobileMenuIconClose.classList.add('hidden');
- }
- if (mobileMenuToggle) mobileMenuToggle.setAttribute('aria-expanded', 'false');
- }
- });
- }
-
- // Close mobile panels on window resize past lg breakpoint
- function closeMobilePanelsOnResize() {
- if (window.innerWidth >= 1024) {
- if (mobileNavPanel) mobileNavPanel.classList.add('hidden');
- if (mobileSearchForm) mobileSearchForm.classList.add('hidden');
- if (mobileMenuIconOpen && mobileMenuIconClose) {
- mobileMenuIconOpen.classList.remove('hidden');
- mobileMenuIconClose.classList.add('hidden');
- }
- if (mobileMenuToggle) mobileMenuToggle.setAttribute('aria-expanded', 'false');
- }
- }
- window.addEventListener('resize', closeMobilePanelsOnResize);
-}
-
-// Mobile enhancements
-export function initMobileEnhancements() {
- // Handle mobile-specific enhancements
- const mobileSidebarToggle = document.getElementById('mobile-sidebar-toggle');
- if (mobileSidebarToggle) {
- mobileSidebarToggle.addEventListener('click', function() {
- const sidebar = document.getElementById('sidebar');
- if (sidebar) {
- sidebar.classList.toggle('hidden');
- sidebar.classList.toggle('flex');
- }
- });
- }
-}
diff --git a/resources/js/modules/profile.js b/resources/js/modules/profile.js
deleted file mode 100644
index adc5882a0..000000000
--- a/resources/js/modules/profile.js
+++ /dev/null
@@ -1,245 +0,0 @@
-/**
- * Profile page module
- * Extracted from csp-safe.js
- */
-import { applyTheme, saveThemePreference, themeMediaQuery } from './theme.js';
-
-let downloadsChart = null;
-let apiRequestsChart = null;
-
-export function initProfilePage() {
- // Animate progress bars
- animateProgressBars();
-}
-
-function animateProgressBars() {
- document.querySelectorAll('.progress-bar').forEach(bar => {
- const width = bar.dataset.width;
- if (width) {
- setTimeout(() => {
- bar.style.width = width + '%';
- }, 100);
- }
- });
-}
-
-// Profile edit page functionality
-export function initProfileEdit() {
- // 2FA Disable Form Toggle
- const toggleBtn = document.getElementById('toggle-disable-2fa-btn');
- const cancelBtn = document.getElementById('cancel-disable-2fa-btn');
- const formContainer = document.getElementById('disable-2fa-form-container');
-
- if (toggleBtn && formContainer) {
- toggleBtn.addEventListener('click', function() {
- formContainer.style.display = formContainer.style.display === 'none' ? 'block' : 'none';
- });
- }
-
- if (cancelBtn && formContainer) {
- cancelBtn.addEventListener('click', function() {
- formContainer.style.display = 'none';
- // Clear password field
- const passwordInput = document.getElementById('disable_2fa_password');
- if (passwordInput) {
- passwordInput.value = '';
- }
- });
- }
-
- // Theme preference instant preview (user area - profile edit page)
- const allThemeRadios = document.querySelectorAll('input[name="theme_preference"]');
-
- allThemeRadios.forEach(radio => {
- // Skip if event listener already attached (prevent duplicates)
- if (radio.dataset.themeListenerAttached === 'true') {
- return;
- }
- radio.dataset.themeListenerAttached = 'true';
-
- radio.addEventListener('change', function() {
- // Prevent event loop if we're programmatically updating
- if (window._updatingThemeUI) {
- return;
- }
- const selectedTheme = this.value;
- applyTheme(selectedTheme);
- saveThemePreference(selectedTheme);
- });
- });
-}
-
-// Theme Management (moved from main.blade.php)
-export function initThemeManagement() {
-
- function updateThemeButton(theme) {
- const themeIcon = document.getElementById('theme-icon');
- const themeLabel = document.getElementById('theme-label');
- const themeToggle = document.getElementById('theme-toggle');
-
- const icons = {
- 'light': 'fa-sun',
- 'dark': 'fa-moon',
- 'system': 'fa-desktop'
- };
- const labels = {
- 'light': 'Light',
- 'dark': 'Dark',
- 'system': 'System'
- };
- const titles = {
- 'light': 'Light Mode',
- 'dark': 'Dark Mode',
- 'system': 'System Mode'
- };
-
- if (themeIcon) {
- themeIcon.classList.remove('fa-sun', 'fa-moon', 'fa-desktop');
- themeIcon.classList.add(icons[theme]);
- }
- if (themeLabel) {
- themeLabel.textContent = labels[theme];
- }
- if (themeToggle) {
- themeToggle.setAttribute('title', titles[theme]);
- }
- }
-
- // Get current theme from data attribute or localStorage
- const currentThemeElement = document.getElementById('current-theme-data');
- let currentTheme = currentThemeElement ? currentThemeElement.dataset.theme : 'light';
- const isAuthenticated = currentThemeElement ? currentThemeElement.dataset.authenticated === 'true' : false;
-
- if (!isAuthenticated) {
- currentTheme = localStorage.getItem('theme') || 'light';
- }
-
- // Listen for OS theme changes
- themeMediaQuery.addEventListener('change', () => {
- if (currentTheme === 'system') {
- applyTheme('system');
- }
- });
-
- // Listen for custom theme change events
- document.addEventListener('themeChanged', function(e) {
- if (e.detail && e.detail.theme) {
- updateThemeButton(e.detail.theme);
- }
- });
-
- // Dark mode toggle (user area - only if not already handled by admin)
- const themeToggle = document.getElementById('theme-toggle');
- // Check if this is admin area by looking for admin-specific elements
- const isAdminArea = document.querySelector('aside.bg-gray-900.dark\\:bg-gray-950') &&
- document.querySelector('a[href*="admin"]');
-
- if (themeToggle && !isAdminArea) {
- // Only handle if not in admin area (admin has its own handler in initAdminMenu)
- themeToggle.addEventListener('click', function() {
- const metaTheme = document.querySelector('meta[name="theme-preference"]');
- const currentTheme = metaTheme ? metaTheme.content : localStorage.getItem('theme') || 'light';
- let nextTheme;
-
- if (currentTheme === 'light') {
- nextTheme = 'dark';
- } else if (currentTheme === 'dark') {
- nextTheme = 'system';
- } else {
- nextTheme = 'light';
- }
-
- applyTheme(nextTheme);
- saveThemePreference(nextTheme);
- });
- }
-
- // Mobile sidebar toggle
- document.getElementById('mobile-sidebar-toggle')?.addEventListener('click', function() {
- document.getElementById('sidebar')?.classList.toggle('hidden');
- });
-}
-
-// Copy to Clipboard functionality - consolidated and optimized
-export function initCopyToClipboard() {
- // Unified copy function with visual feedback
- function copyToClipboard(text, button) {
- // Try modern Clipboard API first
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(function() {
- showCopyFeedback(button);
- }).catch(function() {
- // Fallback for older browsers
- fallbackCopy(text, button);
- });
- } else {
- fallbackCopy(text, button);
- }
- }
-
- function fallbackCopy(text, button) {
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.select();
- textarea.setSelectionRange(0, 99999);
- try {
- document.execCommand('copy');
- showCopyFeedback(button);
- } catch (err) {
- console.error('Failed to copy:', err);
- }
- document.body.removeChild(textarea);
- }
-
- function showCopyFeedback(button) {
- if (!button) return;
- const icon = button.querySelector('i');
- if (icon) {
- icon.classList.remove('fa-copy');
- icon.classList.add('fa-check');
- }
- if (button.classList) {
- button.classList.add('text-green-600');
- }
- setTimeout(function() {
- if (!button) return;
- if (icon) {
- icon.classList.remove('fa-check');
- icon.classList.add('fa-copy');
- }
- if (button.classList) {
- button.classList.remove('text-green-600');
- }
- }, 2000);
- }
-
- // Event delegation for copy buttons
- document.addEventListener('click', function(e) {
- const copyBtn = e.target.closest('.copy-btn');
- if (copyBtn) {
- const targetId = copyBtn.getAttribute('data-copy-target');
- const input = document.getElementById(targetId);
- if (input) {
- e.preventDefault();
- input.select();
- input.setSelectionRange(0, 99999);
- copyToClipboard(input.value, copyBtn);
- }
- }
-
- // API Token copy button (specific ID)
- const apiTokenBtn = e.target.id === 'copyApiToken' ? e.target : e.target.closest('#copyApiToken');
- if (apiTokenBtn) {
- const input = document.getElementById('apiTokenInput');
- if (input) {
- e.preventDefault();
- input.select();
- input.setSelectionRange(0, 99999);
- copyToClipboard(input.value, apiTokenBtn);
- }
- }
- });
-}
diff --git a/resources/js/modules/releases.js b/resources/js/modules/releases.js
deleted file mode 100644
index ab6576016..000000000
--- a/resources/js/modules/releases.js
+++ /dev/null
@@ -1,334 +0,0 @@
-/**
- * Release reports module
- * Extracted from csp-safe.js
- */
-
-export function initReleaseReportButtons() {
- // Find all report button containers
- var containers = document.querySelectorAll('.report-button-container');
-
- containers.forEach(function(container) {
- var trigger = container.querySelector('.report-trigger');
- var modal = container.querySelector('.report-modal');
-
- if (!trigger || !modal) return;
-
- // Skip if already initialized
- if (trigger.hasAttribute('data-report-initialized')) return;
- trigger.setAttribute('data-report-initialized', 'true');
-
- var releaseId = trigger.getAttribute('data-report-release-id');
-
- var form = modal.querySelector('.report-form');
- var closeButtons = modal.querySelectorAll('.report-modal-close');
- var backdrop = modal.querySelector('.report-modal-backdrop');
- var reasonSelect = modal.querySelector('.report-reason');
- var descriptionTextarea = modal.querySelector('.report-description');
- var charCount = modal.querySelector('.report-char-count');
- var submitButton = modal.querySelector('.report-submit');
- var errorDiv = modal.querySelector('.report-error');
- var successDiv = modal.querySelector('.report-success');
- var reportLabel = trigger.querySelector('.report-label');
- var flagIcon = trigger.querySelector('i');
-
- var hasReported = false;
- var isSubmitting = false;
-
- // Open modal
- trigger.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- if (hasReported) return;
-
- modal.classList.remove('hidden');
- // Reset form state
- if (errorDiv) errorDiv.classList.add('hidden');
- if (successDiv) successDiv.classList.add('hidden');
- if (reasonSelect) reasonSelect.value = '';
- if (descriptionTextarea) descriptionTextarea.value = '';
- if (charCount) charCount.textContent = '0/1000 characters';
- if (submitButton) submitButton.disabled = true;
- });
-
- // Close modal function
- function closeModal() {
- modal.classList.add('hidden');
- if (hasReported) {
- trigger.disabled = true;
- trigger.classList.add('opacity-50', 'cursor-not-allowed');
- if (flagIcon) flagIcon.classList.add('text-red-500');
- if (reportLabel) reportLabel.textContent = 'Reported';
- }
- }
-
- // Close button handlers
- closeButtons.forEach(function(closeBtn) {
- closeBtn.addEventListener('click', function(e) {
- e.preventDefault();
- closeModal();
- });
- });
-
- // Backdrop click to close
- if (backdrop) {
- backdrop.addEventListener('click', function() {
- closeModal();
- });
- }
-
- // Character count for description
- if (descriptionTextarea && charCount) {
- descriptionTextarea.addEventListener('input', function() {
- charCount.textContent = this.value.length + '/1000 characters';
- });
- }
-
- // Enable/disable submit based on reason selection
- if (reasonSelect && submitButton) {
- reasonSelect.addEventListener('change', function() {
- submitButton.disabled = !this.value;
- });
- }
-
- // Submit button click handler
- if (submitButton) {
- submitButton.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- if (isSubmitting || !reasonSelect || !reasonSelect.value) return;
-
- isSubmitting = true;
- submitButton.disabled = true;
- submitButton.innerHTML = ' Submitting...';
-
- if (errorDiv) errorDiv.classList.add('hidden');
- if (successDiv) successDiv.classList.add('hidden');
-
- var csrfToken = document.querySelector('meta[name="csrf-token"]');
- var csrfValue = csrfToken ? csrfToken.getAttribute('content') : '';
-
- var requestBody = {
- release_id: releaseId,
- reason: reasonSelect.value,
- description: descriptionTextarea ? descriptionTextarea.value : ''
- };
-
- fetch('/release-report', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfValue,
- 'Accept': 'application/json'
- },
- body: JSON.stringify(requestBody)
- })
- .then(function(response) {
- return response.json().then(function(data) {
- return { ok: response.ok, data: data };
- });
- })
- .then(function(result) {
- if (result.ok && result.data.success) {
- if (successDiv) {
- successDiv.querySelector('p').textContent = result.data.message;
- successDiv.classList.remove('hidden');
- }
- hasReported = true;
- setTimeout(closeModal, 2000);
- } else {
- if (errorDiv) {
- errorDiv.querySelector('p').textContent = result.data.message || 'An error occurred. Please try again.';
- errorDiv.classList.remove('hidden');
- }
- }
- })
- .catch(function(error) {
- if (errorDiv) {
- errorDiv.querySelector('p').textContent = 'Network error. Please try again.';
- errorDiv.classList.remove('hidden');
- }
- })
- .finally(function() {
- isSubmitting = false;
- if (submitButton) {
- submitButton.disabled = !reasonSelect.value;
- submitButton.innerHTML = 'Submit Report';
- }
- });
- });
- }
- });
-}
-
-// Initialize report buttons on DOMContentLoaded
-document.addEventListener('DOMContentLoaded', initReleaseReportButtons);
-
-// Re-initialize on dynamic content load (for AJAX-loaded content)
-document.addEventListener('contentLoaded', initReleaseReportButtons);
-
-/**
- * Initialize admin release reports page functionality
- * Handles description modal, revert confirmation modal, select all, and bulk actions
- */
-export function initAdminReleaseReports() {
- // Report Description Modal
- var descModal = document.getElementById('reportDescriptionModal');
- var descButtons = document.querySelectorAll('.report-description-btn');
- var descContent = document.getElementById('reportDescContent');
- var descReason = document.getElementById('reportDescReason');
- var descReporter = document.getElementById('reportDescReporter');
- var descCloseButtons = document.querySelectorAll('.report-desc-modal-close');
- var descBackdrop = document.querySelector('.report-desc-modal-backdrop');
-
- // Revert Confirmation Modal
- var revertModal = document.getElementById('revertConfirmModal');
- var revertButtons = document.querySelectorAll('.revert-report-btn');
- var revertForm = document.getElementById('revertConfirmForm');
- var revertStatusSpan = document.getElementById('revertReportStatus');
- var revertCloseButtons = document.querySelectorAll('.revert-modal-close');
- var revertBackdrop = document.querySelector('.revert-modal-backdrop');
-
- // Open revert confirmation modal
- revertButtons.forEach(function(btn) {
- if (btn.hasAttribute('data-revert-initialized')) return;
- btn.setAttribute('data-revert-initialized', 'true');
-
- btn.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- var actionUrl = this.getAttribute('data-action-url');
- var status = this.getAttribute('data-report-status');
-
- if (revertForm) revertForm.setAttribute('action', actionUrl);
- if (revertStatusSpan) revertStatusSpan.textContent = status;
-
- if (revertModal) revertModal.classList.remove('hidden');
- });
- });
-
- // Close revert modal
- function closeRevertModal() {
- if (revertModal) revertModal.classList.add('hidden');
- }
-
- revertCloseButtons.forEach(function(btn) {
- btn.addEventListener('click', function(e) {
- e.preventDefault();
- closeRevertModal();
- });
- });
-
- if (revertBackdrop) {
- revertBackdrop.addEventListener('click', closeRevertModal);
- }
-
- // Close revert modal on Escape key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- closeRevertModal();
- closeDescModal();
- }
- });
-
- // Open description modal
- descButtons.forEach(function(btn) {
- if (btn.hasAttribute('data-desc-initialized')) return;
- btn.setAttribute('data-desc-initialized', 'true');
-
- btn.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- var description = this.getAttribute('data-description');
- var reason = this.getAttribute('data-reason');
- var reporter = this.getAttribute('data-reporter');
-
- if (descContent) descContent.textContent = description || 'No additional details provided.';
- if (descReason) descReason.textContent = reason || 'Unknown';
- if (descReporter) descReporter.textContent = reporter || 'Unknown';
-
- if (descModal) descModal.classList.remove('hidden');
- });
- });
-
- // Close description modal
- function closeDescModal() {
- if (descModal) descModal.classList.add('hidden');
- }
-
- descCloseButtons.forEach(function(btn) {
- btn.addEventListener('click', function(e) {
- e.preventDefault();
- closeDescModal();
- });
- });
-
- if (descBackdrop) {
- descBackdrop.addEventListener('click', closeDescModal);
- }
-
- // Select All functionality
- var selectAll = document.getElementById('select-all');
- var checkboxes = document.querySelectorAll('.report-checkbox');
-
- if (selectAll && !selectAll.hasAttribute('data-selectall-initialized')) {
- selectAll.setAttribute('data-selectall-initialized', 'true');
-
- selectAll.addEventListener('change', function() {
- checkboxes.forEach(function(checkbox) {
- checkbox.checked = selectAll.checked;
- });
- });
- }
-
- // Update select all checkbox when individual checkboxes change
- checkboxes.forEach(function(checkbox) {
- if (checkbox.hasAttribute('data-checkbox-initialized')) return;
- checkbox.setAttribute('data-checkbox-initialized', 'true');
-
- checkbox.addEventListener('change', function() {
- var allChecked = Array.from(checkboxes).every(function(cb) { return cb.checked; });
- var someChecked = Array.from(checkboxes).some(function(cb) { return cb.checked; });
- if (selectAll) {
- selectAll.checked = allChecked;
- selectAll.indeterminate = someChecked && !allChecked;
- }
- });
- });
-
- // Bulk action form validation
- var bulkForm = document.getElementById('bulk-action-form');
- if (bulkForm && !bulkForm.hasAttribute('data-bulk-initialized')) {
- bulkForm.setAttribute('data-bulk-initialized', 'true');
-
- bulkForm.addEventListener('submit', function(e) {
- var actionSelect = this.querySelector('select[name="action"]');
- var action = actionSelect ? actionSelect.value : '';
- var checkedCount = document.querySelectorAll('.report-checkbox:checked').length;
-
- if (!action) {
- e.preventDefault();
- alert('Please select an action.');
- return;
- }
-
- if (checkedCount === 0) {
- e.preventDefault();
- alert('Please select at least one report.');
- return;
- }
-
- if (action === 'delete') {
- if (!confirm('Are you sure you want to DELETE ' + checkedCount + ' release(s)? This action cannot be undone.')) {
- e.preventDefault();
- }
- }
- });
- }
-}
-
-// Initialize admin release reports on DOMContentLoaded
-document.addEventListener('DOMContentLoaded', initAdminReleaseReports);
diff --git a/resources/js/modules/search.js b/resources/js/modules/search.js
deleted file mode 100644
index 5d9d65afc..000000000
--- a/resources/js/modules/search.js
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * Search and autocomplete module
- * Extracted from csp-safe.js
- */
-
-export function initSearchAutocomplete() {
- // Initialize header autocomplete (desktop and mobile)
- initAutocompleteInput('header-search-input', 'header-autocomplete-dropdown', 'header-search-form', 'header-autocomplete-item');
- initAutocompleteInput('mobile-search-input', 'mobile-autocomplete-dropdown', 'mobile-search-form-el', 'header-autocomplete-item');
-
- // Initialize main search page autocomplete
- initAutocompleteInput('search', 'autocomplete-dropdown', 'searchForm', 'autocomplete-item');
-
- // Initialize any dynamic autocomplete inputs (from Blade components)
- document.querySelectorAll('[data-autocomplete-input]').forEach(function(input) {
- const dropdownId = input.getAttribute('data-autocomplete-input');
- const formId = input.getAttribute('data-autocomplete-form');
- const inputId = input.id;
- if (inputId && dropdownId) {
- initAutocompleteInput(inputId, dropdownId, formId, 'autocomplete-suggestion');
- }
- });
-
- // Apply CSP-safe styles to modals (replaces inline styles)
- initModalStyles();
-}
-
-/**
- * Initialize autocomplete for a specific input element
- * @param {string} inputId - ID of the search input
- * @param {string} dropdownId - ID of the dropdown container
- * @param {string} formId - ID of the form to submit
- * @param {string} itemClass - CSS class for dropdown items
- */
-export function initAutocompleteInput(inputId, dropdownId, formId, itemClass) {
- const searchInput = document.getElementById(inputId);
- const dropdown = document.getElementById(dropdownId);
- const form = document.getElementById(formId);
-
- if (!searchInput || !dropdown) return;
-
- let debounceTimer;
- let currentIndex = -1;
- let suggestions = [];
-
- // Input handler with debounce
- searchInput.addEventListener('input', function() {
- clearTimeout(debounceTimer);
- const query = this.value.trim();
-
- if (query.length < 2) {
- hideDropdown();
- return;
- }
-
- debounceTimer = setTimeout(async () => {
- try {
- const response = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(query)}`);
- const data = await response.json();
-
- if (data.success && data.suggestions && data.suggestions.length > 0) {
- suggestions = data.suggestions;
- renderDropdown(suggestions, query);
- } else {
- hideDropdown();
- }
- } catch (error) {
- console.error('Autocomplete error:', error);
- hideDropdown();
- }
- }, 200);
- });
-
- // Keyboard navigation
- searchInput.addEventListener('keydown', function(e) {
- if (!dropdown.classList.contains('hidden')) {
- const items = dropdown.querySelectorAll('.' + itemClass);
-
- switch(e.key) {
- case 'ArrowDown':
- e.preventDefault();
- currentIndex = Math.min(currentIndex + 1, items.length - 1);
- updateSelection(items);
- break;
- case 'ArrowUp':
- e.preventDefault();
- currentIndex = Math.max(currentIndex - 1, 0);
- updateSelection(items);
- break;
- case 'Enter':
- if (currentIndex >= 0 && items[currentIndex]) {
- e.preventDefault();
- searchInput.value = suggestions[currentIndex];
- hideDropdown();
- if (form) form.submit();
- }
- break;
- case 'Escape':
- hideDropdown();
- break;
- }
- }
- });
-
- // Click outside to close
- document.addEventListener('click', function(e) {
- if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
- hideDropdown();
- }
- });
-
- function renderDropdown(items, query) {
- currentIndex = -1;
- const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
-
- const maxItems = itemClass === 'header-autocomplete-item' ? 8 : 10;
- const sizeClass = itemClass === 'header-autocomplete-item' ? 'px-3 py-2 text-sm' : 'px-4 py-2';
- const iconSizeClass = itemClass === 'header-autocomplete-item' ? 'text-xs' : '';
-
- dropdown.innerHTML = items.slice(0, maxItems).map((item, index) => {
- const highlighted = item.replace(regex, '$1 ');
- return `
-
-
- ${highlighted}
-
- `;
- }).join('');
-
- // Add click handlers
- dropdown.querySelectorAll('.' + itemClass).forEach((item, index) => {
- item.addEventListener('click', function() {
- searchInput.value = suggestions[index];
- hideDropdown();
- if (form) form.submit();
- });
- item.addEventListener('mouseenter', function() {
- currentIndex = index;
- updateSelection(dropdown.querySelectorAll('.' + itemClass));
- });
- });
-
- dropdown.classList.remove('hidden');
- }
-
- function hideDropdown() {
- dropdown.classList.add('hidden');
- dropdown.innerHTML = '';
- suggestions = [];
- currentIndex = -1;
- }
-
- function updateSelection(items) {
- items.forEach((item, index) => {
- if (index === currentIndex) {
- item.classList.add('bg-blue-100', 'dark:bg-blue-900');
- } else {
- item.classList.remove('bg-blue-100', 'dark:bg-blue-900');
- }
- });
- }
-}
-
-/**
- * Initialize sort dropdown functionality
- * Handles the custom dropdown for sorting releases on browse/search pages
- */
-export function initSortDropdowns() {
- document.querySelectorAll('.sort-dropdown').forEach(function(dropdown) {
- var toggle = dropdown.querySelector('.sort-dropdown-toggle');
- var menu = dropdown.querySelector('.sort-dropdown-menu');
- var chevron = dropdown.querySelector('.sort-dropdown-chevron');
-
- if (!toggle || !menu) return;
-
- // Skip if already initialized
- if (toggle.hasAttribute('data-sort-initialized')) return;
- toggle.setAttribute('data-sort-initialized', 'true');
-
- toggle.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- var isOpen = !menu.classList.contains('hidden');
-
- // Close all other dropdowns first
- document.querySelectorAll('.sort-dropdown-menu').forEach(function(m) {
- m.classList.add('hidden');
- });
- document.querySelectorAll('.sort-dropdown-chevron').forEach(function(c) {
- c.classList.remove('rotate-180');
- });
-
- if (!isOpen) {
- menu.classList.remove('hidden');
- if (chevron) chevron.classList.add('rotate-180');
- }
- });
- });
-
- // Close dropdowns when clicking outside (single global listener)
- if (!window._sortDropdownOutsideListenerAdded) {
- window._sortDropdownOutsideListenerAdded = true;
- document.addEventListener('click', function(e) {
- var isInsideDropdown = e.target.closest('.sort-dropdown');
- if (!isInsideDropdown) {
- document.querySelectorAll('.sort-dropdown-menu').forEach(function(m) {
- m.classList.add('hidden');
- });
- document.querySelectorAll('.sort-dropdown-chevron').forEach(function(c) {
- c.classList.remove('rotate-180');
- });
- }
- });
- }
-}
-
-// Initialize sort dropdowns on DOMContentLoaded
-document.addEventListener('DOMContentLoaded', initSortDropdowns);
diff --git a/resources/js/modules/tabs.js b/resources/js/modules/tabs.js
deleted file mode 100644
index 5afe73948..000000000
--- a/resources/js/modules/tabs.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * Tab switching functions extracted from csp-safe.js
- */
-
-// Tab switcher for profile and other pages
-export function initTabSwitcher() {
- document.querySelectorAll('[data-tab-trigger]').forEach(function(trigger) {
- trigger.addEventListener('click', function(e) {
- e.preventDefault();
- const tabId = this.getAttribute('data-tab-trigger');
-
- // Hide all tabs
- document.querySelectorAll('.tab-content').forEach(function(tab) {
- tab.style.display = 'none';
- });
-
- // Show selected tab
- const selectedTab = document.getElementById(tabId);
- if (selectedTab) {
- selectedTab.style.display = 'block';
- }
-
- // Update active state
- document.querySelectorAll('[data-tab-trigger]').forEach(function(t) {
- t.classList.remove('active', 'border-blue-500', 'text-blue-600');
- t.classList.add('border-transparent', 'text-gray-500');
- });
-
- this.classList.remove('border-transparent', 'text-gray-500');
- this.classList.add('active', 'border-blue-500', 'text-blue-600');
- });
- });
-}
-
-// Season switcher for series - optimized
-export function initSeasonSwitcher() {
- window.switchSeason = function(seasonNumber) {
- // Hide all season content
- document.querySelectorAll('.season-content').forEach(function(content) {
- content.classList.add('hidden');
- });
-
- // Remove active styling from all tabs
- document.querySelectorAll('.season-tab').forEach(function(tab) {
- tab.classList.remove('border-blue-500', 'text-blue-600');
- tab.classList.add('border-transparent', 'text-gray-500');
-
- // Update badge styling
- const badge = tab.querySelector('span');
- if (badge) {
- badge.classList.remove('bg-blue-100', 'text-blue-800');
- badge.classList.add('bg-gray-100', 'text-gray-600');
- }
- });
-
- // Show selected season content
- const selectedContent = document.querySelector('.season-content[data-season="' + seasonNumber + '"]');
- if (selectedContent) {
- selectedContent.classList.remove('hidden');
- }
-
- // Add active styling to selected tab
- const selectedTab = document.querySelector('.season-tab[data-season="' + seasonNumber + '"]');
- if (selectedTab) {
- selectedTab.classList.remove('border-transparent', 'text-gray-500');
- selectedTab.classList.add('border-blue-500', 'text-blue-600');
-
- // Update badge styling
- const badge = selectedTab.querySelector('span');
- if (badge) {
- badge.classList.remove('bg-gray-100', 'text-gray-600');
- badge.classList.add('bg-blue-100', 'text-blue-800');
- }
- }
- };
-}
-
-// Profile Page Tab Switching
-export function initProfileTabs() {
- const tabLinks = document.querySelectorAll('.tab-link');
- const tabContents = document.querySelectorAll('.tab-content');
-
- if (tabLinks.length === 0 || tabContents.length === 0) {
- return; // Not on a page with tabs
- }
-
- let chartsInitialized = false;
-
- tabLinks.forEach(link => {
- link.addEventListener('click', function(e) {
- e.preventDefault();
- const targetId = this.getAttribute('href').substring(1);
-
- // Update active states on tab links
- tabLinks.forEach(l => {
- l.classList.remove('bg-blue-50', 'text-blue-700', 'font-medium');
- l.classList.add('text-gray-700');
- l.classList.add('dark:text-gray-300');
- });
- this.classList.add('bg-blue-50', 'text-blue-700', 'font-medium');
- this.classList.remove('text-gray-700', 'dark:text-gray-300');
-
- // Hide all tab contents
- tabContents.forEach(content => {
- content.style.display = 'none';
- });
-
- // Show selected tab content
- const targetContent = document.getElementById(targetId);
- if (targetContent) {
- targetContent.style.display = 'block';
-
- // Initialize charts when API tab is shown for the first time
- if (targetId === 'api' && !chartsInitialized) {
- chartsInitialized = true;
- // Small delay to ensure the tab is fully visible before rendering charts
- setTimeout(() => {
- // Wait for Chart.js to be available
- let attempts = 0;
- const maxAttempts = 20; // Try for up to 2 seconds
- const checkChartJs = setInterval(() => {
- attempts++;
- if (typeof Chart !== 'undefined') {
- clearInterval(checkChartJs);
- initializeProfileCharts();
- } else if (attempts >= maxAttempts) {
- clearInterval(checkChartJs);
- console.error('Chart.js failed to load within timeout period');
- }
- }, 100);
- }, 50);
- }
- }
-
- // Update URL hash without scrolling
- history.pushState(null, null, '#' + targetId);
- });
- });
-
- // Handle initial hash
- const hash = window.location.hash.substring(1);
- if (hash && document.getElementById(hash)) {
- const link = document.querySelector(`a[href="#${hash}"]`);
- if (link) {
- link.click();
- }
- } else if (hash === '' || hash === 'general') {
- // If on general tab or no hash, ensure other tabs are hidden
- tabContents.forEach((content, index) => {
- if (index !== 0) {
- content.style.display = 'none';
- }
- });
- }
-}
-
-// Binary blacklist
-export function initBinaryBlacklist() {
- // Placeholder - actual implementation depends on existing ajax function
- window.ajax_binaryblacklist_delete = window.ajax_binaryblacklist_delete || function(id) {
- console.log('Delete blacklist:', id);
- };
-}
diff --git a/resources/js/modules/theme.js b/resources/js/modules/theme.js
deleted file mode 100644
index 387b4eaad..000000000
--- a/resources/js/modules/theme.js
+++ /dev/null
@@ -1,191 +0,0 @@
-// Shared theme management utilities
-export const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
-
-/**
- * Apply theme preference to the document
- * @param {string} themePreference - 'light', 'dark', or 'system'
- */
-export function applyTheme(themePreference) {
- const html = document.documentElement;
-
- if (themePreference === 'system') {
- if (themeMediaQuery.matches) {
- html.classList.add('dark');
- } else {
- html.classList.remove('dark');
- }
- } else if (themePreference === 'dark') {
- html.classList.add('dark');
- } else {
- html.classList.remove('dark');
- }
-}
-
-/**
- * Update all theme UI elements to reflect the current theme
- * @param {string} themePreference - 'light', 'dark', or 'system'
- */
-export function updateAllThemeUI(themePreference) {
- // Update ALL theme selector radio buttons (user area profile edit page)
- // Use a flag to prevent event loops when programmatically updating
- window._updatingThemeUI = true;
-
- const allThemeRadios = document.querySelectorAll('input[name="theme_preference"]');
- let updatedCount = 0;
- allThemeRadios.forEach(radio => {
- const wasChecked = radio.checked;
- if (radio.value === themePreference) {
- radio.checked = true;
- if (!wasChecked) updatedCount++;
- } else {
- radio.checked = false;
- }
- });
-
- if (updatedCount > 0) {
- console.log(`Updated ${updatedCount} theme radio button(s) to ${themePreference}`);
- }
-
- // Update theme toggle button title and icon
- const themeToggle = document.getElementById('theme-toggle');
- if (themeToggle) {
- const titles = {
- 'light': 'Theme: Light',
- 'dark': 'Theme: Dark',
- 'system': 'Theme: System (Auto)'
- };
- themeToggle.setAttribute('title', titles[themePreference] || 'Toggle theme');
-
- // Update icon if it exists
- const themeIcon = document.getElementById('theme-icon');
- if (themeIcon) {
- const icons = {
- 'light': 'fa-sun',
- 'dark': 'fa-moon',
- 'system': 'fa-desktop'
- };
- themeIcon.classList.remove('fa-sun', 'fa-moon', 'fa-desktop');
- themeIcon.classList.add(icons[themePreference] || 'fa-sun');
- }
-
- // Update label if it exists
- const themeLabel = document.getElementById('theme-label');
- if (themeLabel) {
- const labels = {
- 'light': 'Light',
- 'dark': 'Dark',
- 'system': 'System'
- };
- themeLabel.textContent = labels[themePreference] || 'Light';
- }
- }
-
- // Update visual state of theme options
- const themeOptions = document.querySelectorAll('.theme-option');
- themeOptions.forEach(option => {
- const radio = option.querySelector('.theme-radio') || option.querySelector('input[type="radio"]');
- if (radio && radio.checked) {
- option.classList.add('theme-option-active');
- } else {
- option.classList.remove('theme-option-active');
- }
- });
-
- // Update meta tag
- const metaTheme = document.querySelector('meta[name="theme-preference"]');
- if (metaTheme) {
- metaTheme.content = themePreference;
- }
-
- // Clear the flag after a short delay to allow all updates to complete
- setTimeout(() => {
- window._updatingThemeUI = false;
- }, 100);
-}
-
-/**
- * Save theme preference to backend or localStorage
- * @param {string} themePreference - 'light', 'dark', or 'system'
- * @returns {Promise} Promise that resolves when theme is saved
- */
-export function saveThemePreference(themePreference) {
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
- const updateThemeUrl = document.querySelector('meta[name="update-theme-url"]')?.content;
- const isAuthenticated = document.querySelector('meta[name="user-authenticated"]');
-
- if (isAuthenticated && isAuthenticated.content === 'true' && updateThemeUrl && csrfToken) {
- return fetch(updateThemeUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-TOKEN': csrfToken
- },
- body: JSON.stringify({ theme_preference: themePreference })
- }).then(response => response.json())
- .then(data => {
- if (data.success) {
- console.log('Theme saved successfully:', themePreference);
- // Update meta tag immediately
- const metaTheme = document.querySelector('meta[name="theme-preference"]');
- if (metaTheme) {
- metaTheme.content = themePreference;
- console.log('Meta tag updated to:', themePreference);
- }
- updateAllThemeUI(themePreference);
- // Dispatch custom event for theme change
- document.dispatchEvent(new CustomEvent('themeChanged', {
- detail: { theme: themePreference }
- }));
- return data;
- } else {
- console.error('Theme save failed:', data);
- }
- })
- .catch(error => {
- console.error('Error updating theme:', error);
- throw error;
- });
- } else {
- // Save to localStorage for guests
- localStorage.setItem('theme', themePreference);
- updateAllThemeUI(themePreference);
- document.dispatchEvent(new CustomEvent('themeChanged', {
- detail: { theme: themePreference }
- }));
- return Promise.resolve({ success: true });
- }
-}
-
-/**
- * Initialize theme system - runs once on page load
- */
-export function initThemeSystem() {
- // Get initial theme preference
- const metaTheme = document.querySelector('meta[name="theme-preference"]');
- const isAuthenticated = document.querySelector('meta[name="user-authenticated"]');
- let currentTheme = metaTheme ? metaTheme.content : 'light';
-
- if (!isAuthenticated || isAuthenticated.content !== 'true') {
- currentTheme = localStorage.getItem('theme') || 'light';
- }
-
- // Apply initial theme
- applyTheme(currentTheme);
- updateAllThemeUI(currentTheme);
-
- // Listen for OS theme changes if 'system' is selected
- themeMediaQuery.addEventListener('change', () => {
- const selectedTheme = metaTheme ? metaTheme.content : localStorage.getItem('theme') || 'light';
- if (selectedTheme === 'system') {
- applyTheme('system');
- }
- });
-
- // Listen for theme changes from any source
- document.addEventListener('themeChanged', function(e) {
- if (e.detail && e.detail.theme) {
- applyTheme(e.detail.theme);
- updateAllThemeUI(e.detail.theme);
- }
- });
-}
diff --git a/resources/js/modules/toast.js b/resources/js/modules/toast.js
deleted file mode 100644
index 5e342db75..000000000
--- a/resources/js/modules/toast.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import { escapeHtml } from './utils.js';
-
-// Toast Notifications
-export function initToastNotifications() {
- // showToast is defined globally for backward compatibility
- window.showToast = function(message, type) {
- type = type || 'success';
-
- let container = document.getElementById('toast-container');
- if (!container) {
- // Create container if it doesn't exist
- container = document.createElement('div');
- container.id = 'toast-container';
- container.className = 'fixed top-4 right-4 z-50';
- document.body.appendChild(container);
- }
-
- const toast = document.createElement('div');
- toast.className = 'toast-notification ' + type;
-
- const iconClass = type === 'success' ? 'fa-check-circle' :
- type === 'error' ? 'fa-exclamation-circle' :
- 'fa-info-circle';
-
- toast.innerHTML =
- ' ' +
- '' + escapeHtml(message) + ' ' +
- '× ';
-
- container.appendChild(toast);
-
- // Auto-remove after 5 seconds
- setTimeout(function() {
- toast.classList.add('removing');
- setTimeout(function() {
- toast.remove();
- }, 300);
- }, 5000);
-
- // Add close button listener
- const closeBtn = toast.querySelector('.toast-close');
- if (closeBtn) {
- closeBtn.addEventListener('click', function() {
- toast.classList.add('removing');
- setTimeout(function() {
- toast.remove();
- }, 300);
- });
- }
- };
-}
-
-// Flash Messages as Toast Notifications
-export function initFlashMessages() {
- const flashMessagesElement = document.getElementById('flash-messages-data');
- if (!flashMessagesElement) {
- return;
- }
-
- const flashMessages = JSON.parse(flashMessagesElement.dataset.messages || '{}');
-
- if (flashMessages.success && typeof showToast === 'function') {
- showToast(flashMessages.success, 'success');
- }
-
- if (flashMessages.error) {
- if (Array.isArray(flashMessages.error)) {
- flashMessages.error.forEach(error => {
- if (typeof showToast === 'function') {
- showToast(error, 'error');
- }
- });
- } else if (typeof showToast === 'function') {
- showToast(flashMessages.error, 'error');
- }
- }
-
- if (flashMessages.warning && typeof showToast === 'function') {
- showToast(flashMessages.warning, 'warning');
- }
-
- if (flashMessages.info && typeof showToast === 'function') {
- showToast(flashMessages.info, 'info');
- }
-}
diff --git a/resources/js/modules/utils.js b/resources/js/modules/utils.js
deleted file mode 100644
index 41c4b3dc5..000000000
--- a/resources/js/modules/utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Shared utility functions
- */
-
-/**
- * Escape HTML entities to prevent XSS
- * @param {string} text
- * @returns {string}
- */
-export function escapeHtml(text) {
- const map = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": '''
- };
- return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
-}
diff --git a/resources/views/admin/content/add.blade.php b/resources/views/admin/content/add.blade.php
index 02de2234a..8e2fa69a3 100644
--- a/resources/views/admin/content/add.blade.php
+++ b/resources/views/admin/content/add.blade.php
@@ -1,5 +1,35 @@
@extends('layouts.admin')
+@push('tinymce')
+
+
+@endpush
+
@section('content')
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php
index 215ed6017..b87f05f88 100644
--- a/resources/views/admin/dashboard.blade.php
+++ b/resources/views/admin/dashboard.blade.php
@@ -1,7 +1,7 @@
@extends('layouts.admin')
@section('content')
-
+
Admin Dashboard
@@ -469,7 +469,7 @@
Auto-refreshes every 20 minutes
-
+
@if(isset($recent_activity) && count($recent_activity) > 0)
@foreach($recent_activity as $activity)
diff --git a/resources/views/admin/groups/index.blade.php b/resources/views/admin/groups/index.blade.php
index 408710300..29f7a8391 100644
--- a/resources/views/admin/groups/index.blade.php
+++ b/resources/views/admin/groups/index.blade.php
@@ -1,7 +1,7 @@
@extends('layouts.admin')
@section('content')
-
+
@@ -83,17 +83,17 @@
Reset Selected
Reset All
Purge All
@@ -110,7 +110,8 @@
@@ -134,7 +135,8 @@
+ data-group-name="{{ $group->name }}"
+ @change="onGroupCheckboxChange()">
@@ -157,17 +159,13 @@
@if($group->active == 1)
id }}', '0')"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 hover:bg-green-200">
Active
@else
id }}', '1')"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-200">
Inactive
@@ -176,17 +174,13 @@
@if($group->backfill == 1)
id }}', '0')"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 hover:bg-blue-200">
Enabled
@else
id }}', '1')"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-200">
Disabled
@@ -228,22 +222,19 @@
id }}')"
class="text-yellow-600 dark:text-yellow-400 hover:text-yellow-900 dark:hover:text-yellow-300"
title="Reset this group">
id }}')"
class="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
title="Delete this group">
id }}')"
class="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
title="Purge this group">
@@ -278,11 +269,19 @@
@endif
-
-
-
-
+
+
+
Confirm Reset All Groups
@@ -293,12 +292,12 @@
Cancel
Reset All
@@ -308,8 +307,17 @@
-
-
+
+
Confirm Purge All Groups
@@ -320,12 +328,12 @@
Cancel
Purge All
@@ -335,25 +343,38 @@
-
-
+
+
Confirm Reset Selected Groups
- Are you sure you want to reset 0 selected group(s)?
+ Are you sure you want to reset 0 selected group(s)?
This will reset the article pointers for the selected groups back to their current state.
-
+
Cancel
Reset Selected
@@ -361,6 +382,7 @@
+
@endsection
diff --git a/resources/views/admin/release-reports/index.blade.php b/resources/views/admin/release-reports/index.blade.php
index c2c8fbb75..28557aa62 100644
--- a/resources/views/admin/release-reports/index.blade.php
+++ b/resources/views/admin/release-reports/index.blade.php
@@ -293,12 +293,14 @@
-
-
-
+
+
-
-
+
+
+
+
+
Report Details
@@ -328,18 +330,21 @@
Close
+
-
-
-
+
+
-
-
+
+
+
+
+
Confirm Revert
@@ -369,6 +374,7 @@
+
diff --git a/resources/views/admin/site/edit.blade.php b/resources/views/admin/site/edit.blade.php
index 4f7e077f3..3fb3e0357 100644
--- a/resources/views/admin/site/edit.blade.php
+++ b/resources/views/admin/site/edit.blade.php
@@ -1,5 +1,35 @@
@extends('layouts.admin')
+@push('tinymce')
+
+
+@endpush
+
@section('content')
diff --git a/resources/views/components/report-button.blade.php b/resources/views/components/report-button.blade.php
index 01f88ab67..3c207e505 100644
--- a/resources/views/components/report-button.blade.php
+++ b/resources/views/components/report-button.blade.php
@@ -11,7 +11,7 @@
@if($variant === 'icon')
@@ -41,12 +41,14 @@
-
-
-
+
+
-
-
+
+
+
+
+
Report Release
@@ -113,6 +115,7 @@
+
diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php
index 2d1ef3fe4..85e806411 100644
--- a/resources/views/layouts/admin.blade.php
+++ b/resources/views/layouts/admin.blade.php
@@ -15,6 +15,9 @@
+
+
+
@@ -108,20 +111,17 @@
-
-
-
-
-
@include('partials.confirmation-modal')
-
-
+
@include('partials.toast-notifications')
@stack('scripts')
+
+ @stack('tinymce')
+