diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css index 3b6376e..2c92a38 100644 --- a/assets/css/dashboard.css +++ b/assets/css/dashboard.css @@ -160,6 +160,215 @@ body::after { 100% { content: '10101010'; opacity: 0.1; } } +/* ===== ENHANCED TERMINAL ANIMATIONS ===== */ + +/* Typing cursor effect for focused inputs */ +input:focus::placeholder, +textarea:focus::placeholder { + animation: typing-cursor 1s steps(1) infinite; +} + +@keyframes typing-cursor { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* Glowing border pulse on focus */ +input:focus, +textarea:focus, +select:focus { + animation: focus-pulse 2s ease-in-out infinite; +} + +@keyframes focus-pulse { + 0%, 100% { box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5); } + 50% { box-shadow: var(--glow-amber-intense), inset 0 0 10px rgba(0, 0, 0, 0.5); } +} + +/* Boot-up fade in effect */ +@keyframes boot-up { + 0% { + opacity: 0; + filter: brightness(2) saturate(0); + } + 30% { + opacity: 1; + filter: brightness(1.5) saturate(0.5); + } + 100% { + opacity: 1; + filter: brightness(1) saturate(1); + } +} + +.ascii-frame-outer { + animation: boot-up 0.8s ease-out; +} + +/* Phosphor text glow on important elements */ +.stat-value, +.ticket-id, +h1, h2, h3 { + text-shadow: 0 0 2px currentColor, 0 0 4px currentColor; +} + +/* Enhanced link hover with underline animation */ +a:not(.btn) { + position: relative; + text-decoration: none; +} + +a:not(.btn)::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: currentColor; + box-shadow: 0 0 5px currentColor; + transition: width 0.3s ease; +} + +a:not(.btn):hover::after { + width: 100%; +} + +/* Matrix-style rain effect on hover for stats */ +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(180deg, + transparent 0%, + rgba(0, 255, 65, 0.03) 50%, + transparent 100% + ); + background-size: 100% 200%; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; +} + +.stat-card:hover::before { + opacity: 1; + animation: matrix-rain 2s linear infinite; +} + +@keyframes matrix-rain { + 0% { background-position: 0 -100%; } + 100% { background-position: 0 100%; } +} + +/* Smooth table row selection animation */ +tbody tr { + transition: all 0.2s ease; + position: relative; +} + +tbody tr::before { + content: '>'; + position: absolute; + left: -20px; + opacity: 0; + color: var(--terminal-green); + transition: all 0.2s ease; +} + +tbody tr:hover::before { + opacity: 1; + left: -15px; +} + +/* Button press effect */ +.btn { + transition: all 0.15s ease; + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: width 0.4s, height 0.4s; +} + +.btn:active::before { + width: 200%; + height: 200%; +} + +.btn:active { + transform: scale(0.98); +} + +/* Terminal cursor blink for active/selected elements */ +.keyboard-selected td:first-child::before { + content: '█'; + position: absolute; + left: -25px; + animation: cursor-blink 0.8s steps(1) infinite; + color: var(--terminal-green); +} + +@keyframes cursor-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* Neon glow effect for priority badges */ +.priority-badge { + position: relative; +} + +.priority-badge::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: inherit; + background: inherit; + filter: blur(6px); + opacity: 0.4; + z-index: -1; +} + +/* Smooth scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); + border: 1px solid var(--terminal-green); +} + +::-webkit-scrollbar-thumb { + background: var(--terminal-green); + box-shadow: 0 0 5px var(--terminal-green); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--terminal-amber); + box-shadow: 0 0 10px var(--terminal-amber); +} + +/* Firefox scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: var(--terminal-green) var(--bg-primary); +} + body.menu-open { padding-left: 260px; } @@ -711,6 +920,148 @@ h1 { 100% { content: '|]'; } } +/* ===== SKELETON LOADING SCREENS ===== */ +.skeleton { + position: relative; + overflow: hidden; + background: linear-gradient(90deg, + rgba(0, 255, 65, 0.05) 0%, + rgba(0, 255, 65, 0.1) 50%, + rgba(0, 255, 65, 0.05) 100% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Skeleton text line */ +.skeleton-text { + height: 1em; + margin: 0.5em 0; + border-radius: 0; + border: 1px solid rgba(0, 255, 65, 0.2); +} + +.skeleton-text.short { width: 40%; } +.skeleton-text.medium { width: 70%; } +.skeleton-text.long { width: 90%; } + +/* Skeleton table row */ +.skeleton-row { + display: flex; + align-items: center; + padding: 12px 8px; + border-bottom: 1px solid rgba(0, 255, 65, 0.1); + gap: 1rem; +} + +.skeleton-row .skeleton-cell { + height: 1.2em; + flex: 1; + border: 1px solid rgba(0, 255, 65, 0.15); +} + +.skeleton-row .skeleton-cell:first-child { flex: 0 0 100px; } +.skeleton-row .skeleton-cell:nth-child(2) { flex: 0 0 60px; } +.skeleton-row .skeleton-cell:nth-child(3) { flex: 3; } + +/* Skeleton stat card */ +.skeleton-stat { + padding: 1rem; + border: 2px solid rgba(0, 255, 65, 0.2); + min-height: 80px; +} + +.skeleton-stat .skeleton-value { + height: 2rem; + width: 60%; + margin-bottom: 0.5rem; +} + +.skeleton-stat .skeleton-label { + height: 1rem; + width: 80%; +} + +/* Skeleton comment */ +.skeleton-comment { + padding: 1rem; + border: 1px solid rgba(0, 255, 65, 0.2); + margin-bottom: 1rem; +} + +.skeleton-comment-header { + display: flex; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.skeleton-avatar { + width: 32px; + height: 32px; + border-radius: 0; + border: 1px solid rgba(0, 255, 65, 0.2); + flex-shrink: 0; +} + +.skeleton-comment-meta { + flex: 1; +} + +/* Terminal-style loading indicator */ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 10, 2, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 100; + font-family: var(--font-mono); + color: var(--terminal-green); +} + +.loading-overlay .loading-text { + margin-top: 1rem; + animation: blink-cursor 1s step-end infinite; +} + +.loading-overlay .loading-spinner { + font-size: 2rem; + animation: terminal-spin 0.5s linear infinite; +} + +@keyframes terminal-spin { + 0% { content: '⠋'; } + 10% { content: '⠙'; } + 20% { content: '⠹'; } + 30% { content: '⠸'; } + 40% { content: '⠼'; } + 50% { content: '⠴'; } + 60% { content: '⠦'; } + 70% { content: '⠧'; } + 80% { content: '⠇'; } + 90% { content: '⠏'; } +} + +.loading-spinner::before { + content: '⠋'; + animation: terminal-spin 0.8s linear infinite; +} + +@keyframes blink-cursor { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + /* ===== MESSAGE STYLES ===== */ .error-message { padding: 15px 20px; @@ -3324,19 +3675,59 @@ tr:hover .quick-actions { align-items: center; gap: 0.75rem; padding: 1rem; - background: var(--bg-secondary); + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(0, 255, 65, 0.03) 100%); border: 2px solid var(--terminal-green); transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +/* Corner accent */ +.stat-card::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 30px; + height: 30px; + background: linear-gradient(135deg, transparent 50%, rgba(0, 255, 65, 0.1) 50%); + transition: all 0.3s ease; } .stat-card:hover { border-color: var(--terminal-amber); box-shadow: var(--glow-amber); + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(255, 176, 0, 0.05) 100%); +} + +.stat-card:hover::after { + background: linear-gradient(135deg, transparent 50%, rgba(255, 176, 0, 0.15) 50%); +} + +/* Critical stat card styling */ +.stat-card.stat-critical { + border-color: var(--priority-1); + background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(255, 77, 77, 0.08) 100%); +} + +.stat-card.stat-critical .stat-value { + color: var(--priority-1); + text-shadow: var(--glow-priority-1); + animation: pulse-glow 2s ease-in-out infinite; +} + +.stat-card.stat-critical::after { + background: linear-gradient(135deg, transparent 50%, rgba(255, 77, 77, 0.15) 50%); } .stat-icon { font-size: 1.5rem; opacity: 0.9; + transition: transform 0.3s ease; +} + +.stat-card:hover .stat-icon { + transform: scale(1.1); } .stat-content { diff --git a/assets/css/ticket.css b/assets/css/ticket.css index b444a1e..8353b8b 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -372,6 +372,29 @@ textarea[data-field="description"]:not(:disabled)::after { 50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); } } +/* Tab transition animations */ +@keyframes tab-fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes tab-slide-in { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + .ticket-age .age-icon { font-size: 1rem; } @@ -601,13 +624,37 @@ textarea.editable { } .comment { - background: var(--bg-primary); + background: linear-gradient(135deg, var(--bg-primary) 0%, rgba(0, 255, 65, 0.02) 100%); padding: 15px; border: 1px solid var(--terminal-green); border-radius: 0; margin-bottom: 1rem; position: relative; box-shadow: none; + transition: all 0.3s ease; + animation: comment-appear 0.4s ease-out; +} + +@keyframes comment-appear { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.comment:hover { + border-color: var(--terminal-amber); + background: linear-gradient(135deg, var(--bg-primary) 0%, rgba(255, 176, 0, 0.03) 100%); + box-shadow: 0 0 15px rgba(0, 255, 65, 0.1); +} + +.comment:hover::before, +.comment:hover::after { + color: var(--terminal-amber); } /* Add corner decorations to individual comments */ @@ -802,6 +849,182 @@ textarea.editable { min-height: auto !important; } +/* ===== COMMENT THREADING STYLES ===== */ + +/* Thread depth indentation */ +.comment.thread-depth-1 { + margin-left: 2rem; +} + +.comment.thread-depth-2 { + margin-left: 4rem; +} + +.comment.thread-depth-3 { + margin-left: 6rem; +} + +/* Reply styling */ +.comment.comment-reply { + position: relative; + border-left: 2px solid var(--terminal-green-dim); + margin-top: 0.5rem; + padding-left: 1rem; +} + +.comment.comment-reply::before { + content: none; +} + +/* Thread connector line */ +.comment .thread-line { + position: absolute; + left: -1rem; + top: 0; + width: 0.75rem; + height: 1rem; + border-left: 2px solid var(--terminal-green-dim); + border-bottom: 2px solid var(--terminal-green-dim); +} + +/* Comment content wrapper for threading */ +.comment .comment-content { + flex: 1; +} + +/* Replies container */ +.comment-replies { + margin-top: 0.5rem; +} + +/* Reply button styling */ +.comment-action-btn.reply-btn { + color: var(--terminal-green); + font-size: 1rem; +} + +.comment-action-btn.reply-btn:hover { + color: var(--terminal-cyan); + border-color: var(--terminal-cyan); +} + +/* Reply form inline */ +.reply-form-container { + margin-top: 1rem; + padding: 1rem; + background: rgba(0, 255, 65, 0.03); + border: 1px dashed var(--terminal-green); + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.reply-form-container .reply-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + color: var(--terminal-green); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.reply-form-container .reply-header .replying-to { + color: var(--terminal-amber); +} + +.reply-form-container .close-reply-btn { + background: none; + border: 1px solid var(--terminal-red); + color: var(--terminal-red); + padding: 0.25rem 0.5rem; + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.reply-form-container .close-reply-btn:hover { + background: rgba(255, 0, 0, 0.1); +} + +.reply-form-container textarea { + width: 100%; + min-height: 80px; + padding: 0.75rem; + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + color: var(--terminal-green); + font-family: var(--font-mono); + font-size: 0.9rem; + resize: vertical; + margin-bottom: 0.75rem; +} + +.reply-form-container textarea:focus { + outline: none; + border-color: var(--terminal-amber); + box-shadow: 0 0 10px rgba(255, 176, 0, 0.3); +} + +.reply-form-container .reply-actions { + display: flex; + justify-content: space-between; + align-items: center; +} + +.reply-form-container .reply-buttons { + display: flex; + gap: 0.5rem; +} + +/* Quote block in replies */ +.comment-text blockquote { + border-left: 3px solid var(--terminal-green-dim); + padding-left: 1rem; + margin: 0.5rem 0; + color: var(--terminal-green-dim); + font-style: italic; +} + +/* Thread collapse indicator */ +.thread-collapse-btn { + background: none; + border: none; + color: var(--terminal-green-dim); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 0.25rem; + margin-left: 0.5rem; +} + +.thread-collapse-btn:hover { + color: var(--terminal-green); +} + +.comment.collapsed .comment-replies { + display: none; +} + +.comment.collapsed .thread-collapse-btn::after { + content: ' [+]'; +} + +.comment:not(.collapsed) .thread-collapse-btn::after { + content: ' [-]'; +} + +/* ===== END COMMENT THREADING STYLES ===== */ + /* Comment Tabs - TERMINAL STYLE */ .ticket-tabs { display: flex; @@ -857,6 +1080,7 @@ textarea.editable { .tab-content { display: none; padding: 20px; + animation: tab-fade-in 0.3s ease-out; border: 2px solid var(--terminal-green); border-top: none; background: var(--bg-secondary); diff --git a/assets/js/advanced-search.js b/assets/js/advanced-search.js index 8cbc812..26fe318 100644 --- a/assets/js/advanced-search.js +++ b/assets/js/advanced-search.js @@ -35,7 +35,9 @@ function closeOnAdvancedSearchBackdropClick(event) { // Load users for dropdown async function loadUsersForSearch() { try { - const response = await fetch('/api/get_users.php'); + const response = await fetch('/api/get_users.php', { + credentials: 'same-origin' + }); const data = await response.json(); if (data.success && data.users) { @@ -163,6 +165,7 @@ async function saveCurrentFilter() { try { const response = await fetch('/api/saved_filters.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -230,7 +233,9 @@ function getCurrentFilterCriteria() { // Load saved filters async function loadSavedFilters() { try { - const response = await fetch('/api/saved_filters.php'); + const response = await fetch('/api/saved_filters.php', { + credentials: 'same-origin' + }); const data = await response.json(); if (data.success && data.filters) { @@ -326,6 +331,7 @@ async function deleteSavedFilter() { try { const response = await fetch('/api/saved_filters.php', { method: 'DELETE', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index d3c97de..eeb852f 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -539,6 +539,7 @@ function quickSave() { fetch('/api/update_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -607,6 +608,7 @@ function saveTicket() { fetch('/api/update_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -645,7 +647,9 @@ function loadTemplate() { } // Fetch template data - fetch(`/api/get_template.php?template_id=${templateId}`) + fetch(`/api/get_template.php?template_id=${templateId}`, { + credentials: 'same-origin' + }) .then(response => { if (!response.ok) { throw new Error('Failed to fetch template'); @@ -776,6 +780,7 @@ function performBulkCloseAction(ticketIds) { fetch('/api/bulk_operation.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -848,7 +853,9 @@ function showBulkAssignModal() { document.body.insertAdjacentHTML('beforeend', modalHtml); // Fetch users for the dropdown - fetch('/api/get_users.php') + fetch('/api/get_users.php', { + credentials: 'same-origin' + }) .then(response => response.json()) .then(data => { if (data.success && data.users) { @@ -884,6 +891,7 @@ function performBulkAssign() { fetch('/api/bulk_operation.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -979,6 +987,7 @@ function performBulkPriority() { fetch('/api/bulk_operation.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1113,6 +1122,7 @@ function performBulkStatusChange() { fetch('/api/bulk_operation.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1201,6 +1211,7 @@ function performBulkDelete() { fetch('/api/bulk_operation.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1474,6 +1485,7 @@ function performQuickStatusChange(ticketId) { fetch('/api/update_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1539,7 +1551,9 @@ function quickAssign(ticketId) { document.body.insertAdjacentHTML('beforeend', modalHtml); // Load users - fetch('/api/get_users.php') + fetch('/api/get_users.php', { + credentials: 'same-origin' + }) .then(response => response.json()) .then(data => { if (data.success && data.users) { @@ -1565,6 +1579,7 @@ function performQuickAssign(ticketId) { fetch('/api/assign_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1859,3 +1874,108 @@ function exportSelectedTickets(format) { const dropdown = document.getElementById('exportDropdown'); if (dropdown) dropdown.classList.remove('open'); } + +// ======================================== +// Skeleton Loading Helpers +// ======================================== + +/** + * Generate skeleton table rows + */ +function generateSkeletonRows(count = 5) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` + +
+
+
+
+
+
+
+
+
+
+
+ + `; + } + return html; +} + +/** + * Generate skeleton comments + */ +function generateSkeletonComments(count = 3) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` +
+
+
+
+
+
+
+
+
+
+
+
+ `; + } + return html; +} + +/** + * Generate skeleton stat cards + */ +function generateSkeletonStats(count = 4) { + let html = ''; + for (let i = 0; i < count; i++) { + html += ` +
+
+
+
+ `; + } + return html; +} + +/** + * Show loading overlay on element + */ +function showLoadingOverlay(element, message = 'Loading...') { + // Remove existing overlay + const existing = element.querySelector('.loading-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'loading-overlay'; + overlay.innerHTML = ` +
+
${message}
+ `; + element.style.position = 'relative'; + element.appendChild(overlay); +} + +/** + * Hide loading overlay + */ +function hideLoadingOverlay(element) { + const overlay = element.querySelector('.loading-overlay'); + if (overlay) { + overlay.style.opacity = '0'; + overlay.style.transition = 'opacity 0.3s'; + setTimeout(() => overlay.remove(), 300); + } +} + +// Export for use in other scripts +window.generateSkeletonRows = generateSkeletonRows; +window.generateSkeletonComments = generateSkeletonComments; +window.showLoadingOverlay = showLoadingOverlay; +window.hideLoadingOverlay = hideLoadingOverlay; diff --git a/assets/js/settings.js b/assets/js/settings.js index ea0775f..3f33795 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -8,7 +8,9 @@ let userPreferences = {}; // Load preferences on page load async function loadUserPreferences() { try { - const response = await fetch('/api/user_preferences.php'); + const response = await fetch('/api/user_preferences.php', { + credentials: 'same-origin' + }); const data = await response.json(); if (data.success) { userPreferences = data.preferences; @@ -96,6 +98,7 @@ async function saveSettings() { for (const [key, value] of Object.entries(prefs)) { const response = await fetch('/api/user_preferences.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 7ae1006..4323afa 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -72,6 +72,7 @@ function saveTicket() { fetch(apiUrl, { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -173,6 +174,7 @@ function addComment() { fetch('/api/add_comment.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -333,6 +335,7 @@ function handleAssignmentChange() { fetch('/api/assign_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -367,6 +370,7 @@ function handleMetadataChanges() { fetch('/api/update_ticket.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -590,7 +594,9 @@ function showTab(tabName) { function loadDependencies() { const ticketId = window.ticketData.id; - fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`) + fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`, { + credentials: 'same-origin' + }) .then(response => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); @@ -708,6 +714,7 @@ function addDependency() { fetch('/api/ticket_dependencies.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -741,6 +748,7 @@ function removeDependency(dependencyId) { fetch('/api/ticket_dependencies.php', { method: 'DELETE', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -906,7 +914,9 @@ function loadAttachments() { if (!container) return; - fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`) + fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`, { + credentials: 'same-origin' + }) .then(response => response.json()) .then(data => { if (data.success) { @@ -984,6 +994,7 @@ function deleteAttachment(attachmentId) { fetch('/api/delete_attachment.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1047,7 +1058,9 @@ function initMentionAutocomplete() { * Fetch available users for mentions */ function fetchMentionUsers() { - fetch('/api/get_users.php') + fetch('/api/get_users.php', { + credentials: 'same-origin' + }) .then(response => response.json()) .then(data => { if (data.success && data.users) { @@ -1235,6 +1248,15 @@ document.addEventListener('DOMContentLoaded', function() { case 'cancel-edit-comment': cancelEditComment(parseInt(target.dataset.commentId)); break; + case 'reply-comment': + showReplyForm(parseInt(target.dataset.commentId), target.dataset.user); + break; + case 'edit-comment': + editComment(parseInt(target.dataset.commentId)); + break; + case 'delete-comment': + deleteComment(parseInt(target.dataset.commentId)); + break; } }); }); @@ -1311,6 +1333,7 @@ function saveEditComment(commentId) { // Send update request fetch('/api/update_comment.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1395,6 +1418,7 @@ function deleteComment(commentId) { fetch('/api/delete_comment.php', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN @@ -1425,8 +1449,126 @@ function deleteComment(commentId) { }); } +// ======================================== +// Comment Reply Functions +// ======================================== + +/** + * Show reply form for a comment + */ +function showReplyForm(commentId, userName) { + // Remove any existing reply forms + document.querySelectorAll('.reply-form-container').forEach(form => form.remove()); + + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + if (!commentDiv) return; + + const replyFormHtml = ` +
+
+ Replying to @${userName} + +
+ +
+ +
+ +
+
+
+ `; + + // Find the comment-content div and insert after it + const contentDiv = commentDiv.querySelector('.comment-content') || commentDiv; + contentDiv.insertAdjacentHTML('afterend', replyFormHtml); + + // Focus on the textarea + const textarea = document.getElementById('replyText'); + if (textarea) { + textarea.focus(); + } +} + +/** + * Close reply form + */ +function closeReplyForm() { + document.querySelectorAll('.reply-form-container').forEach(form => { + form.style.animation = 'fadeIn 0.2s ease reverse'; + setTimeout(() => form.remove(), 200); + }); +} + +/** + * Submit a reply to a comment + */ +function submitReply(parentCommentId) { + const replyText = document.getElementById('replyText'); + const replyMarkdown = document.getElementById('replyMarkdown'); + const ticketId = window.ticketData.id; + + if (!replyText || !replyText.value.trim()) { + showToast('Please enter a reply', 'warning'); + return; + } + + const commentText = replyText.value.trim(); + const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false; + + fetch('/api/add_comment.php', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.CSRF_TOKEN + }, + body: JSON.stringify({ + ticket_id: ticketId, + comment_text: commentText, + markdown_enabled: isMarkdownEnabled, + parent_comment_id: parentCommentId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Close the reply form + closeReplyForm(); + + // Reload page to show the new threaded comment properly + // (Threading requires proper hierarchical rendering) + showToast('Reply added successfully', 'success'); + setTimeout(() => window.location.reload(), 500); + } else { + showToast(data.error || 'Failed to add reply', 'error'); + } + }) + .catch(error => { + console.error('Error adding reply:', error); + showToast('Failed to add reply', 'error'); + }); +} + +/** + * Toggle thread collapse/expand + */ +function toggleThreadCollapse(commentId) { + const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); + if (commentDiv) { + commentDiv.classList.toggle('collapsed'); + } +} + // Expose functions globally window.editComment = editComment; window.saveEditComment = saveEditComment; window.cancelEditComment = cancelEditComment; window.deleteComment = deleteComment; +window.showReplyForm = showReplyForm; +window.closeReplyForm = closeReplyForm; +window.submitReply = submitReply; +window.toggleThreadCollapse = toggleThreadCollapse; diff --git a/migrations/002_add_comment_threading.sql b/migrations/002_add_comment_threading.sql new file mode 100644 index 0000000..c32b12d --- /dev/null +++ b/migrations/002_add_comment_threading.sql @@ -0,0 +1,19 @@ +-- Migration: Add comment threading support +-- Adds parent_comment_id for reply/thread functionality + +-- Add parent_comment_id column for threaded comments +ALTER TABLE ticket_comments +ADD COLUMN parent_comment_id INT NULL DEFAULT NULL AFTER comment_id; + +-- Add foreign key constraint (self-referencing for thread hierarchy) +ALTER TABLE ticket_comments +ADD CONSTRAINT fk_parent_comment +FOREIGN KEY (parent_comment_id) REFERENCES ticket_comments(comment_id) +ON DELETE CASCADE; + +-- Add index for efficient thread retrieval +CREATE INDEX idx_parent_comment ON ticket_comments(parent_comment_id); + +-- Add thread_depth column to track nesting level (prevents infinite recursion issues) +ALTER TABLE ticket_comments +ADD COLUMN thread_depth TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER parent_comment_id; diff --git a/models/CommentModel.php b/models/CommentModel.php index 4dcc4a0..f9ddeb5 100644 --- a/models/CommentModel.php +++ b/models/CommentModel.php @@ -50,18 +50,34 @@ class CommentModel { return $users; } - public function getCommentsByTicketId($ticketId) { - $sql = "SELECT tc.*, u.display_name, u.username - FROM ticket_comments tc - LEFT JOIN users u ON tc.user_id = u.user_id - WHERE tc.ticket_id = ? - ORDER BY tc.created_at DESC"; + public function getCommentsByTicketId($ticketId, $threaded = true) { + // Check if threading columns exist + $hasThreading = $this->hasThreadingSupport(); + + if ($hasThreading) { + $sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.ticket_id = ? + ORDER BY + CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC, + CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC"; + } else { + $sql = "SELECT tc.*, u.display_name, u.username + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.ticket_id = ? + ORDER BY tc.created_at DESC"; + } + $stmt = $this->conn->prepare($sql); - $stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar + $stmt->bind_param("s", $ticketId); $stmt->execute(); $result = $stmt->get_result(); $comments = []; + $commentMap = []; + while ($row = $result->fetch_assoc()) { // Use display_name from users table if available, fallback to user_name field if (!empty($row['display_name'])) { @@ -69,33 +85,111 @@ class CommentModel { } else { $row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User'; } - $comments[] = $row; + $row['replies'] = []; + $row['parent_comment_id'] = $row['parent_comment_id'] ?? null; + $row['thread_depth'] = $row['thread_depth'] ?? 0; + $commentMap[$row['comment_id']] = $row; } - return $comments; + // Build threaded structure if threading is enabled + if ($hasThreading && $threaded) { + $rootComments = []; + foreach ($commentMap as $id => $comment) { + if ($comment['parent_comment_id'] === null) { + $rootComments[] = $this->buildCommentThread($comment, $commentMap); + } + } + return $rootComments; + } + + // Flat list + return array_values($commentMap); + } + + /** + * Check if threading columns exist + */ + private function hasThreadingSupport() { + static $hasSupport = null; + if ($hasSupport !== null) { + return $hasSupport; + } + + $result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'"); + $hasSupport = ($result && $result->num_rows > 0); + return $hasSupport; + } + + /** + * Recursively build comment thread + */ + private function buildCommentThread($comment, &$allComments) { + $comment['replies'] = []; + foreach ($allComments as $c) { + if ($c['parent_comment_id'] == $comment['comment_id']) { + $comment['replies'][] = $this->buildCommentThread($c, $allComments); + } + } + // Sort replies by date ascending + usort($comment['replies'], function($a, $b) { + return strtotime($a['created_at']) - strtotime($b['created_at']); + }); + return $comment; + } + + /** + * Get flat list of comments (for backward compatibility) + */ + public function getCommentsByTicketIdFlat($ticketId) { + return $this->getCommentsByTicketId($ticketId, false); } public function addComment($ticketId, $commentData, $userId = null) { - $sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) - VALUES (?, ?, ?, ?, ?)"; - - $stmt = $this->conn->prepare($sql); + // Check if threading is supported + $hasThreading = $this->hasThreadingSupport(); // Set default username (kept for backward compatibility) $username = $commentData['user_name'] ?? 'User'; $markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0; - - // Preserve line breaks in the comment text $commentText = $commentData['comment_text']; + $parentCommentId = $commentData['parent_comment_id'] ?? null; + $threadDepth = 0; - $stmt->bind_param( - "sissi", - $ticketId, - $userId, - $username, - $commentText, - $markdownEnabled - ); + // Calculate thread depth if replying to a comment + if ($hasThreading && $parentCommentId) { + $parentComment = $this->getCommentById($parentCommentId); + if ($parentComment) { + $threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3 + } + } + + if ($hasThreading) { + $sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth) + VALUES (?, ?, ?, ?, ?, ?, ?)"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param( + "sissiii", + $ticketId, + $userId, + $username, + $commentText, + $markdownEnabled, + $parentCommentId, + $threadDepth + ); + } else { + $sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) + VALUES (?, ?, ?, ?, ?)"; + $stmt = $this->conn->prepare($sql); + $stmt->bind_param( + "sissi", + $ticketId, + $userId, + $username, + $commentText, + $markdownEnabled + ); + } if ($stmt->execute()) { $commentId = $this->conn->insert_id; @@ -106,7 +200,9 @@ class CommentModel { 'user_name' => $username, 'created_at' => date('M d, Y H:i'), 'markdown_enabled' => $markdownEnabled, - 'comment_text' => $commentText + 'comment_text' => $commentText, + 'parent_comment_id' => $parentCommentId, + 'thread_depth' => $threadDepth ]; } else { return [ diff --git a/views/TicketView.php b/views/TicketView.php index a437b18..15b7008 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -331,42 +331,76 @@ $nonce = SecurityHeadersMiddleware::getNonce(); $currentUserId = $GLOBALS['currentUser']['user_id'] ?? null; $isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false; - foreach ($comments as $comment) { - // Use display_name_formatted which falls back appropriately + // Recursive function to render threaded comments + function renderComment($comment, $currentUserId, $isAdmin, $depth = 0) { $displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User'; $commentId = $comment['comment_id']; $isOwner = ($comment['user_id'] == $currentUserId); $canModify = $isOwner || $isAdmin; $markdownEnabled = $comment['markdown_enabled'] ? 1 : 0; + $threadDepth = $comment['thread_depth'] ?? $depth; + $parentId = $comment['parent_comment_id'] ?? null; - echo "
"; + $depthClass = 'thread-depth-' . min($threadDepth, 3); + $threadClass = $parentId ? 'comment-reply' : 'comment-root'; + + echo "
"; + + // Thread connector line for replies + if ($parentId) { + echo "
"; + } + + echo "
"; echo "
"; echo "" . htmlspecialchars($displayName) . ""; $dateStr = date('M d, Y H:i', strtotime($comment['created_at'])); $editedIndicator = !empty($comment['updated_at']) ? ' (edited)' : ''; echo "{$dateStr}{$editedIndicator}"; + // Action buttons + echo "
"; + // Reply button (max depth of 3) + if ($threadDepth < 3) { + echo ""; + } // Edit/Delete buttons for owner or admin if ($canModify) { - echo "
"; - echo ""; - echo ""; - echo "
"; + echo ""; + echo ""; } - echo "
"; + + echo "
"; // .comment-header + echo "
"; if ($comment['markdown_enabled']) { - // Markdown will be rendered by JavaScript echo htmlspecialchars($comment['comment_text']); } else { - // For non-markdown comments, convert line breaks to
and escape HTML echo nl2br(htmlspecialchars($comment['comment_text'])); } echo "
"; + // Hidden raw text for editing echo ""; - echo "
"; + + echo "
"; // .comment-content + + // Render replies recursively + if (!empty($comment['replies'])) { + echo "
"; + foreach ($comment['replies'] as $reply) { + renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1); + } + echo "
"; + } + + echo "
"; // .comment + } + + // Render all comments + foreach ($comments as $comment) { + renderComment($comment, $currentUserId, $isAdmin); } ?>