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 += `
-
-
-
-
- | File Name |
- Size |
-
-
-
- `;
-
- data.files.forEach((file, index) => {
- const rowClass = index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900';
- const fileName = file.title || file.name || 'Unknown';
- const fileSize = file.size ? formatFileSize(file.size) : 'N/A';
-
- html += `
-
- | ${escapeHtml(fileName)} |
- ${fileSize} |
-
- `;
- });
-
- html += `
-
-
-
- `;
-
- html += '
';
- content.innerHTML = html;
- })
- .catch(error => {
- content.innerHTML = `
-
-
-
${escapeHtml(error.message)}
-
- `;
- });
- };
-
- window.showMediainfo = function(releaseId) {
- let modal = document.getElementById('mediainfoModal');
-
- if (modal && modal.parentElement !== document.body) {
- document.body.appendChild(modal);
- }
-
- const content = document.getElementById('mediainfoContent');
-
- modal.style.display = 'flex';
- modal.style.position = 'fixed';
- modal.style.top = '0';
- modal.style.left = '0';
- modal.style.right = '0';
- modal.style.bottom = '0';
- modal.style.zIndex = '99999';
- modal.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
-
- content.innerHTML = `
-
-
-
Loading media information...
-
- `;
-
- const apiUrl = '/api/release/' + releaseId + '/mediainfo';
-
- fetch(apiUrl)
- .then(response => {
- if (!response.ok) {
- throw new Error('Failed to load media information');
- }
- return response.json();
- })
- .then(data => {
- if (!data.video && !data.audio && !data.subs) {
- content.innerHTML = 'No media information available
';
- return;
- }
-
- let html = '';
-
- // Video information
- if (data.video) {
- html += `
-
-
- Video Information
-
-
- `;
-
- if (data.video.containerformat) {
- html += `
-
-
- Container
- - ${escapeHtml(data.video.containerformat)}
-
- `;
- }
- if (data.video.videocodec) {
- html += `
-
-
- Codec
- - ${escapeHtml(data.video.videocodec)}
-
- `;
- }
- if (data.video.videowidth && data.video.videoheight) {
- html += `
-
-
- Resolution
- - ${data.video.videowidth}x${data.video.videoheight}
-
- `;
- }
- if (data.video.videoaspect) {
- html += `
-
-
- Aspect Ratio
- - ${escapeHtml(data.video.videoaspect)}
-
- `;
- }
- if (data.video.videoframerate) {
- html += `
-
-
- Frame Rate
- - ${escapeHtml(data.video.videoframerate)} fps
-
- `;
- }
- if (data.video.videoduration) {
- const durationMs = parseInt(data.video.videoduration);
- if (!isNaN(durationMs) && durationMs > 0) {
- const minutes = Math.round(durationMs / 1000 / 60);
- html += `
-
-
- Duration
- - ${minutes} minutes
-
- `;
- }
- }
-
- html += '
';
- }
-
- // Audio information
- if (data.audio && data.audio.length > 0) {
- html += `
-
-
- Audio Information
-
- `;
-
- data.audio.forEach((audio, index) => {
- if (index > 0) html += '
';
- html += '
';
-
- if (audio.audioformat) {
- html += `
-
-
- Format
- - ${escapeHtml(audio.audioformat)}
-
- `;
- }
- if (audio.audiochannels) {
- html += `
-
-
- Channels
- - ${escapeHtml(audio.audiochannels)}
-
- `;
- }
- if (audio.audiobitrate) {
- html += `
-
-
- Bit Rate
- - ${escapeHtml(audio.audiobitrate)}
-
- `;
- }
- if (audio.audiolanguage) {
- html += `
-
-
- Language
- - ${escapeHtml(audio.audiolanguage)}
-
- `;
- }
- if (audio.audiosamplerate) {
- html += `
-
-
- Sample Rate
- - ${escapeHtml(audio.audiosamplerate)}
-
- `;
- }
-
- html += '
';
- });
-
- html += '
';
- }
-
- // Subtitle information
- if (data.subs) {
- html += `
-
-
- Subtitles
-
-
${escapeHtml(data.subs)}
-
- `;
- }
-
- html += '
';
- content.innerHTML = html;
- })
- .catch(error => {
- content.innerHTML = `
-
-
-
${escapeHtml(error.message)}
-
- `;
- });
- };
-
- // Event handlers for badges and modals
- document.addEventListener('click', function(e) {
- // Preview badge
- const previewBadge = e.target.closest('.preview-badge');
- if (previewBadge) {
- e.preventDefault();
- const guid = previewBadge.dataset.guid;
- if (typeof showPreviewImage === 'function') {
- showPreviewImage(guid, 'preview');
- }
- }
-
- // Sample badge
- const sampleBadge = e.target.closest('.sample-badge');
- if (sampleBadge) {
- e.preventDefault();
- const guid = sampleBadge.dataset.guid;
- if (typeof showPreviewImage === 'function') {
- showPreviewImage(guid, 'sample');
- }
- }
-
- // Mediainfo badge
- const mediainfoBadge = e.target.closest('.mediainfo-badge');
- if (mediainfoBadge) {
- e.preventDefault();
- const releaseId = mediainfoBadge.dataset.releaseId;
- showMediainfo(releaseId);
- }
-
- // File list badge
- const filelistBadge = e.target.closest('.filelist-badge');
- if (filelistBadge) {
- e.preventDefault();
- const guid = filelistBadge.dataset.guid;
- showFilelist(guid);
- }
-
- // Handle modal close buttons with data attributes
- if (e.target.hasAttribute('data-close-preview-modal') || e.target.closest('[data-close-preview-modal]')) {
- e.preventDefault();
- if (typeof closePreviewModal === 'function') {
- closePreviewModal();
- }
- }
-
- if (e.target.hasAttribute('data-close-mediainfo-modal') || e.target.closest('[data-close-mediainfo-modal]')) {
- e.preventDefault();
- if (typeof closeMediainfoModal === 'function') {
- closeMediainfoModal();
- }
- }
-
- if (e.target.hasAttribute('data-close-filelist-modal') || e.target.closest('[data-close-filelist-modal]')) {
- e.preventDefault();
- if (typeof closeFilelistModal === 'function') {
- closeFilelistModal();
- }
- }
- });
-
- // Close modals on background click
- const previewModal = document.getElementById('previewModal');
- if (previewModal) {
- previewModal.addEventListener('click', function(e) {
- if (e.target === this && typeof closePreviewModal === 'function') {
- closePreviewModal();
- }
- });
- }
-
- const mediainfoModal = document.getElementById('mediainfoModal');
- if (mediainfoModal) {
- mediainfoModal.addEventListener('click', function(e) {
- // Close if clicking the backdrop OR the close button
- if (e.target === this || e.target.closest('[data-close-mediainfo-modal]')) {
- closeMediainfoModal();
- }
- });
- }
-
- const filelistModal = document.getElementById('filelistModal');
- if (filelistModal) {
- filelistModal.addEventListener('click', function(e) {
- // Close if clicking the backdrop OR the close button
- if (e.target === this || e.target.closest('[data-close-filelist-modal]')) {
- closeFilelistModal();
- }
- });
- }
-
- // Close modals on ESC key
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape') {
- if (typeof closePreviewModal === 'function') closePreviewModal();
- closeMediainfoModal();
- closeFilelistModal();
- }
- });
-}
-
-// 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 += `
+
+
+
+
+ | File Name |
+ Size |
+
+
+
+ `;
+
+ data.files.forEach((file, index) => {
+ const rowClass = index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900';
+ const fileName = file.title || file.name || 'Unknown';
+ const fileSize = file.size ? formatFileSize(file.size) : 'N/A';
+
+ html += `
+
+ | ${escapeHtml(fileName)} |
+ ${fileSize} |
+
+ `;
+ });
+
+ html += `
+
+
+
+ `;
+
+ html += '
';
+ content.innerHTML = html;
+ })
+ .catch(error => {
+ content.innerHTML = `
+
+
+
${escapeHtml(error.message)}
+
+ `;
+ });
+ };
+
+ window.showMediainfo = function(releaseId) {
+ let modal = document.getElementById('mediainfoModal');
+
+ if (modal && modal.parentElement !== document.body) {
+ document.body.appendChild(modal);
+ }
+
+ const content = document.getElementById('mediainfoContent');
+
+ modal.style.display = 'flex';
+ modal.style.position = 'fixed';
+ modal.style.top = '0';
+ modal.style.left = '0';
+ modal.style.right = '0';
+ modal.style.bottom = '0';
+ modal.style.zIndex = '99999';
+ modal.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
+
+ content.innerHTML = `
+
+
+
Loading media information...
+
+ `;
+
+ const apiUrl = '/api/release/' + releaseId + '/mediainfo';
+
+ fetch(apiUrl)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to load media information');
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (!data.video && !data.audio && !data.subs) {
+ content.innerHTML = 'No media information available
';
+ return;
+ }
+
+ let html = '';
+
+ // Video information
+ if (data.video) {
+ html += `
+
+
+ Video Information
+
+
+ `;
+
+ if (data.video.containerformat) {
+ html += `
+
+
- Container
+ - ${escapeHtml(data.video.containerformat)}
+
+ `;
+ }
+ if (data.video.videocodec) {
+ html += `
+
+
- Codec
+ - ${escapeHtml(data.video.videocodec)}
+
+ `;
+ }
+ if (data.video.videowidth && data.video.videoheight) {
+ html += `
+
+
- Resolution
+ - ${data.video.videowidth}x${data.video.videoheight}
+
+ `;
+ }
+ if (data.video.videoaspect) {
+ html += `
+
+
- Aspect Ratio
+ - ${escapeHtml(data.video.videoaspect)}
+
+ `;
+ }
+ if (data.video.videoframerate) {
+ html += `
+
+
- Frame Rate
+ - ${escapeHtml(data.video.videoframerate)} fps
+
+ `;
+ }
+ if (data.video.videoduration) {
+ const durationMs = parseInt(data.video.videoduration);
+ if (!isNaN(durationMs) && durationMs > 0) {
+ const minutes = Math.round(durationMs / 1000 / 60);
+ html += `
+
+
- Duration
+ - ${minutes} minutes
+
+ `;
+ }
+ }
+
+ html += '
';
+ }
+
+ // Audio information
+ if (data.audio && data.audio.length > 0) {
+ html += `
+
+
+ Audio Information
+
+ `;
+
+ data.audio.forEach((audio, index) => {
+ if (index > 0) html += '
';
+ html += '
';
+
+ if (audio.audioformat) {
+ html += `
+
+
- Format
+ - ${escapeHtml(audio.audioformat)}
+
+ `;
+ }
+ if (audio.audiochannels) {
+ html += `
+
+
- Channels
+ - ${escapeHtml(audio.audiochannels)}
+
+ `;
+ }
+ if (audio.audiobitrate) {
+ html += `
+
+
- Bit Rate
+ - ${escapeHtml(audio.audiobitrate)}
+
+ `;
+ }
+ if (audio.audiolanguage) {
+ html += `
+
+
- Language
+ - ${escapeHtml(audio.audiolanguage)}
+
+ `;
+ }
+ if (audio.audiosamplerate) {
+ html += `
+
+
- Sample Rate
+ - ${escapeHtml(audio.audiosamplerate)}
+
+ `;
+ }
+
+ html += '
';
+ });
+
+ html += '
';
+ }
+
+ // Subtitle information
+ if (data.subs) {
+ html += `
+
+
+ Subtitles
+
+
${escapeHtml(data.subs)}
+
+ `;
+ }
+
+ html += '
';
+ content.innerHTML = html;
+ })
+ .catch(error => {
+ content.innerHTML = `
+
+
+
${escapeHtml(error.message)}
+
+ `;
+ });
+ };
+
+ // Event handlers for badges and modals
+ document.addEventListener('click', function(e) {
+ // Preview badge
+ const previewBadge = e.target.closest('.preview-badge');
+ if (previewBadge) {
+ e.preventDefault();
+ const guid = previewBadge.dataset.guid;
+ if (typeof showPreviewImage === 'function') {
+ showPreviewImage(guid, 'preview');
+ }
+ }
+
+ // Sample badge
+ const sampleBadge = e.target.closest('.sample-badge');
+ if (sampleBadge) {
+ e.preventDefault();
+ const guid = sampleBadge.dataset.guid;
+ if (typeof showPreviewImage === 'function') {
+ showPreviewImage(guid, 'sample');
+ }
+ }
+
+ // Mediainfo badge
+ const mediainfoBadge = e.target.closest('.mediainfo-badge');
+ if (mediainfoBadge) {
+ e.preventDefault();
+ const releaseId = mediainfoBadge.dataset.releaseId;
+ showMediainfo(releaseId);
+ }
+
+ // File list badge
+ const filelistBadge = e.target.closest('.filelist-badge');
+ if (filelistBadge) {
+ e.preventDefault();
+ const guid = filelistBadge.dataset.guid;
+ showFilelist(guid);
+ }
+
+ // Handle modal close buttons with data attributes
+ if (e.target.hasAttribute('data-close-preview-modal') || e.target.closest('[data-close-preview-modal]')) {
+ e.preventDefault();
+ if (typeof closePreviewModal === 'function') {
+ closePreviewModal();
+ }
+ }
+
+ if (e.target.hasAttribute('data-close-mediainfo-modal') || e.target.closest('[data-close-mediainfo-modal]')) {
+ e.preventDefault();
+ if (typeof closeMediainfoModal === 'function') {
+ closeMediainfoModal();
+ }
+ }
+
+ if (e.target.hasAttribute('data-close-filelist-modal') || e.target.closest('[data-close-filelist-modal]')) {
+ e.preventDefault();
+ if (typeof closeFilelistModal === 'function') {
+ closeFilelistModal();
+ }
+ }
+ });
+
+ // Close modals on background click
+ const previewModal = document.getElementById('previewModal');
+ if (previewModal) {
+ previewModal.addEventListener('click', function(e) {
+ if (e.target === this && typeof closePreviewModal === 'function') {
+ closePreviewModal();
+ }
+ });
+ }
+
+ const mediainfoModal = document.getElementById('mediainfoModal');
+ if (mediainfoModal) {
+ mediainfoModal.addEventListener('click', function(e) {
+ // Close if clicking the backdrop OR the close button
+ if (e.target === this || e.target.closest('[data-close-mediainfo-modal]')) {
+ closeMediainfoModal();
+ }
+ });
+ }
+
+ const filelistModal = document.getElementById('filelistModal');
+ if (filelistModal) {
+ filelistModal.addEventListener('click', function(e) {
+ // Close if clicking the backdrop OR the close button
+ if (e.target === this || e.target.closest('[data-close-filelist-modal]')) {
+ closeFilelistModal();
+ }
+ });
+ }
+
+ // Close modals on ESC key
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') {
+ if (typeof closePreviewModal === 'function') closePreviewModal();
+ closeMediainfoModal();
+ closeFilelistModal();
+ }
+ });
+}
+
+// Details page image modal
+export function initDetailsPageImageModal() {
+ // Handle image modal triggers on release details page
+ const imageModalTriggers = document.querySelectorAll('.image-modal-trigger');
+ const imageModal = document.getElementById('imageModal');
+ const imageModalImage = document.getElementById('imageModalImage');
+ const imageModalTitle = document.getElementById('imageModalTitle');
+ const closeButtons = document.querySelectorAll('[data-close-image-modal]');
+
+ if (!imageModal || !imageModalImage || !imageModalTitle) {
+ return; // Not on the details page
+ }
+
+ // Add click handlers to image triggers
+ imageModalTriggers.forEach(function(trigger) {
+ trigger.addEventListener('click', function(e) {
+ e.preventDefault();
+ const imageUrl = this.getAttribute('data-image-url');
+ const imageTitle = this.getAttribute('data-image-title') || 'Image Preview';
+
+ // Use the image URL as-is (keeping _thumb suffix)
+ imageModalImage.src = imageUrl;
+ imageModalTitle.textContent = imageTitle;
+ imageModal.classList.remove('hidden');
+ imageModal.style.display = 'flex';
+ });
+ });
+
+ // Add click handler to close button
+ closeButtons.forEach(function(closeBtn) {
+ closeBtn.addEventListener('click', function(e) {
+ e.preventDefault();
+ imageModal.classList.add('hidden');
+ imageModal.style.display = 'none';
+ });
+ });
+
+ // Close modal when clicking on backdrop
+ imageModal.addEventListener('click', function(e) {
+ if (e.target === imageModal) {
+ imageModal.classList.add('hidden');
+ imageModal.style.display = 'none';
+ }
+ });
+
+ // Close modal with Escape key
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && !imageModal.classList.contains('hidden')) {
+ imageModal.classList.add('hidden');
+ imageModal.style.display = 'none';
+ }
+ });
+}
+
+// Modal styles initialization
+export function initModalStyles() {
+ // Preview modal
+ const previewModal = document.getElementById('previewModal');
+ if (previewModal) {
+ previewModal.style.display = 'none';
+ previewModal.style.zIndex = '9999';
+ }
+
+ // NFO modal
+ const nfoModal = document.getElementById('nfoModal');
+ if (nfoModal) {
+ nfoModal.style.display = 'none';
+ }
+
+ // Image modal
+ const imageModal = document.getElementById('imageModal');
+ if (imageModal) {
+ imageModal.style.display = 'none';
+ }
+
+ // MediaInfo modal
+ const mediainfoModal = document.getElementById('mediainfoModal');
+ if (mediainfoModal) {
+ mediainfoModal.style.display = 'none';
+ mediainfoModal.style.zIndex = '9999';
+ }
+
+ // Filelist modal
+ const filelistModal = document.getElementById('filelistModal');
+ if (filelistModal) {
+ filelistModal.style.display = 'none';
+ filelistModal.style.zIndex = '9999';
+ }
+
+ // MediaInfo content scroll
+ const mediainfoContent = document.getElementById('mediainfoContent');
+ if (mediainfoContent) {
+ mediainfoContent.style.maxHeight = 'calc(90vh - 80px)';
+ }
+
+ // Filelist content scroll
+ const filelistContent = document.getElementById('filelistContent');
+ if (filelistContent) {
+ filelistContent.style.maxHeight = 'calc(90vh - 80px)';
+ }
+}
diff --git a/resources/js/modules/navigation.js b/resources/js/modules/navigation.js
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 @@
-
+
diff --git a/resources/views/admin/comments/index.blade.php b/resources/views/admin/comments/index.blade.php
index 73315ef2f..cd2a22ba3 100644
--- a/resources/views/admin/comments/index.blade.php
+++ b/resources/views/admin/comments/index.blade.php
@@ -27,7 +27,7 @@
@endif
-