feat: auto update

This commit is contained in:
2025-04-18 10:41:32 +03:00
parent e7e6382a10
commit bb12fe5ca7
9 changed files with 1478 additions and 1045 deletions

82
static/css/login.css Normal file
View File

@@ -0,0 +1,82 @@
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #1a1b26;
color: #ffffff;
}
.container {
width: 100%;
max-width: 400px;
margin: 100px auto;
padding: 20px;
}
.login-card {
background-color: #282a36;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
color: #ffffff;
font-size: 24px;
margin: 0;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #8be9fd;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #44475a;
border-radius: 4px;
background-color: #1a1b26;
color: #ffffff;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #6272a4;
}
.login-button {
width: 100%;
padding: 12px;
background-color: #7aa2f7;
border: none;
border-radius: 4px;
color: #ffffff;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
.login-button:hover {
background-color: #6b91e4;
}
.error-message {
background-color: #ff5555;
color: #ffffff;
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
}

387
static/css/styles.css Normal file
View File

@@ -0,0 +1,387 @@
:root {
/* Catppuccin Mocha */
--rosewater: #f5e0dc;
--flamingo: #f2cdcd;
--pink: #f5c2e7;
--mauve: #cba6f7;
--red: #f38ba8;
--maroon: #eba0ac;
--peach: #fab387;
--yellow: #f9e2af;
--green: #a6e3a1;
--teal: #94e2d5;
--sky: #89dceb;
--sapphire: #74c7ec;
--blue: #89b4fa;
--lavender: #b4befe;
--text: #cdd6f4;
--subtext1: #bac2de;
--subtext0: #a6adc8;
--overlay2: #9399b2;
--overlay1: #7f849c;
--overlay0: #6c7086;
--surface2: #585b70;
--surface1: #45475a;
--surface0: #313244;
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: var(--text);
background: var(--base);
}
h1,
h2,
h3 {
color: var(--text);
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: 0.75rem;
margin-top: 0.5rem;
border: 1px solid var(--surface1);
border-radius: 8px;
font-size: 1rem;
background: var(--surface0);
color: var(--text);
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus {
outline: none;
border-color: var(--blue);
}
/* Убираем стрелки для input number */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
button {
padding: 0.75rem 1.5rem;
background-color: var(--blue);
color: var(--base);
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
button:hover {
background-color: var(--lavender);
}
button:disabled {
background-color: var(--overlay0);
cursor: not-allowed;
}
.status {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid var(--surface1);
border-radius: 8px;
background-color: var(--surface0);
}
.error {
color: var(--red);
}
.files {
margin-top: 2rem;
}
.files ul {
list-style: none;
padding: 0;
}
.files li {
margin: 0.75rem 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border: 1px solid var(--surface1);
border-radius: 8px;
transition: all 0.2s;
background: var(--surface0);
}
.files li:hover {
background-color: var(--surface1);
}
.file-info {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1.5rem;
width: 100%;
}
.download-link {
color: var(--text);
text-decoration: none;
position: relative;
padding-left: 2rem;
margin-right: auto;
font-weight: 500;
transition: color 0.2s;
}
.download-link:hover {
color: var(--blue);
}
.download-link::before {
content: "📥";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
.file-date,
.file-size {
color: var(--subtext0);
font-size: 0.9rem;
}
.delete-btn {
background: none;
border: none;
color: var(--red);
cursor: pointer;
padding: 0.5rem;
font-size: 1.2rem;
transition: all 0.2s;
}
.delete-btn:hover {
color: var(--maroon);
}
/* Стилі для вкладок */
.tabs {
margin-bottom: 20px;
}
.tab-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
justify-content: flex-start;
}
.tab-button {
height: 44px;
padding: 0 24px;
background-color: var(--surface0);
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: var(--subtext0);
transition: all 0.2s;
}
.tab-button:hover {
background-color: var(--surface1);
color: var(--text);
}
.tab-button.active {
background-color: var(--blue);
color: var(--base);
}
.tab-content {
display: none;
background: var(--surface0);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.tab-content.active {
display: block;
}
.file-select {
width: 100%;
padding: 0.75rem;
margin-top: 0.5rem;
border: 1px solid var(--surface1);
border-radius: 8px;
font-size: 1rem;
background: var(--surface0);
color: var(--text);
transition: border-color 0.2s;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23cdd6f4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1em;
}
.file-select:focus {
outline: none;
border-color: var(--blue);
}
.items-limit-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
height: 44px;
}
.items-limit-group label {
color: var(--subtext0);
font-size: 14px;
}
#items-limit {
margin: 0;
text-align: center;
max-width: 4rem;
}
.items-limit-group input[type="number"] {
height: 44px;
width: 4rem;
padding: 0 0.75rem;
flex-shrink: 0;
text-align: center;
}
.categories-section {
margin-bottom: 2rem;
background: var(--surface0);
border-radius: 8px;
}
.category-form {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1rem;
}
.category-form .form-group {
margin-bottom: 0;
}
.category-form button {
flex: 0 0 auto;
}
.secondary-button {
background-color: var(--surface1);
color: var(--text);
}
.secondary-button:hover {
background-color: var(--surface2);
}
.categories-list ul {
list-style: none;
padding: 0;
}
.categories-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin: 0.5rem 0;
background: var(--surface1);
border-radius: 4px;
}
.yml-generation-section {
margin-bottom: 2rem;
background: var(--surface0);
border-radius: 8px;
}
.loading {
position: relative;
pointer-events: none;
opacity: 0.7;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid var(--blue);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem;
background: var(--red);
color: var(--base);
border-radius: 8px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}

530
static/js/main.js Normal file
View File

@@ -0,0 +1,530 @@
let statusCheckInterval;
function startStatusCheck() {
// Очищаем предыдущий интервал, если он существует
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
}
// Проверяем статус каждые 500мс
statusCheckInterval = setInterval(checkStatus, 500);
// Сразу делаем первую проверку
checkStatus();
}
function checkStatus() {
fetch('/status')
.then(response => response.json())
.then(data => {
const status = document.getElementById('status');
const button = document.getElementById('parseButton');
if (data.is_running) {
let statusHtml = `
<p>
Парсинг запущено для категорії: ${data.current_category}<br>
`;
if (data.total_products > 0) {
statusHtml += `
<strong>Товар ${data.current_product} із ${data.total_products}</strong><br>
Прогрес: ${data.progress}%
`;
} else {
statusHtml += `Отримання інформації про кількість товарів...`;
}
statusHtml += `</p>`;
status.innerHTML = statusHtml;
setTimeout(checkStatus, 500);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_products > 0) {
status.innerHTML = `
<p>
Парсинг завершено<br>
<strong>Всього оброблено товарів: ${data.total_products}</strong>
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
button.disabled = false;
});
}
function stopStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
statusCheckInterval = null;
}
}
function deleteFile(filename, type) {
if (confirm('Ви впевнені, що хочете видалити цей файл?')) {
fetch(`/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
} else {
// Удаляем файл из списка
const fileItem = document.querySelector(`a[href*="${filename}"]`).closest('li');
fileItem.remove();
// Обновляем соответствующий select в зависимости от типа файла
if (type === 'parsed') {
const fileSelect = document.getElementById('file-select');
const optionToRemove = Array.from(fileSelect.options).find(opt => opt.value === filename);
if (optionToRemove) {
optionToRemove.remove();
}
} else if (type === 'translated') {
const ymlFileSelect = document.getElementById('yml-file-select');
const optionToRemove = Array.from(ymlFileSelect.options).find(opt => opt.value === filename);
if (optionToRemove) {
optionToRemove.remove();
}
}
}
})
.catch(error => {
console.error('Error:', error);
alert('Помилка при видаленні файлу');
});
}
}
function openTab(tabName) {
// Приховуємо всі вкладки
const tabContents = document.getElementsByClassName('tab-content');
for (let content of tabContents) {
content.classList.remove('active');
}
// Деактивуємо всі кнопки
const tabButtons = document.getElementsByClassName('tab-button');
for (let button of tabButtons) {
button.classList.remove('active');
}
// Показуємо вибрану вкладку
document.getElementById(tabName).classList.add('active');
// Активуємо потрібну кнопку
event.currentTarget.classList.add('active');
}
function startTranslation() {
const fileSelect = document.getElementById('file-select');
const button = document.getElementById('translateButton');
const status = document.getElementById('translation-status');
if (!fileSelect.value) {
status.innerHTML = '<p class="error">Будь ласка, виберіть файл для обробки</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо переклад...</p>';
fetch('/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `filename=${encodeURIComponent(fileSelect.value)}`
})
.then(response => response.json())
.then(data => {
if (data.error) {
status.innerHTML = `<p class="error">${data.error}</p>`;
button.disabled = false;
} else {
checkTranslationStatus();
}
});
}
function checkTranslationStatus() {
fetch('/translation-status')
.then(response => response.json())
.then(data => {
const status = document.getElementById('translation-status');
const button = document.getElementById('translateButton');
if (data.is_running) {
if (data.total_items > 0) {
const percent = Math.round((data.processed_items / data.total_items) * 100);
status.innerHTML = `
<p>
Переклад в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Підготовка до перекладу...</p>';
}
setTimeout(checkTranslationStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else if (data.total_items > 0) {
status.innerHTML = `
<p>
Переклад завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('translated', 'processor', 'yml-file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Translation status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
document.getElementById('translateButton').disabled = false;
});
}
function generateFullYML() {
if (!confirm("Згенерувати загальний YML-файл для всіх категорій?")) return;
fetch('/generate-full-yml', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
alert("Повний YML успішно згенеровано");
updateFilesList('yml', 'generator');
} else {
alert("Помилка: " + (data.error || "Невідома помилка"));
}
});
}
function updateItemsLimit(value) {
fetch('/update-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items_limit: parseInt(value)
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
}
});
}
function addCategory() {
const categoryName = document.getElementById('category-name').value;
const portalId = document.getElementById('portal-id').value;
const categoryUrl = document.getElementById('category-url').value;
if (!categoryName || !categoryUrl) {
alert('Будь ласка, заповніть назву і URL категорії');
return;
}
fetch('/add-category', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: categoryName,
portal_id: portalId,
url: categoryUrl
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
} else {
const newId = data.id;
document.getElementById('category-name').value = '';
document.getElementById('portal-id').value = '';
document.getElementById('category-url').value = '';
const categoriesList = document.getElementById('categories-list');
const li = document.createElement('li');
li.title = categoryUrl;
li.innerHTML = `
<span>${newId} - ${categoryName} ${portalId ? `(portal_id: ${portalId})` : ''}</span>
<button class="delete-btn" onclick="deleteCategory('${newId}')" title="Видалити категорію">🗑️</button>
`;
categoriesList.appendChild(li);
const ymlSelect = document.getElementById('yml-category-select');
const option = document.createElement('option');
option.value = newId;
option.text = categoryName;
ymlSelect.appendChild(option);
}
});
}
function deleteCategory(categoryId) {
if (confirm('Ви впевнені, що хочете видалити цю категорію?')) {
fetch('/delete-category', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: categoryId
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
alert(data.error);
} else {
// Удаляем категорию из списка
const categoryItems = document.querySelectorAll('#categories-list li');
categoryItems.forEach(item => {
if (item.querySelector('span').textContent.startsWith(categoryId + ' -')) {
item.remove();
}
});
// Удаляем опцию из select в генераторе YML
const ymlSelect = document.getElementById('yml-category-select');
const option = Array.from(ymlSelect.options).find(opt => opt.value === categoryId);
if (option) option.remove();
}
});
}
}
function generateYML() {
console.log('generateYML called'); // Отладка
const categoryId = document.getElementById('yml-category-select').value;
const fileSelect = document.getElementById('yml-file-select');
console.log('Selected category:', categoryId); // Отладка
console.log('Selected file:', fileSelect.value); // Отладка
const button = document.getElementById('generateButton');
const status = document.getElementById('yml-status');
if (!categoryId || !fileSelect.value) {
status.innerHTML = '<p class="error">Будь ласка, виберіть категорію та файл</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Генерація YML...</p>';
fetch('/generate-yml', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: fileSelect.value,
category_id: categoryId
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
status.innerHTML = `<p class="error">${data.error}</p>`;
} else {
status.innerHTML = '<p>YML файл успішно згенеровано</p>';
updateFilesList('yml', 'generator', null);
}
button.disabled = false;
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
button.disabled = false;
});
}
// Добавляем вспомогательную функцию для поиска по тексту
jQuery.expr[':'].contains = function (a, i, m) {
return jQuery(a).text().toUpperCase()
.indexOf(m[3].toUpperCase()) >= 0;
};
function updateFilesList(fileType, containerId, selectId = null) {
fetch(`/get-files/${fileType}`)
.then(response => response.json())
.then(files => {
// Обновляем список файлов
const filesList = document.querySelector(`#${containerId} .files ul`);
filesList.innerHTML = files.map(file => `
<li>
<div class="file-info">
<a href="/download/${file.name}" class="download-link" download>
${file.name}
</a>
<span class="file-date">${file.modified}</span>
<span class="file-size">${file.size}</span>
</div>
<button class="delete-btn" onclick="deleteFile('${file.name}', '${fileType}')" title="Видалити файл">
🗑️
</button>
</li>
`).join('');
// Обновляем select, если указан
if (selectId) {
const select = document.getElementById(selectId);
select.innerHTML = '<option value="">Виберіть файл...</option>' +
files.map(file => `
<option value="${file.name}">${file.name}</option>
`).join('');
}
});
}
function showLoader(element) {
element.classList.add('loading');
}
function hideLoader(element) {
element.classList.remove('loading');
}
function showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
document.body.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 3000);
}
// Использование:
async function someAction() {
const element = document.getElementById('someElement');
showLoader(element);
try {
const response = await fetch('/some-endpoint');
const data = await response.json();
if (data.error) throw new Error(data.error);
// обработка успешного ответа
} catch (error) {
showError(error.message);
} finally {
hideLoader(element);
}
}
function translateAllCategories() {
if (!confirm("Запустити переклад для всіх категорій?")) return;
fetch('/manual-translate-all', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.message) {
alert(data.message);
} else {
alert("Переклад запущено.");
}
})
.catch(error => {
alert("Помилка при запуску перекладу: " + error.message);
});
}
function startParsing() {
const url = document.getElementById('url').value;
const button = document.getElementById('parseButton');
const status = document.getElementById('status');
if (!url) {
status.innerHTML = '<p class="error">Будь ласка, введіть URL</p>';
return;
}
button.disabled = true;
status.innerHTML = '<p>Починаємо парсинг...</p>';
fetch('/parse', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `url=${encodeURIComponent(url)}`
})
.then(response => response.json())
.then(data => {
if (data.error) {
status.innerHTML = `<p class="error">${data.error}</p>`;
button.disabled = false;
} else {
checkParsingStatus();
}
})
.catch(error => {
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
button.disabled = false;
});
}
function checkParsingStatus() {
fetch('/status')
.then(response => response.json())
.then(data => {
const status = document.getElementById('status');
const button = document.getElementById('parseButton');
if (data.is_running) {
if (data.total_items > 0) {
const percent = Math.round((data.processed_items / data.total_items) * 100);
status.innerHTML = `
<p>
Парсинг в процесі...<br>
Оброблено: ${data.processed_items} з ${data.total_items}<br>
Прогрес: ${percent}%
</p>
`;
} else {
status.innerHTML = '<p>Отримання інформації про товари...</p>';
}
setTimeout(checkParsingStatus, 1000);
} else {
if (data.error) {
status.innerHTML = `<p class="error">Помилка: ${data.error}</p>`;
} else {
status.innerHTML = `
<p>
Парсинг завершено<br>
Всього оброблено товарів: ${data.total_items}
</p>
`;
updateFilesList('parsed', 'parser', 'file-select');
}
button.disabled = false;
}
})
.catch(error => {
console.error('Status check error:', error);
status.innerHTML = `<p class="error">Помилка: ${error.message}</p>`;
document.getElementById('parseButton').disabled = false;
});
}
function refreshOldestCategory() {
fetch('/manual-refresh-all', {
method: 'POST'
})
}