// Global State const state = { currentTab: 'dashboard-tab', dbPage: 1, dbLimit: 12, dbSearch: '', dbCategory: '', dbCompany: '', selectedProduct: null, sseConnected: false }; // DOM Elements const elements = { tabs: document.querySelectorAll('.nav-btn'), tabContents: document.querySelectorAll('.tab-content'), pageTitle: document.getElementById('page-title'), pageSubtitle: document.getElementById('page-subtitle'), // Stats elements statTotal: document.getElementById('stat-total'), statCompleted: document.getElementById('stat-completed'), statPending: document.getElementById('stat-pending'), statFailed: document.getElementById('stat-failed'), progressPercent: document.getElementById('progress-percent'), progressFillSuccess: document.getElementById('progress-fill-success'), progressFillError: document.getElementById('progress-fill-error'), activeThreadsBadge: document.getElementById('active-threads-badge'), // Scraper Controls inputConcurrency: document.getElementById('input-concurrency'), inputDelay: document.getElementById('input-delay'), btnToggleScrape: document.getElementById('btn-toggle-scrape'), btnRetryFailed: document.getElementById('btn-retry-failed'), consoleLogs: document.getElementById('console-logs'), btnClearLogs: document.getElementById('btn-clear-logs'), // Database tab elements dbSearchInput: document.getElementById('db-search'), filterCategory: document.getElementById('filter-category'), filterCompany: document.getElementById('filter-company'), btnClearFilters: document.getElementById('btn-clear-filters'), productsGrid: document.getElementById('products-grid-list'), dbEmptyState: document.getElementById('db-empty-state'), dbLoadingState: document.getElementById('db-loading-state'), paginationControls: document.getElementById('pagination-controls'), btnPrevPage: document.getElementById('btn-prev-page'), btnNextPage: document.getElementById('btn-next-page'), pageNumbersContainer: document.getElementById('page-numbers-container'), // Form tab elements productForm: document.getElementById('product-form'), formProductId: document.getElementById('form-product-id'), formBarcode: document.getElementById('form-barcode'), formName: document.getElementById('form-name'), formCategory: document.getElementById('form-category'), formSubcategory: document.getElementById('form-subcategory'), formCompany: document.getElementById('form-company'), formImage: document.getElementById('form-image'), btnSaveProduct: document.getElementById('btn-save-product'), btnResetForm: document.getElementById('btn-reset-form'), // Modal elements productDetailModal: document.getElementById('product-detail-modal'), btnCloseModal: document.getElementById('btn-close-modal'), modalProductImage: document.getElementById('modal-product-image'), modalProductName: document.getElementById('modal-product-name'), modalProductCategory: document.getElementById('modal-product-category'), modalProductCompany: document.getElementById('modal-product-company'), modalCleanName: document.getElementById('modal-clean-name'), modalAmountRaw: document.getElementById('modal-amount-raw'), modalAmount: document.getElementById('modal-amount'), modalUnit: document.getElementById('modal-unit'), modalBarcodeSvg: document.getElementById('modal-barcode-svg'), modalQrcodeCanvas: document.getElementById('modal-qrcode-canvas'), downloadButtons: document.querySelectorAll('.download-btn'), // Toast notification toast: document.getElementById('toast') }; // API Base URL const API_URL = ''; // Same host // Toast Notification Helper function showToast(message, isError = false) { const toast = elements.toast; const icon = toast.querySelector('.toast-icon'); const msg = toast.querySelector('.toast-message'); msg.textContent = message; if (isError) { toast.classList.add('error'); icon.className = 'fa-solid fa-circle-xmark toast-icon'; } else { toast.classList.remove('error'); icon.className = 'fa-solid fa-circle-check toast-icon'; } toast.classList.add('show'); setTimeout(() => { toast.classList.remove('show'); }, 4000); } /* Event Handlers & Core Functions */ // Initialize client document.addEventListener('DOMContentLoaded', () => { initTabs(); initScraperControl(); initDatabase(); initForm(); initModal(); connectSSE(); }); // 1. Navigation Tab Switches function initTabs() { elements.tabs.forEach(btn => { btn.addEventListener('click', () => { const targetTab = btn.getAttribute('data-tab'); state.currentTab = targetTab; // Toggle active classes elements.tabs.forEach(t => t.classList.remove('active')); elements.tabContents.forEach(c => c.classList.remove('active')); btn.classList.add('active'); document.getElementById(targetTab).classList.add('active'); // Header Content Updates if (targetTab === 'dashboard-tab') { elements.pageTitle.textContent = 'Kontrol Paneli'; elements.pageSubtitle.textContent = 'Veri tarama sürecini ve sunucu istatistiklerini canlı takip edin'; } else if (targetTab === 'database-tab') { elements.pageTitle.textContent = 'Ürün Veritabanı'; elements.pageSubtitle.textContent = 'Taranan barkod verilerini arayın, inceleyin, düzenleyin veya yeni kodlar üretin'; fetchProducts(); } else if (targetTab === 'add-product-tab') { elements.pageTitle.textContent = 'Ürün Ekle'; elements.pageSubtitle.textContent = 'Veritabanına manuel olarak yeni bir barkod kaydı girin'; resetForm(); } }); }); } // 2. SSE Scraper Stream Connection let sseSource = null; let pollingInterval = null; function connectSSE() { if (sseSource) { sseSource.close(); } const indicator = document.querySelector('.status-indicator'); // Set a timeout to switch to polling if SSE doesn't connect within 2.5 seconds const sseTimeout = setTimeout(() => { if (!state.sseConnected) { console.log('SSE connection timeout. Falling back to HTTP polling...'); startPolling(); } }, 2500); try { if (!window.EventSource) { throw new Error('EventSource is not supported by this browser.'); } sseSource = new EventSource(`${API_URL}/api/scrape/stream`); sseSource.onopen = () => { clearTimeout(sseTimeout); stopPolling(); state.sseConnected = true; indicator.className = 'status-indicator connected'; indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (SSE)'; }; sseSource.onerror = (e) => { clearTimeout(sseTimeout); state.sseConnected = false; indicator.className = 'status-indicator disconnected'; indicator.querySelector('.status-text').textContent = 'SSE Bağlantısı Kesildi - Polling Aktif'; startPolling(); }; sseSource.onmessage = (event) => { try { const stats = JSON.parse(event.data); updateScraperDashboard(stats); } catch (error) { console.error('Failed to parse SSE data:', error); } }; } catch (err) { clearTimeout(sseTimeout); console.warn('SSE initialization failed. Using polling fallback:', err.message); startPolling(); } } function startPolling() { if (pollingInterval) return; const indicator = document.querySelector('.status-indicator'); indicator.className = 'status-indicator connected'; indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (Polling)'; // Run once immediately fetchStats(); pollingInterval = setInterval(fetchStats, 1500); } async function fetchStats() { const indicator = document.querySelector('.status-indicator'); try { const response = await fetch(`${API_URL}/api/stats`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const stats = await response.json(); updateScraperDashboard(stats); indicator.className = 'status-indicator connected'; if (!state.sseConnected) { indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Aktif (Polling)'; } } catch (error) { console.error('Polling error:', error); indicator.className = 'status-indicator disconnected'; indicator.querySelector('.status-text').textContent = 'Sunucu Bağlantısı Koptu'; } } function stopPolling() { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } } // Update Dashboard Stats & Progress let lastLogTimestamp = null; function updateScraperDashboard(stats) { // 1. Text values elements.statTotal.textContent = stats.totalUrls.toLocaleString(); elements.statCompleted.textContent = stats.completedCount.toLocaleString(); elements.statPending.textContent = stats.pendingCount.toLocaleString(); elements.statFailed.textContent = stats.failedCount.toLocaleString(); elements.activeThreadsBadge.textContent = `${stats.activeWorkers} Aktif İşçi`; // 2. Concurrency inputs update if not focused if (document.activeElement !== elements.inputConcurrency) { elements.inputConcurrency.value = stats.concurrency; } if (document.activeElement !== elements.inputDelay) { elements.inputDelay.value = stats.delayMs; } // 3. Play/Pause Button State if (stats.isRunning) { elements.btnToggleScrape.className = 'btn btn-danger btn-large'; elements.btnToggleScrape.innerHTML = ' Tarayıcıyı Duraklat'; } else { elements.btnToggleScrape.className = 'btn btn-primary btn-large'; elements.btnToggleScrape.innerHTML = ' Tarayıcıyı Başlat'; } // 4. Progress Bars const total = stats.totalUrls || 1; const completedPercent = (stats.completedCount / total) * 100; const failedPercent = (stats.failedCount / total) * 100; const totalPercent = Math.min(100, completedPercent + failedPercent); elements.progressPercent.textContent = `${totalPercent.toFixed(1)}%`; elements.progressFillSuccess.style.width = `${completedPercent}%`; elements.progressFillError.style.width = `${failedPercent}%`; // 5. Build Scrolling Logs if (stats.logs && stats.logs.length > 0) { const consoleBox = elements.consoleLogs; // Clear default loading line on first actual logs if (consoleBox.innerHTML.includes('Sistem yükleniyor')) { consoleBox.innerHTML = ''; } // Find logs that are newer than what is currently rendered const existingLinesCount = consoleBox.childElementCount; const newLogs = stats.logs.slice(existingLinesCount); newLogs.forEach(log => { const line = document.createElement('div'); line.className = `console-line ${log.type}`; const time = document.createElement('span'); time.className = 'console-time'; time.textContent = new Date(log.timestamp).toLocaleTimeString(); const msg = document.createElement('span'); msg.textContent = log.message; line.appendChild(time); line.appendChild(msg); consoleBox.appendChild(line); // Limit lines in browser DOM to 300 for memory efficiency while (consoleBox.childElementCount > 300) { consoleBox.removeChild(consoleBox.firstChild); } // Scroll to bottom consoleBox.scrollTop = consoleBox.scrollHeight; }); } } // 3. Scraper Control Actions function initScraperControl() { // Start/Pause Button Toggle elements.btnToggleScrape.addEventListener('click', async () => { const isRunningText = elements.btnToggleScrape.textContent.includes('Duraklat'); const endpoint = isRunningText ? '/api/scrape/pause' : '/api/scrape/start'; const payload = isRunningText ? {} : { concurrency: parseInt(elements.inputConcurrency.value) || 5, delayMs: parseInt(elements.inputDelay.value) !== undefined ? parseInt(elements.inputDelay.value) : 500 }; try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.success) { showToast(data.message); } else { showToast(data.error || 'Tarama durumunu değiştirme işlemi başarısız oldu.', true); } } catch (error) { showToast('Sunucu ile iletişim kurulamadı.', true); } }); // Retry Failed elements.btnRetryFailed.addEventListener('click', async () => { try { const response = await fetch('/api/scrape/retry', { method: 'POST' }); const data = await response.json(); if (data.success) { showToast(data.message); } else { showToast(data.error || 'Hatalı adresleri sıfırlama işlemi başarısız.', true); } } catch (error) { showToast('İşlem başarısız.', true); } }); // Clear Logs Console View elements.btnClearLogs.addEventListener('click', () => { elements.consoleLogs.innerHTML = '
Konsol temizlendi. Yeni güncellemeler bekleniyor...
'; }); } // 4. Product Database Tab (Search, Filter, Pagination, Render) function initDatabase() { // Debounce search input let searchTimeout = null; elements.dbSearchInput.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { state.dbSearch = elements.dbSearchInput.value.trim(); state.dbPage = 1; fetchProducts(); }, 400); }); // Select filters change elements.filterCategory.addEventListener('change', () => { state.dbCategory = elements.filterCategory.value; state.dbPage = 1; fetchProducts(); }); elements.filterCompany.addEventListener('change', () => { state.dbCompany = elements.filterCompany.value; state.dbPage = 1; fetchProducts(); }); // Clear Filters elements.btnClearFilters.addEventListener('click', () => { elements.dbSearchInput.value = ''; elements.filterCategory.value = ''; elements.filterCompany.value = ''; state.dbSearch = ''; state.dbCategory = ''; state.dbCompany = ''; state.dbPage = 1; fetchProducts(); }); // Pagination Click Listeners elements.btnPrevPage.addEventListener('click', () => { if (state.dbPage > 1) { state.dbPage--; fetchProducts(); } }); elements.btnNextPage.addEventListener('click', () => { state.dbPage++; fetchProducts(); }); } // Fetch products from database API async function fetchProducts() { elements.dbLoadingState.classList.remove('hidden'); elements.productsGrid.classList.add('hidden'); elements.dbEmptyState.classList.add('hidden'); elements.paginationControls.classList.add('hidden'); // Build URL query parameters const params = new URLSearchParams({ page: state.dbPage, limit: state.dbLimit, q: state.dbSearch, category: state.dbCategory, company: state.dbCompany }); try { const response = await fetch(`/api/products?${params.toString()}`); const data = await response.json(); elements.dbLoadingState.classList.add('hidden'); if (data.products && data.products.length > 0) { renderProductsGrid(data.products); renderPagination(data.pagination); // Only update filters dropdown lists on page 1 search/initial load if (state.dbPage === 1 && data.filters) { populateFilters(data.filters); } } else { elements.dbEmptyState.classList.remove('hidden'); } } catch (error) { console.error('Failed to load products:', error); elements.dbLoadingState.classList.add('hidden'); elements.dbEmptyState.classList.remove('hidden'); showToast('Ürün veritabanı yüklenirken hata oluştu.', true); } } // Populates filter select dropdown arrays function populateFilters(filters) { // 1. Categories dropdown const currentCategory = elements.filterCategory.value; elements.filterCategory.innerHTML = ''; filters.categories.forEach(cat => { const option = document.createElement('option'); option.value = cat.name; option.textContent = `${cat.name} (${cat.count})`; if (cat.name === currentCategory) option.selected = true; elements.filterCategory.appendChild(option); }); // 2. Companies dropdown const currentCompany = elements.filterCompany.value; elements.filterCompany.innerHTML = ''; filters.companies.forEach(comp => { const option = document.createElement('option'); option.value = comp.name; option.textContent = `${comp.name} (${comp.count})`; if (comp.name === currentCompany) option.selected = true; elements.filterCompany.appendChild(option); }); } // Renders the Grid of Product Cards function renderProductsGrid(products) { const grid = elements.productsGrid; grid.innerHTML = ''; grid.classList.remove('hidden'); products.forEach(product => { const card = document.createElement('div'); card.className = 'product-card'; card.dataset.id = product.id; // Fallback placeholder image URL const imageSrc = product.image || `https://placehold.co/400x400/1e293b/cbd5e1?text=${encodeURIComponent(product.cleanName || 'Ürün Resmi')}`; card.innerHTML = `
${product.cleanName || 'Ürün Görseli'}
${product.barcode}

