diff --git a/package.json b/package.json index 390cbdde5..9f87635eb 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,11 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.8", "@tailwindcss/vite": "^4.0.0", - "autoprefixer": "^10.4.20", "axios": "^1.13.2", "lodash": "^4.17.21", "npm-check-updates": "^17.1.1", - "postcss": "^8.4.44", "prettier": "^3.3.3", - "tailwindcss": "^3.4.18", + "tailwindcss": "^4.0.0", "vite": "^7.0", "vue": "^3.5.24" }, diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 49c0612d5..000000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/resources/css/app.css b/resources/css/app.css index 41887b1f2..c7a1ae905 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,14 +1,24 @@ +/* Tailwind CSS v4 */ +@import "tailwindcss"; + +/* Tailwind v4 plugins */ +@plugin "@tailwindcss/forms"; + +/* Dark mode: class-based strategy (replaces darkMode: 'class' in tailwind.config.js) */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Theme customization (replaces theme.extend in tailwind.config.js) */ +@theme { + --font-sans: "Figtree", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --radius-card: 0.75rem; +} + /* Import FontAwesome */ @import '@fortawesome/fontawesome-free/css/all.min.css'; - /* Import custom CSP-safe styles */ @import './csp-safe.css'; -@tailwind base; -@tailwind components; -@tailwind utilities; - /* Fixed layout - ensure html and body have proper height constraints */ html, body { height: 100%; @@ -68,9 +78,14 @@ html, body { } } +/* Page-level card container (rounded-xl, shadow) */ +.card { + @apply bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 transition-colors duration-200; +} + /* Custom styles for proper spacing and button alignment */ .btn { - @apply inline-flex items-center justify-center px-4 py-2 border rounded-md font-semibold text-sm transition-colors duration-150; + @apply inline-flex items-center justify-center px-4 py-2 border rounded-lg font-semibold text-sm transition-colors duration-150; } .btn i { @@ -127,237 +142,293 @@ html, body { } .table-actions .btn { - @apply flex-shrink-0; + @apply shrink-0; } /* Mobile Responsive Improvements */ -@layer utilities { - /* Mobile-friendly tables */ - @media (max-width: 768px) { - .table-responsive { - @apply overflow-x-auto -mx-4 px-4; - } - - .table-responsive table { - @apply min-w-max; - } - - /* Card-style table rows for mobile */ - .table-mobile-cards tbody tr { - @apply block border border-gray-200 dark:border-gray-700 rounded-lg mb-4 p-4; - } - - .table-mobile-cards tbody td { - @apply block text-left; - padding-left: 50% !important; - position: relative; - } - - .table-mobile-cards tbody td::before { - content: attr(data-label); - position: absolute; - left: 1rem; - font-weight: 600; - @apply text-gray-700 dark:text-gray-300; - } - - .table-mobile-cards thead { - @apply hidden; - } - - /* Stack buttons vertically on mobile */ - .btn-group { - @apply flex-col space-x-0 space-y-2; - } - - /* Full width buttons on mobile */ - .btn-mobile-full { - @apply w-full justify-center; - } - - /* Mobile search forms */ - .search-form-mobile { - @apply flex-col space-y-2; - } - - .search-form-mobile select, - .search-form-mobile input { - @apply w-full rounded; - } - - /* Reduce padding on small screens */ - .container { - @apply px-2; - } - - /* Stack navigation items */ - .nav-mobile-stack { - @apply flex-col space-x-0 space-y-2; - } - - /* Hide non-essential columns on mobile */ - .hide-mobile { - @apply hidden; - } - - /* Smaller text on mobile */ - .text-mobile-sm { - @apply text-xs; - } - - /* Mobile dropdown menus should be full width */ - .dropdown-menu { - @apply left-0 right-0 w-auto mx-2; - } - - /* Mobile modal adjustments */ - .modal-content { - @apply mx-2 max-h-[90vh] overflow-y-auto; - } - - /* Smaller font sizes for headings on mobile */ - h1 { - @apply text-2xl; - } - - h2 { - @apply text-xl; - } - - h3 { - @apply text-lg; - } - - /* Mobile grid adjustments */ - .grid-mobile-1 { - @apply grid-cols-1; - } - - /* Touch-friendly sizing */ - .touch-target { - min-height: 44px; - min-width: 44px; - } +/* Mobile-friendly tables */ +@media (max-width: 768px) { + .table-responsive { + overflow-x: auto; + margin-left: -1rem; + margin-right: -1rem; + padding-left: 1rem; + padding-right: 1rem; } - /* Small mobile devices */ - @media (max-width: 480px) { - /* Even smaller text on very small screens */ - body { - @apply text-sm; - } - - .btn { - @apply text-xs px-3 py-2; - } - - /* Stack form fields */ - .form-grid { - @apply grid-cols-1 gap-2; - } - - /* Compact headers */ - .page-header { - @apply text-xl py-2; - } - - /* Reduce card padding */ - .card-mobile-compact { - @apply p-3; - } + .table-responsive table { + min-width: max-content; } - /* Tablet improvements */ - @media (min-width: 768px) and (max-width: 1024px) { - /* 2-column layout on tablets where appropriate */ - .tablet-cols-2 { - @apply grid-cols-2; - } - - /* Tablet-optimized navigation */ - .nav-tablet { - @apply space-x-2; - } + /* Card-style table rows for mobile */ + .table-mobile-cards tbody tr { + display: block; + border: 1px solid rgb(229 231 235); + border-radius: 0.5rem; + margin-bottom: 1rem; + padding: 1rem; } - /* Landscape mobile optimization */ - @media (max-height: 600px) and (orientation: landscape) { - /* Reduce vertical padding in landscape */ - .py-6 { - @apply py-2; - } + .dark .table-mobile-cards tbody tr { + border-color: rgb(55 65 81); + } - /* Compact modals in landscape */ - .modal-content { - max-height: 85vh; - } + .table-mobile-cards tbody td { + display: block; + text-align: left; + padding-left: 50% !important; + position: relative; + } - /* Smaller fixed buttons */ - #mobile-sidebar-toggle, - #theme-toggle { - @apply p-2 text-sm; - } + .table-mobile-cards tbody td::before { + content: attr(data-label); + position: absolute; + left: 1rem; + font-weight: 600; + color: rgb(55 65 81); + } + + .dark .table-mobile-cards tbody td::before { + color: rgb(209 213 219); + } + + .table-mobile-cards thead { + display: none; + } + + /* Stack buttons vertically on mobile */ + .btn-group { + display: flex; + flex-direction: column; + row-gap: 0.5rem; + column-gap: 0; + } + + /* Full width buttons on mobile */ + .btn-mobile-full { + width: 100%; + justify-content: center; + } + + /* Mobile search forms */ + .search-form-mobile { + flex-direction: column; + row-gap: 0.5rem; + } + + .search-form-mobile select, + .search-form-mobile input { + width: 100%; + border-radius: 0.25rem; + } + + /* Reduce padding on small screens */ + .container { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + /* Stack navigation items */ + .nav-mobile-stack { + flex-direction: column; + row-gap: 0.5rem; + column-gap: 0; + } + + /* Hide non-essential columns on mobile */ + .hide-mobile { + display: none; + } + + /* Smaller text on mobile */ + .text-mobile-sm { + font-size: 0.75rem; + line-height: 1rem; + } + + /* Mobile dropdown menus should be full width */ + .dropdown-menu { + left: 0; + right: 0; + width: auto; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + + /* Mobile modal adjustments */ + .modal-content { + margin-left: 0.5rem; + margin-right: 0.5rem; + max-height: 90vh; + overflow-y: auto; + } + + /* Smaller font sizes for headings on mobile */ + h1 { + font-size: 1.5rem; + line-height: 2rem; + } + + h2 { + font-size: 1.25rem; + line-height: 1.75rem; + } + + h3 { + font-size: 1.125rem; + line-height: 1.75rem; + } + + /* Mobile grid adjustments */ + .grid-mobile-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + /* Touch-friendly sizing */ + .touch-target { + min-height: 44px; + min-width: 44px; } /* Mobile-optimized badges and labels */ - @media (max-width: 768px) { - .badge { - @apply text-xs px-2 py-1; - } + .badge { + font-size: 0.75rem; + line-height: 1rem; + padding: 0.25rem 0.5rem; + } - /* Improve readability of release titles */ - .release-title { - @apply text-sm leading-tight; - } + /* Improve readability of release titles */ + .release-title { + font-size: 0.875rem; + line-height: 1.25rem; + } - /* Stack action buttons in cards */ - .card-actions { - @apply flex-col space-y-2 space-x-0; - } + /* Stack action buttons in cards */ + .card-actions { + flex-direction: column; + row-gap: 0.5rem; + column-gap: 0; + } - /* Make pagination more compact */ - .pagination { - @apply text-sm; - } + /* Make pagination more compact */ + .pagination { + font-size: 0.875rem; + line-height: 1.25rem; + } - .pagination .page-link { - @apply px-2 py-1 min-w-[2rem]; - } + .pagination .page-link { + padding: 0.25rem 0.5rem; + min-width: 2rem; + } - /* Optimize data tables for mobile */ - table.dataTable { - font-size: 0.875rem; - } + /* Optimize data tables for mobile */ + table.dataTable { + font-size: 0.875rem; + } - table.dataTable td, - table.dataTable th { - padding: 0.5rem 0.25rem; - } + table.dataTable td, + table.dataTable th { + padding: 0.5rem 0.25rem; + } - /* Improve form labels */ - label { - @apply text-sm font-medium; - } + /* Improve form labels */ + label { + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + } - /* Better spacing for form groups */ - .form-group { - @apply mb-3; - } + /* Better spacing for form groups */ + .form-group { + margin-bottom: 0.75rem; + } - /* Mobile-friendly alerts */ - .alert { - @apply text-sm px-3 py-2; - } + /* Mobile-friendly alerts */ + .alert { + font-size: 0.875rem; + line-height: 1.25rem; + padding: 0.5rem 0.75rem; + } - /* Compact breadcrumbs */ - .breadcrumb { - @apply text-xs; - } + /* Compact breadcrumbs */ + .breadcrumb { + font-size: 0.75rem; + line-height: 1rem; + } - /* Stack filter options vertically */ - .filter-options { - @apply flex-col space-y-2; - } + /* Stack filter options vertically */ + .filter-options { + flex-direction: column; + row-gap: 0.5rem; + } +} + +/* Small mobile devices */ +@media (max-width: 480px) { + /* Even smaller text on very small screens */ + body { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .btn { + font-size: 0.75rem; + line-height: 1rem; + padding: 0.5rem 0.75rem; + } + + /* Stack form fields */ + .form-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 0.5rem; + } + + /* Compact headers */ + .page-header { + font-size: 1.25rem; + line-height: 1.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + /* Reduce card padding */ + .card-mobile-compact { + padding: 0.75rem; + } +} + +/* Tablet improvements */ +@media (min-width: 768px) and (max-width: 1024px) { + /* 2-column layout on tablets where appropriate */ + .tablet-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + /* Tablet-optimized navigation */ + .nav-tablet { + column-gap: 0.5rem; + } +} + +/* Landscape mobile optimization */ +@media (max-height: 600px) and (orientation: landscape) { + /* Reduce vertical padding in landscape */ + .py-6 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + /* Compact modals in landscape */ + .modal-content { + max-height: 85vh; + } + + /* Smaller fixed buttons */ + #mobile-sidebar-toggle, + #theme-toggle { + padding: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; } } diff --git a/resources/forum/blade-tailwind/css/forum.css b/resources/forum/blade-tailwind/css/forum.css index 9f20a3370..dbfb5331e 100644 --- a/resources/forum/blade-tailwind/css/forum.css +++ b/resources/forum/blade-tailwind/css/forum.css @@ -1,8 +1,8 @@ @import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap'); -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); @layer base { .forum { @@ -42,14 +42,15 @@ } .dark ::-webkit-scrollbar-track { - @apply bg-gray-800; + background-color: rgb(31 41 55); /* gray-800 */ } .dark ::-webkit-scrollbar-thumb { - @apply bg-gray-600 rounded; + background-color: rgb(75 85 99); /* gray-600 */ + border-radius: 0.25rem; } .dark ::-webkit-scrollbar-thumb:hover { - @apply bg-gray-500; + background-color: rgb(107 114 128); /* gray-500 */ } } diff --git a/resources/forum/livewire-tailwind/css/forum.css b/resources/forum/livewire-tailwind/css/forum.css index f1f6bcc75..b8ee949c6 100644 --- a/resources/forum/livewire-tailwind/css/forum.css +++ b/resources/forum/livewire-tailwind/css/forum.css @@ -1,8 +1,8 @@ @import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,700;1,400&display=swap'); -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); @layer base { .forum { @@ -65,7 +65,7 @@ @media (prefers-color-scheme: dark) { .forum { - @apply text-slate-400; + color: rgb(148 163 184); /* slate-400 */ } .bg-category { @@ -77,7 +77,11 @@ } a:not(.link-button):not(.group-button):not(.text-category) { - @apply text-slate-300 hover:text-white; + color: rgb(203 213 225); /* slate-300 */ + } + + a:not(.link-button):not(.group-button):not(.text-category):hover { + color: rgb(255 255 255); /* white */ } } } diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 9db2785b1..b3a6a898b 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -1,5072 +1,103 @@ -// CSP-Safe JavaScript - All inline event handlers moved here -// This file handles all onclick, onchange, and other inline event handlers +// CSP-Safe JavaScript - Modular Orchestrator +// All functionality has been extracted into ES modules under ./modules/ -// Utility function to escape HTML -function escapeHtml(text) { - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return String(text).replace(/[&<>"']/g, function(m) { return map[m]; }); -} +// 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'; -// Shared theme management utilities -const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); +// 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'; -/** - * Apply theme preference to the document - * @param {string} themePreference - 'light', 'dark', or 'system' - */ -function applyTheme(themePreference) { - const html = document.documentElement; +// 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'; - 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'); - } -} +// 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'; -/** - * Update all theme UI elements to reflect the current theme - * @param {string} themePreference - 'light', 'dark', or 'system' - */ -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 - */ -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 - */ -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); - } - }); -} - -/** - * Toggle password visibility for password input fields - * @param {string} fieldId - The ID of the password field to 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 - */ -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); - } - }); - }); -} +// 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(); - initSelectRedirects(); - initLogoutForms(); - initSeasonSwitcher(); - initBinaryBlacklist(); initImageModal(); - initTabSwitcher(); initPreviewModal(); - initRegexManagement(); initMediainfoAndFilelist(); - initCartFunctionality(); + + // Navigation initAdminMenu(); initSidebarToggle(); initDropdownMenus(); - initImageFallbacks(); - initProfileTabs(); - initAdminUserEdit(); 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(); - initContentToggle(); - initContentDelete(); - initPasswordVisibilityToggles(); - initSearchAutocomplete(); - // Initialize page-specific functionality from inline scripts + // Page-specific functionality initMyMovies(); initAuthPages(); initProfileEdit(); initDetailsPageImageModal(); initMoviesLayoutToggle(); - initProfilePage(); // Initialize profile page (progress bars and charts) - initCopyToClipboard(); // Copy functionality for RSS/Profile pages - initVerifyUserModal(); // Admin user verification modal - initQualityFilter(); // Quality filter for movie releases - initUserListScrollSync(); // Synchronized scrollbars for user list table + initProfilePage(); + initCopyToClipboard(); + initVerifyUserModal(); + initQualityFilter(); + initUserListScrollSync(); }); -// Event delegation for dynamically added elements - optimized with early returns -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; - } - } - }); -} - -// Toast Notifications -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); - }); - } - }; -} - -// 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 -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 -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; - }; -} - -// Select redirects -function initSelectRedirects() { - // Already handled in event delegation -} - -// Logout forms -function initLogoutForms() { - // Already handled in event delegation -} - -// Season switcher for series - optimized -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'); - } - } - }; -} - -// Binary blacklist -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); - }; -} - -// Image modal -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'); - } - }; -} - -// Tab switcher for profile and other pages -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'); - }); - }); -} - -// Preview Modal for Browse Page -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(); - } - }); -} - -// Regex Management Functions -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'); - } - }); - }; -} - -// Mediainfo and Filelist Modals -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(); - } - }); -} - -// Cart and Multi-select functionality -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); - } - }); - } - }); - }); - } -} - -// Admin Menu Toggle -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 -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 -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 - const mobileMenuToggle = document.getElementById('mobile-menu-toggle'); - if (mobileMenuToggle) { - mobileMenuToggle.addEventListener('click', function() { - const desktopNav = document.querySelector('.md\\:flex.md\\:items-center'); - if (desktopNav) { - desktopNav.classList.toggle('hidden'); - desktopNav.classList.toggle('flex'); - } - }); - } - - // 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'); - }); - } -} - -// Image Fallbacks -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; - } - }); - }); -} - -// Profile Page Tab Switching -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'; - } - }); - } -} - -// 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'); - } - }); -}; - -// Admin User Edit - Role Expiry DateTime Picker -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); -} - -// My Movies page functionality -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)); - } -} - -// Auth pages functionality -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(); - } -} - -// Profile edit page functionality -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); - }); - }); -} - -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'; - } - }); -} - -// Movies page layout toggle functionality -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'); - } - }); - } - } -} - -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'); - } - }); - } -} - -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 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(); - } - }); -} - -// TinyMCE Initialization for Admin Content Pages -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(); - } - } - }); -}; - -// Recent Activity Auto-Refresh (20 minutes) -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(); - } - }); -} - -// Profile page functionality -let downloadsChart = null; -let apiRequestsChart = null; - -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); - } - }); -} - -// Theme Management (moved from main.blade.php) -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 -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); - } - } - }); -} - -// Verify User Modal functionality -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(); - } - } - }); -} - -// Flash Messages as Toast Notifications -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'); - } -} - -// Initialize on DOMContentLoaded +// Theme management and flash messages (run immediately or on DOMContentLoaded) (function() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { @@ -5078,993 +109,3 @@ function initFlashMessages() { initFlashMessages(); } })(); - -// 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(); - } -} - -// Initialize quality filter for movie releases (resolution + source) -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(); - }); - }); -} - -// Admin Content List - Toggle Enable/Disable -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 -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'; - }); - } - }); - }); -} - -// Initialize synchronized horizontal scrollbars for user list table -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; - } - }); -} - -/** - * Initialize search autocomplete functionality - * Handles both the main search page and header search inputs - */ -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(); -} - -/** - * Apply CSP-safe styles to modals that previously had inline styles - */ -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)'; - } -} - -/** - * 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 - */ -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'); - } - }); - } -} - -// ============================================================================ -// Sort Dropdown Component -// ============================================================================ - -/** - * Initialize sort dropdown functionality - * Handles the custom dropdown for sorting releases on browse/search pages - */ -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); - -// ============================================================================= -// RELEASE REPORT FUNCTIONALITY -// ============================================================================= - -/** - * Initialize release report buttons and modals - * CSP-safe implementation for reporting bad releases - */ -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); - -// ============================================================================= -// ADMIN RELEASE REPORTS PAGE FUNCTIONALITY -// ============================================================================= - -/** - * Initialize admin release reports page functionality - * Handles description modal, revert confirmation modal, select all, and bulk actions - */ -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/admin/dashboard.js b/resources/js/modules/admin/dashboard.js new file mode 100644 index 000000000..ccd128e5c --- /dev/null +++ b/resources/js/modules/admin/dashboard.js @@ -0,0 +1,483 @@ +/** + * 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 new file mode 100644 index 000000000..6a565129b --- /dev/null +++ b/resources/js/modules/admin/features.js @@ -0,0 +1,1233 @@ +/** + * 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 new file mode 100644 index 000000000..129145ba7 --- /dev/null +++ b/resources/js/modules/admin/groups.js @@ -0,0 +1,497 @@ +/** + * 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 new file mode 100644 index 000000000..dee15be19 --- /dev/null +++ b/resources/js/modules/auth.js @@ -0,0 +1,39 @@ +/** + * 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 new file mode 100644 index 000000000..a7389b563 --- /dev/null +++ b/resources/js/modules/cart.js @@ -0,0 +1,475 @@ +/** + * 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 new file mode 100644 index 000000000..c23d796ab --- /dev/null +++ b/resources/js/modules/content.js @@ -0,0 +1,433 @@ +/** + * 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 new file mode 100644 index 000000000..c6cd3c0f8 --- /dev/null +++ b/resources/js/modules/event-delegation.js @@ -0,0 +1,315 @@ +// 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 new file mode 100644 index 000000000..d2f41df50 --- /dev/null +++ b/resources/js/modules/forms.js @@ -0,0 +1,223 @@ +/** + * 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 new file mode 100644 index 000000000..bebf61080 --- /dev/null +++ b/resources/js/modules/modals.js @@ -0,0 +1,874 @@ +/** + * 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 new file mode 100644 index 000000000..55df9c859 --- /dev/null +++ b/resources/js/modules/navigation.js @@ -0,0 +1,274 @@ +/** + * 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 new file mode 100644 index 000000000..adc5882a0 --- /dev/null +++ b/resources/js/modules/profile.js @@ -0,0 +1,245 @@ +/** + * 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 new file mode 100644 index 000000000..ab6576016 --- /dev/null +++ b/resources/js/modules/releases.js @@ -0,0 +1,334 @@ +/** + * 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 new file mode 100644 index 000000000..5d9d65afc --- /dev/null +++ b/resources/js/modules/search.js @@ -0,0 +1,220 @@ +/** + * 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 new file mode 100644 index 000000000..5afe73948 --- /dev/null +++ b/resources/js/modules/tabs.js @@ -0,0 +1,163 @@ +/** + * 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 new file mode 100644 index 000000000..387b4eaad --- /dev/null +++ b/resources/js/modules/theme.js @@ -0,0 +1,191 @@ +// 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 new file mode 100644 index 000000000..5e342db75 --- /dev/null +++ b/resources/js/modules/toast.js @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..41c4b3dc5 --- /dev/null +++ b/resources/js/modules/utils.js @@ -0,0 +1,19 @@ +/** + * 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/anidb/edit.blade.php b/resources/views/admin/anidb/edit.blade.php index d63f6da65..c05256b2a 100644 --- a/resources/views/admin/anidb/edit.blade.php +++ b/resources/views/admin/anidb/edit.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/anidb/index.blade.php b/resources/views/admin/anidb/index.blade.php index ea8cf9ffe..2b9d06146 100644 --- a/resources/views/admin/anidb/index.blade.php +++ b/resources/views/admin/anidb/index.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/anidb/remove.blade.php b/resources/views/admin/anidb/remove.blade.php index 8ea46dcb0..aafac6033 100644 --- a/resources/views/admin/anidb/remove.blade.php +++ b/resources/views/admin/anidb/remove.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/blacklist/edit.blade.php b/resources/views/admin/blacklist/edit.blade.php index b73fdba7c..424fa0aeb 100644 --- a/resources/views/admin/blacklist/edit.blade.php +++ b/resources/views/admin/blacklist/edit.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/blacklist/index.blade.php b/resources/views/admin/blacklist/index.blade.php index dc39b11f2..164acb0b0 100644 --- a/resources/views/admin/blacklist/index.blade.php +++ b/resources/views/admin/blacklist/index.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/books/edit.blade.php b/resources/views/admin/books/edit.blade.php index 86a9b30cc..5f6e6648d 100644 --- a/resources/views/admin/books/edit.blade.php +++ b/resources/views/admin/books/edit.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/books/index.blade.php b/resources/views/admin/books/index.blade.php index 0385b1c0d..dfc308e48 100644 --- a/resources/views/admin/books/index.blade.php +++ b/resources/views/admin/books/index.blade.php @@ -2,7 +2,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/categories/edit.blade.php b/resources/views/admin/categories/edit.blade.php index b62b76406..5b3b8bded 100644 --- a/resources/views/admin/categories/edit.blade.php +++ b/resources/views/admin/categories/edit.blade.php @@ -4,7 +4,7 @@ @section('content')
-
+
diff --git a/resources/views/admin/categories/index.blade.php b/resources/views/admin/categories/index.blade.php index 79a0de704..14b2dbabf 100644 --- a/resources/views/admin/categories/index.blade.php +++ b/resources/views/admin/categories/index.blade.php @@ -4,7 +4,7 @@ @section('content')
-
+
@@ -156,7 +156,7 @@
-