| Current Path : /home/seto/indexator.pm/public/js/ |
| Current File : /home/seto/indexator.pm/public/js/app.js |
/* Indexator — app.js */
document.addEventListener('DOMContentLoaded', function () {
// ── Bootstrap tooltips ──────────────────────────────────────────────────
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
new bootstrap.Tooltip(el);
});
// ── Progress bar polling ─────────────────────────────────────────────────
const progressBar = document.getElementById('indexator-progress-bar');
if (progressBar) {
const projectId = progressBar.dataset.projectId;
const pollInterval = 3000; // ms
let timer = null;
// Badge HTML helpers (mirrors PHP statusBadge)
function badgeHtml(status) {
const map = {
'indexed': ['bg-success', 'indexed'],
'not_indexed': ['bg-warning text-dark', 'not indexed'],
'error': ['bg-danger', 'error'],
'pending': ['bg-secondary', 'queued'],
};
const [cls, label] = map[status] || ['bg-light text-secondary border', '—'];
return '<span class="badge ' + cls + '">' + label + '</span>';
}
function checkingBadgeHtml() {
return '<span class="badge-checking">'
+ '<span class="spinner-border spinner-border-sm" style="width:.6rem;height:.6rem;border-width:2px"></span>'
+ ' checking</span>';
}
function updateProgress() {
fetch('/api/projects/' + projectId + '/progress')
.then(function (res) { return res.json(); })
.then(function (data) {
const pct = data.percentage || 0;
// Update bar
progressBar.style.width = pct + '%';
progressBar.textContent = pct + '%';
progressBar.setAttribute('aria-valuenow', pct);
// Update stat counters if present
setCounter('stat-indexed', data.indexed);
setCounter('stat-not-indexed', data.not_indexed);
setCounter('stat-error', data.error);
setCounter('stat-pending', data.pending);
setCounter('stat-checked-pct', pct + '%');
// ── Animate rows being checked ────────────────────────────
const pendingSet = new Set((data.pending_ids || []).map(Number));
// Show checking badge on pending rows
pendingSet.forEach(function (id) {
const cell = document.getElementById('status-' + id);
if (cell && !cell.dataset.checking) {
cell.innerHTML = checkingBadgeHtml();
cell.dataset.checking = '1';
}
});
// Update rows that were recently checked
(data.recent_updates || []).forEach(function (u) {
const cell = document.getElementById('status-' + u.id);
if (cell) {
cell.innerHTML = badgeHtml(u.status);
delete cell.dataset.checking;
}
});
// Remove checking animation from rows no longer pending
document.querySelectorAll('[data-checking]').forEach(function (cell) {
const id = Number(cell.id.replace('status-', ''));
if (!pendingSet.has(id)) {
// Status unknown until reload — show queued badge
cell.innerHTML = badgeHtml('pending');
delete cell.dataset.checking;
}
});
if (data.project_status !== 'checking') {
clearInterval(timer);
window.location.reload();
}
})
.catch(function () { /* silently ignore network errors */ });
}
timer = setInterval(updateProgress, pollInterval);
}
function setCounter(id, value) {
const el = document.getElementById(id);
if (el && value !== undefined) el.textContent = value;
}
// ── Recheck/Submit selected + select-all-across-pages ────────────────────
const recheckSelectedBtn = document.getElementById('recheck-selected-btn');
const submitSelectedIndexingBtn = document.getElementById('submit-selected-indexing-btn');
if (recheckSelectedBtn || submitSelectedIndexingBtn) {
const urlCheckboxes = document.querySelectorAll('.url-checkbox');
const selectAllCheckbox = document.getElementById('select-all-urls');
const banner = document.getElementById('select-all-pages-banner');
const bannerText = document.getElementById('select-all-pages-text');
const selectAllPagesBtn = document.getElementById('select-all-pages-btn');
const clearAllBtn = document.getElementById('clear-all-selection-btn');
let allPagesSelected = false;
function getCheckedCount() {
return Array.from(urlCheckboxes).filter(cb => cb.checked).length;
}
function updateSelectionState() {
const checkedCount = getCheckedCount();
const anyChecked = checkedCount > 0 || allPagesSelected;
if (recheckSelectedBtn) recheckSelectedBtn.disabled = !anyChecked || allPagesSelected;
if (submitSelectedIndexingBtn) submitSelectedIndexingBtn.disabled = !anyChecked;
// show/hide banner
if (banner) {
const allOnPage = urlCheckboxes.length > 0 && checkedCount === urlCheckboxes.length;
if (allPagesSelected) {
const total = submitSelectedIndexingBtn ? parseInt(submitSelectedIndexingBtn.dataset.total || '0', 10) : 0;
bannerText.textContent = 'All ' + total + ' URLs across all pages are selected.';
if (selectAllPagesBtn) selectAllPagesBtn.style.display = 'none';
banner.style.display = '';
} else if (allOnPage && selectAllPagesBtn) {
bannerText.textContent = 'All ' + checkedCount + ' URLs on this page are selected.';
selectAllPagesBtn.style.display = '';
banner.style.display = '';
} else {
banner.style.display = 'none';
}
}
}
urlCheckboxes.forEach(function (cb) {
cb.addEventListener('change', function () {
allPagesSelected = false;
updateSelectionState();
});
});
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function () {
allPagesSelected = false;
urlCheckboxes.forEach(cb => { cb.checked = selectAllCheckbox.checked; });
updateSelectionState();
});
}
if (selectAllPagesBtn) {
selectAllPagesBtn.addEventListener('click', function () {
allPagesSelected = true;
updateSelectionState();
});
}
if (clearAllBtn) {
clearAllBtn.addEventListener('click', function () {
allPagesSelected = false;
urlCheckboxes.forEach(cb => { cb.checked = false; });
if (selectAllCheckbox) selectAllCheckbox.checked = false;
updateSelectionState();
});
}
updateSelectionState();
// Expose for the indexing modal
window._indexingSelection = {
getAllPagesSelected: function () { return allPagesSelected; },
getCheckedIds: function () {
return Array.from(urlCheckboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
},
};
}
// ── Stop all jobs (AJAX POST) ────────────────────────────────────────────
const stopBtn = document.getElementById('stop-jobs-btn');
if (stopBtn) {
stopBtn.addEventListener('click', function () {
if (!confirm('Остановить проверку? Все незавершённые задачи будут удалены.')) return;
const projectId = stopBtn.dataset.projectId;
const csrf = document.querySelector('meta[name="csrf-token"]').content;
stopBtn.disabled = true;
stopBtn.textContent = 'Stopping…';
fetch('/api/projects/' + projectId + '/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf),
})
.then(function (res) { return res.json(); })
.then(function () {
window.location.reload();
})
.catch(function () {
alert('Не удалось остановить. Попробуйте ещё раз.');
stopBtn.disabled = false;
stopBtn.textContent = '■ Stop';
});
});
}
// ── Recheck ALL projects (modal + AJAX POST) ─────────────────────────────
const recheckAllBtn = document.getElementById('recheck-all-btn');
if (recheckAllBtn) {
const recheckAllModalEl = document.getElementById('recheckAllModal');
const recheckAllModal = recheckAllModalEl ? new bootstrap.Modal(recheckAllModalEl) : null;
recheckAllBtn.addEventListener('click', function () {
if (recheckAllModal) {
recheckAllModal.show();
}
});
if (recheckAllModalEl) {
recheckAllModalEl.addEventListener('click', function (e) {
const btn = e.target.closest('.recheck-all-confirm');
if (!btn) return;
const scope = btn.dataset.scope;
const csrf = recheckAllBtn.dataset.csrf;
recheckAllModal.hide();
recheckAllBtn.disabled = true;
recheckAllBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Starting…';
fetch('/api/projects/recheck-all', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf) + '&scope=' + encodeURIComponent(scope),
})
.then(function (res) { return res.json(); })
.then(function () { window.location.reload(); })
.catch(function () {
alert('Не удалось запустить. Попробуйте ещё раз.');
recheckAllBtn.disabled = false;
recheckAllBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Recheck All';
});
});
}
}
// ── Recheck single project (AJAX POST) ──────────────────────────────────
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-recheck-project');
if (!btn) return;
const projectId = btn.dataset.projectId;
const csrf = document.querySelector('meta[name="csrf-token"]').content;
btn.disabled = true;
btn.querySelector('i').className = 'bi bi-arrow-clockwise spin';
fetch('/api/projects/' + projectId + '/recheck', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf),
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.error) {
alert(data.error);
btn.disabled = false;
btn.querySelector('i').className = 'bi bi-arrow-clockwise';
} else {
window.location.reload();
}
})
.catch(function () {
alert('Не удалось запустить. Попробуйте ещё раз.');
btn.disabled = false;
btn.querySelector('i').className = 'bi bi-arrow-clockwise';
});
});
// ── Stop ALL projects (AJAX POST) ───────────────────────────────────────
const stopAllBtn = document.getElementById('stop-all-btn');
if (stopAllBtn) {
stopAllBtn.addEventListener('click', function () {
if (!confirm('Остановить проверку по всем проектам? Все незавершённые задачи будут удалены.')) return;
const csrf = stopAllBtn.dataset.csrf;
stopAllBtn.disabled = true;
stopAllBtn.textContent = 'Stopping…';
fetch('/api/projects/stop-all', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf),
})
.then(function (res) { return res.json(); })
.then(function () { window.location.reload(); })
.catch(function () {
alert('Не удалось остановить. Попробуйте ещё раз.');
stopAllBtn.disabled = false;
stopAllBtn.textContent = '■ Stop All';
});
});
}
// ── Reset stuck jobs (AJAX POST) ─────────────────────────────────────────
const resetBtn = document.getElementById('reset-stuck-jobs-btn');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
const projectId = resetBtn.dataset.projectId;
const csrf = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/projects/' + projectId + '/reset-jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf),
})
.then(function (res) { return res.json(); })
.then(function (data) {
alert('Reset ' + data.reset + ' stuck job(s). The page will reload.');
window.location.reload();
})
.catch(function () {
alert('Failed to reset jobs. Please try again.');
});
});
}
// ── Indexing submit modal ─────────────────────────────────────────────────
const indexingModalEl = document.getElementById('indexingModal');
if (indexingModalEl) {
const indexingModal = new bootstrap.Modal(indexingModalEl);
const submitBtn = document.getElementById('indexing-submit-btn');
const resultDiv = document.getElementById('indexing-result');
const warningDiv = document.getElementById('indexing-warning');
const urlCountEl = document.getElementById('indexing-url-count');
const urlLabelEl = document.getElementById('indexing-url-label');
let currentProject = null; // { id, name, mode, urlIds }
const csrf = document.querySelector('meta[name="csrf-token"]').content;
function openIndexingModal(projectId, projectName, mode, urlIds, urlCount) {
currentProject = { id: projectId, name: projectName, mode: mode, urlIds: urlIds || [] };
document.getElementById('indexing-project-name').textContent = projectName;
if (urlCountEl) urlCountEl.textContent = urlCount !== undefined ? urlCount : '…';
if (urlLabelEl) {
urlLabelEl.textContent = mode === 'all' ? '(all URLs)'
: mode === 'selected' ? '(selected)'
: '(not indexed)';
}
warningDiv.style.display = 'none';
resultDiv.style.display = 'none';
submitBtn.disabled = false;
submitBtn.textContent = '⚡ Submit';
submitBtn.onclick = null;
// Check previous submission
fetch('/api/projects/' + projectId + '/indexing')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.last) {
const d = new Date(data.last.created_at.replace(' ', 'T') + 'Z');
const days = Math.round((Date.now() - d) / 86400000);
const ago = days === 0 ? 'today' : days + 'd ago';
warningDiv.innerHTML = '⚠ Already submitted <strong>' + data.last.urls_count
+ ' URLs</strong> ' + ago + ' (' + data.last.engines.toUpperCase()
+ ') — status: <strong>' + data.last.status + '</strong>. Submit again?';
warningDiv.style.display = '';
}
})
.catch(function () {});
indexingModal.show();
}
// ⚡ Submit to index (not_indexed mode — existing button)
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-index-project');
if (!btn) return;
openIndexingModal(
btn.dataset.projectId,
btn.dataset.projectName,
'not_indexed',
[],
parseInt(btn.dataset.notIndexed || '0', 10)
);
});
// ⚡ Submit Selected to Index
const submitSelectedBtn = document.getElementById('submit-selected-indexing-btn');
if (submitSelectedBtn) {
submitSelectedBtn.addEventListener('click', function () {
const sel = window._indexingSelection;
const allPages = sel && sel.getAllPagesSelected();
const ids = sel && !allPages ? sel.getCheckedIds() : [];
const mode = allPages ? 'all' : 'selected';
const count = allPages
? parseInt(submitSelectedBtn.dataset.total || '0', 10)
: ids.length;
openIndexingModal(
submitSelectedBtn.dataset.projectId,
submitSelectedBtn.dataset.projectName,
mode,
ids,
count
);
});
}
submitBtn.addEventListener('click', function () {
if (!currentProject) return;
const engines = [];
if (document.getElementById('engine-google').checked) engines.push('google');
if (document.getElementById('engine-bing').checked) engines.push('bing');
if (engines.length === 0) {
resultDiv.innerHTML = '<div class="alert alert-warning mb-0">Select at least one engine.</div>';
resultDiv.style.display = '';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting…';
resultDiv.style.display = 'none';
let body = '_csrf=' + encodeURIComponent(csrf)
+ '&mode=' + encodeURIComponent(currentProject.mode)
+ engines.map(function (e) { return '&engines[]=' + e; }).join('');
if (currentProject.mode === 'selected' && currentProject.urlIds.length > 0) {
body += currentProject.urlIds.map(function (id) { return '&url_ids[]=' + encodeURIComponent(id); }).join('');
}
fetch('/api/projects/' + currentProject.id + '/submit-indexing', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body,
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
resultDiv.innerHTML = '<div class="alert alert-danger mb-0">' + data.error + '</div>';
} else {
const errs = data.errors && data.errors.length
? '<br><small class="text-warning">Warnings: ' + data.errors.join('; ') + '</small>'
: '';
resultDiv.innerHTML = '<div class="alert alert-success mb-0">'
+ '✓ Submitted <strong>' + data.urls_count + ' URLs</strong>'
+ ' in <strong>' + data.batch_count + ' batch(es)</strong>'
+ ' to <strong>' + data.engines.join(', ').toUpperCase() + '</strong>.'
+ '<br><small class="text-muted">Task IDs: ' + data.task_ids.join(', ') + '</small>'
+ errs
+ '</div>';
submitBtn.textContent = '✓ Done';
submitBtn.disabled = false;
submitBtn.onclick = function () { indexingModal.hide(); };
warningDiv.style.display = 'none';
}
resultDiv.style.display = '';
})
.catch(function () {
resultDiv.innerHTML = '<div class="alert alert-danger mb-0">Request failed.</div>';
resultDiv.style.display = '';
submitBtn.disabled = false;
submitBtn.textContent = '⚡ Submit';
});
});
}
// ── Refresh indexing submission status ───────────────────────────────────
document.addEventListener('click', function (e) {
const btn = e.target.closest('.refresh-indexing-btn');
if (!btn) return;
const submissionId = btn.dataset.submissionId;
const csrf = document.querySelector('meta[name="csrf-token"]').content;
btn.disabled = true;
btn.textContent = '…';
fetch('/api/indexing/' + submissionId + '/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_csrf=' + encodeURIComponent(csrf),
})
.then(function (r) { return r.json(); })
.then(function (data) {
btn.textContent = data.status || 'done';
btn.disabled = false;
// Update badge in row
const row = btn.closest('tr');
const badge = row ? row.querySelector('.badge') : null;
if (badge && data.status) {
const cls = { complete: 'bg-success', failed: 'bg-danger', partial: 'bg-warning text-dark' };
badge.className = 'badge ' + (cls[data.status] || 'bg-secondary');
badge.textContent = data.status;
}
})
.catch(function () {
btn.textContent = '↻ Refresh';
btn.disabled = false;
});
});
// ── Check IndexingBot balance (settings page) ────────────────────────────
const checkBalanceBtn = document.getElementById('check-balance-btn');
if (checkBalanceBtn) {
checkBalanceBtn.addEventListener('click', function () {
const resultEl = document.getElementById('balance-result');
checkBalanceBtn.disabled = true;
checkBalanceBtn.textContent = '…';
fetch('/api/indexing/balance')
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error || data.status >= 400) {
resultEl.className = 'alert alert-danger py-2 small mb-3';
resultEl.textContent = 'Error: ' + (data.error || data.msg);
} else {
const balance = parseFloat(data.data.balance || 0).toFixed(2);
const balanceRef = parseFloat(data.data.balance_ref || 0).toFixed(2);
resultEl.className = 'alert alert-success py-2 small mb-3';
resultEl.textContent = 'Баланс: ' + balance + ' ₽'
+ (balanceRef > 0 ? ' + ' + balanceRef + ' ₽ (реф.)' : '');
}
resultEl.style.display = '';
})
.catch(function () {
resultEl.className = 'alert alert-danger py-2 small mb-3';
resultEl.textContent = 'Request failed.';
resultEl.style.display = '';
})
.finally(function () {
checkBalanceBtn.disabled = false;
checkBalanceBtn.textContent = 'Check balance';
});
});
}
// ── Project sort ─────────────────────────────────────────────────────────
const sortSelect = document.getElementById('project-sort');
if (sortSelect) {
const projectsList = document.getElementById('projects-list');
const SORT_KEY = 'indexator_project_sort';
function sortCards(value) {
const cards = Array.from(projectsList.querySelectorAll('.project-card'));
cards.sort(function (a, b) {
switch (value) {
case 'name_asc':
return a.dataset.name.localeCompare(b.dataset.name);
case 'name_desc':
return b.dataset.name.localeCompare(a.dataset.name);
case 'total_desc':
return parseInt(b.dataset.total) - parseInt(a.dataset.total);
case 'rate_asc':
return parseInt(a.dataset.rate) - parseInt(b.dataset.rate);
case 'created_desc':
return parseInt(b.dataset.created) - parseInt(a.dataset.created);
case 'submitted_desc': {
const ta = parseInt(a.dataset.submitted);
const tb = parseInt(b.dataset.submitted);
// Never-submitted go to the end
if (ta === 0 && tb === 0) return 0;
if (ta === 0) return 1;
if (tb === 0) return -1;
return tb - ta;
}
default:
return 0;
}
});
cards.forEach(function (c) { projectsList.appendChild(c); });
}
// Restore saved sort
const saved = localStorage.getItem(SORT_KEY);
if (saved) {
sortSelect.value = saved;
}
sortCards(sortSelect.value);
sortSelect.addEventListener('change', function () {
localStorage.setItem(SORT_KEY, this.value);
sortCards(this.value);
});
}
// ── Project search ───────────────────────────────────────────────────────
const searchInput = document.getElementById('project-search');
if (searchInput) {
const summaryCard = document.getElementById('summary-card');
const cards = document.querySelectorAll('.project-card');
const noResults = document.getElementById('no-search-results');
searchInput.addEventListener('input', function () {
const q = this.value.trim().toLowerCase();
if (q === '') {
cards.forEach(function (c) { c.style.display = ''; });
if (summaryCard) summaryCard.style.display = '';
noResults.style.display = 'none';
return;
}
if (summaryCard) summaryCard.style.display = 'none';
let visible = 0;
cards.forEach(function (c) {
const match = c.dataset.name.includes(q) || c.dataset.domain.includes(q);
c.style.display = match ? '' : 'none';
if (match) visible++;
});
noResults.style.display = visible === 0 ? '' : 'none';
});
}
});