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 + ? '' + : ''; + } + 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 + ? '' + : ''; + } + 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 += ''; + html += ''; + 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 += ''; + html += ''; + }); + + html += '
File NameSize
' + escapeHtml(name) + '' + size + '
'; + 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 - ? `` - : ``; - } - 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 - ? `` - : ``; - } - 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 += ` -
- - - - - - - - - `; - - 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 += ` - - - - - `; - }); - - html += ` - -
File NameSize
${escapeHtml(fileName)}${fileSize}
-
- `; - - 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 @@ @@ -110,7 +110,8 @@ @@ -134,7 +135,8 @@ + data-group-name="{{ $group->name }}" + @change="onGroupCheckboxChange()"> @@ -157,17 +159,13 @@ @if($group->active == 1) @else @@ -176,17 +174,13 @@ @if($group->backfill == 1) @else @@ -228,22 +222,19 @@
@endif
-
- -