${product.name}

${product.category ? ` ${product.category}` : ''} ${product.company ? ` ${product.company}` : ''} ${product.amountRaw ? ` ${product.amountRaw} ${product.unit || ''}` : ''}
`; // Card button click bindings card.querySelector('.view-details-btn').addEventListener('click', () => openDetailModal(product)); card.querySelector('.edit-product-btn').addEventListener('click', () => editProductForm(product)); card.querySelector('.delete-product-btn').addEventListener('click', () => deleteProduct(product)); grid.appendChild(card); }); } // Renders Page Numbers function renderPagination(pageInfo) { elements.paginationControls.classList.remove('hidden'); // Enable/Disable navigation buttons elements.btnPrevPage.disabled = pageInfo.page <= 1; elements.btnNextPage.disabled = pageInfo.page >= pageInfo.totalPages; const container = elements.pageNumbersContainer; container.innerHTML = ''; const current = pageInfo.page; const total = pageInfo.totalPages; // Helper logic to build numbers with dots (e.g. 1 2 ... 5 6 7 ... 9 10) let pages = []; if (total <= 5) { for (let i = 1; i <= total; i++) pages.push(i); } else { if (current <= 3) { pages = [1, 2, 3, 4, '...', total]; } else if (current >= total - 2) { pages = [1, '...', total - 3, total - 2, total - 1, total]; } else { pages = [1, '...', current - 1, current, current + 1, '...', total]; } } pages.forEach(p => { const span = document.createElement('span'); if (p === '...') { span.textContent = '...'; span.className = 'dots'; } else { span.textContent = p; if (p === current) { span.className = 'active'; } span.addEventListener('click', () => { state.dbPage = p; fetchProducts(); }); } container.appendChild(span); }); } // Delete Product Operation async function deleteProduct(product) { if (!confirm(`"${product.name}" adlı ürünü silmek istediğinize emin misiniz?`)) { return; } try { const response = await fetch(`/api/products/${product.id}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { showToast('Ürün başarıyla silindi.'); fetchProducts(); } else { showToast(data.error || 'Ürün silinirken bir hata oluştu.', true); } } catch (error) { showToast('İşlem gerçekleştirilemedi.', true); } } // 5. Form Submissions (Add / Edit Product Manual Entry) function initForm() { elements.productForm.addEventListener('submit', async (e) => { e.preventDefault(); const id = elements.formProductId.value; const payload = { barcode: elements.formBarcode.value.trim(), name: elements.formName.value.trim(), category: elements.formCategory.value.trim(), subcategory: elements.formSubcategory.value.trim(), company: elements.formCompany.value.trim(), image: elements.formImage.value.trim() }; const isEdit = id !== ''; const endpoint = isEdit ? `/api/products/${id}` : '/api/products'; const method = isEdit ? 'PUT' : 'POST'; try { elements.btnSaveProduct.disabled = true; elements.btnSaveProduct.innerHTML = ' Kaydediliyor...'; const response = await fetch(endpoint, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); elements.btnSaveProduct.disabled = false; elements.btnSaveProduct.innerHTML = ' Ürünü Kaydet'; if (response.ok) { showToast(isEdit ? 'Ürün başarıyla güncellendi!' : 'Ürün başarıyla eklendi!'); resetForm(); // Switch to Database Tab document.getElementById('btn-tab-database').click(); } else { showToast(data.error || 'Kayıt başarısız oldu.', true); } } catch (error) { elements.btnSaveProduct.disabled = false; elements.btnSaveProduct.innerHTML = ' Ürünü Kaydet'; showToast('Sunucu bağlantı hatası.', true); } }); elements.btnResetForm.addEventListener('click', resetForm); } // Redirects and fills Form for Edit Action function editProductForm(product) { // Fill the inputs elements.formProductId.value = product.id; elements.formBarcode.value = product.barcode; elements.formName.value = product.name; elements.formCategory.value = product.category || ''; elements.formSubcategory.value = product.subcategory || ''; elements.formCompany.value = product.company || ''; elements.formImage.value = product.image || ''; // Update labels and display document.getElementById('btn-tab-add').click(); elements.pageTitle.textContent = 'Ürün Düzenle'; elements.pageSubtitle.textContent = `"${product.cleanName}" barkod kaydını güncelleyin`; elements.btnSaveProduct.innerHTML = ' Değişiklikleri Kaydet'; } function resetForm() { elements.productForm.reset(); elements.formProductId.value = ''; elements.btnSaveProduct.innerHTML = ' Ürünü Kaydet'; if (state.currentTab === 'add-product-tab') { elements.pageTitle.textContent = 'Ürün Ekle'; elements.pageSubtitle.textContent = 'Veritabanına manuel olarak yeni bir barkod kaydı girin'; } } // 6. Product Detail Modal & Barcode Renders function initModal() { elements.btnCloseModal.addEventListener('click', () => { elements.productDetailModal.classList.remove('active'); state.selectedProduct = null; }); // Close on overlay overlay click elements.productDetailModal.addEventListener('click', (e) => { if (e.target === elements.productDetailModal) { elements.productDetailModal.classList.remove('active'); state.selectedProduct = null; } }); // Handle code downloads (SVG for Barcode, Canvas for QR Code) elements.downloadButtons.forEach(btn => { btn.addEventListener('click', () => { const type = btn.getAttribute('data-type'); if (!state.selectedProduct) return; const barcodeVal = state.selectedProduct.barcode; const productName = state.selectedProduct.cleanName || 'barkod'; if (type === 'barcode') { // EAN-13 SVG Download const svgSerializer = new XMLSerializer(); const svgString = svgSerializer.serializeToString(elements.modalBarcodeSvg); const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = `EAN13_${barcodeVal}_${productName.toLowerCase().replace(/[^a-z0-9]/g, '_')}.svg`; a.click(); URL.revokeObjectURL(blobUrl); } else if (type === 'qrcode') { // QR Code PNG Download const canvas = elements.modalQrcodeCanvas; const dataUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = dataUrl; a.download = `QR_${barcodeVal}_${productName.toLowerCase().replace(/[^a-z0-9]/g, '_')}.png`; a.click(); } }); }); } function openDetailModal(product) { state.selectedProduct = product; // Fill text labels elements.modalProductName.textContent = product.name; elements.modalCleanName.textContent = product.cleanName || '-'; elements.modalAmountRaw.textContent = product.amountRaw || 'Miktar Belirtilmemiş'; elements.modalAmount.textContent = product.amount !== null ? product.amount : '-'; elements.modalUnit.textContent = product.unit || '-'; // Badges elements.modalProductCategory.innerHTML = product.category ? ` ${product.category}` : ' Kategorisiz'; elements.modalProductCompany.innerHTML = product.company ? ` ${product.company}` : ' Şirket Belirsiz'; // Product Image const imageSrc = product.image || `https://placehold.co/400x400/1e293b/cbd5e1?text=${encodeURIComponent(product.cleanName)}`; elements.modalProductImage.src = imageSrc; // GENERATE BARKOD (EAN-13 or Code128) const barcodeVal = product.barcode; try { // EAN-13 requires exactly 12 or 13 numerical digits. // If it's something else, we fallback to CODE128 format so it successfully renders. const isNumericOnly = /^\d+$/.test(barcodeVal); const format = (isNumericOnly && (barcodeVal.length === 12 || barcodeVal.length === 13)) ? "EAN13" : "CODE128"; JsBarcode("#modal-barcode-svg", barcodeVal, { format: format, width: 2, height: 90, displayValue: true, fontSize: 14, font: 'var(--font-mono)', background: "#ffffff", lineColor: "#000000", margin: 10 }); } catch (err) { console.error('Barcode generation error:', err); elements.modalBarcodeSvg.innerHTML = `Barkod Oluşturulamadı (${err.message})`; } // GENERATE QR CODE try { QRCode.toCanvas(elements.modalQrcodeCanvas, barcodeVal, { width: 150, margin: 1, color: { dark: "#000000", light: "#ffffff" } }, function (error) { if (error) console.error(error); }); } catch (err) { console.error('QR Code generation error:', err); } // Toggle Modal class elements.productDetailModal.classList.add('active'); }