c34a42aaf5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
502 lines
27 KiB
HTML
502 lines
27 KiB
HTML
{{define "base"}}
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{template "title" .}}</title>
|
|
<link rel="stylesheet" href="/static/app.css">
|
|
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
|
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
|
|
<style>
|
|
.htmx-request { opacity: 0.5; }
|
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-100 min-h-screen">
|
|
<nav class="bg-white shadow-sm sticky top-0 z-40">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between h-14">
|
|
<div class="flex items-center space-x-8">
|
|
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
|
<div class="hidden md:flex space-x-4">
|
|
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
|
|
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
|
|
<a href="/partnumber-books" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Партномера</a>
|
|
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<!-- Sync Status Indicator (htmx-powered) -->
|
|
<div id="sync-status"
|
|
class="flex items-center gap-3 text-sm"
|
|
hx-get="/partials/sync-status"
|
|
hx-trigger="load, refresh from:body, every 30s"
|
|
hx-swap="innerHTML">
|
|
</div>
|
|
<span id="db-user" class="text-sm text-gray-600"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
{{template "content" .}}
|
|
</main>
|
|
|
|
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 text-right">
|
|
<span class="text-xs text-gray-400">v{{.AppVersion}}</span>
|
|
</footer>
|
|
|
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
|
|
|
<!-- Sync Info Modal -->
|
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
|
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
|
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
|
<div class="flex justify-between items-center">
|
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
|
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto flex-1">
|
|
<div class="p-6 space-y-5">
|
|
<!-- Section 1: DB Connection -->
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
|
<div class="text-sm space-y-1">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">Адрес:</span>
|
|
<span id="modal-db-host" class="text-gray-700 font-mono">—</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">Пользователь:</span>
|
|
<span id="modal-db-user" class="text-gray-700">—</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">Статус:</span>
|
|
<span id="modal-db-status" class="text-gray-700">Проверка...</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">Последняя синхронизация:</span>
|
|
<span id="modal-last-sync" class="text-gray-700">—</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal-readiness-section" class="hidden">
|
|
<h4 class="font-medium text-red-700 mb-2">Почему синхронизация недоступна</h4>
|
|
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm">
|
|
<div id="modal-readiness-reason" class="text-red-700">—</div>
|
|
<div id="modal-readiness-min-version" class="text-red-600 text-xs mt-1 hidden"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal-pricelist-sync-issue" class="hidden">
|
|
<h4 class="font-medium text-red-700 mb-2">Состояние прайслистов</h4>
|
|
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm space-y-1">
|
|
<div id="modal-pricelist-sync-summary" class="text-red-700">—</div>
|
|
<div id="modal-pricelist-sync-attempt" class="text-red-600 text-xs hidden"></div>
|
|
<div id="modal-pricelist-sync-error" class="text-red-600 text-xs hidden whitespace-pre-wrap"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 2: Statistics -->
|
|
<div>
|
|
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
|
<span class="text-gray-500">Компоненты (lot):</span>
|
|
<span id="modal-lot-count" class="font-medium text-gray-700">—</span>
|
|
</div>
|
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
|
<span class="text-gray-500">Котировки:</span>
|
|
<span id="modal-lotlog-count" class="font-medium text-gray-700">—</span>
|
|
</div>
|
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
|
<span class="text-gray-500">Конфигурации:</span>
|
|
<span id="modal-config-count" class="font-medium text-gray-700">—</span>
|
|
</div>
|
|
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
|
<span class="text-gray-500">Проекты:</span>
|
|
<span id="modal-project-count" class="font-medium text-gray-700">—</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 3: Pending Changes (shown only if any) -->
|
|
<div id="modal-pending-section" class="hidden">
|
|
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
|
|
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
|
</div>
|
|
|
|
<!-- Section 4: Errors (shown only if any) -->
|
|
<div id="modal-errors-section" class="hidden">
|
|
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
|
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
|
</div>
|
|
|
|
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
|
<div id="modal-repair-section" class="hidden">
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
|
<p class="text-sm text-blue-700 mb-3">
|
|
Система может исправить данные и очистить ошибки синхронизации:
|
|
</p>
|
|
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
|
<li>Проверит и исправит названия проектов</li>
|
|
<li>Восстановит битые ссылки на проекты</li>
|
|
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
|
</ul>
|
|
<button id="repair-button" onclick="repairPendingChanges()"
|
|
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
ИСПРАВИТЬ
|
|
</button>
|
|
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
|
<div class="flex justify-end">
|
|
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
|
Закрыть
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function showToast(msg, type) {
|
|
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
|
const el = document.getElementById('toast');
|
|
el.innerHTML = '<div class="' + (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow">' + msg + '</div>';
|
|
setTimeout(() => el.innerHTML = '', 3000);
|
|
}
|
|
|
|
// Open sync modal
|
|
function openSyncModal() {
|
|
const modal = document.getElementById('sync-modal');
|
|
if (modal) {
|
|
modal.classList.remove('hidden');
|
|
// Load sync info when modal opens
|
|
loadSyncInfo();
|
|
}
|
|
}
|
|
|
|
// Close sync modal
|
|
function closeSyncModal() {
|
|
const modal = document.getElementById('sync-modal');
|
|
if (modal) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Load sync info for modal
|
|
async function loadSyncInfo() {
|
|
try {
|
|
const resp = await fetch('/api/sync/info');
|
|
const data = await resp.json();
|
|
|
|
// Section 1: DB Connection
|
|
document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
|
|
document.getElementById('modal-db-user').textContent = data.db_user || '—';
|
|
|
|
const statusEl = document.getElementById('modal-db-status');
|
|
if (data.is_online) {
|
|
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
|
|
} else {
|
|
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
|
|
}
|
|
|
|
if (data.last_sync_at) {
|
|
const date = new Date(data.last_sync_at);
|
|
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
|
} else {
|
|
document.getElementById('modal-last-sync').textContent = '—';
|
|
}
|
|
|
|
const readinessSection = document.getElementById('modal-readiness-section');
|
|
const readinessReason = document.getElementById('modal-readiness-reason');
|
|
const readinessMinVersion = document.getElementById('modal-readiness-min-version');
|
|
if (data.readiness && data.readiness.blocked) {
|
|
readinessSection.classList.remove('hidden');
|
|
readinessReason.textContent = data.readiness.reason_text || 'Синхронизация заблокирована preflight-проверкой.';
|
|
if (data.readiness.required_min_app_version) {
|
|
readinessMinVersion.classList.remove('hidden');
|
|
readinessMinVersion.textContent = 'Требуется обновление до версии ' + data.readiness.required_min_app_version;
|
|
} else {
|
|
readinessMinVersion.classList.add('hidden');
|
|
readinessMinVersion.textContent = '';
|
|
}
|
|
} else {
|
|
readinessSection.classList.add('hidden');
|
|
readinessReason.textContent = '';
|
|
readinessMinVersion.classList.add('hidden');
|
|
readinessMinVersion.textContent = '';
|
|
}
|
|
|
|
const syncIssueSection = document.getElementById('modal-pricelist-sync-issue');
|
|
const syncIssueSummary = document.getElementById('modal-pricelist-sync-summary');
|
|
const syncIssueAttempt = document.getElementById('modal-pricelist-sync-attempt');
|
|
const syncIssueError = document.getElementById('modal-pricelist-sync-error');
|
|
const hasSyncFailure = data.last_pricelist_sync_status === 'failed';
|
|
if (data.has_incomplete_server_sync) {
|
|
syncIssueSection.classList.remove('hidden');
|
|
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые еще не загружены локально.';
|
|
} else if (hasSyncFailure) {
|
|
syncIssueSection.classList.remove('hidden');
|
|
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов завершилась ошибкой.';
|
|
} else {
|
|
syncIssueSection.classList.add('hidden');
|
|
syncIssueSummary.textContent = '';
|
|
}
|
|
if (syncIssueSection.classList.contains('hidden')) {
|
|
syncIssueAttempt.classList.add('hidden');
|
|
syncIssueAttempt.textContent = '';
|
|
syncIssueError.classList.add('hidden');
|
|
syncIssueError.textContent = '';
|
|
} else {
|
|
if (data.last_pricelist_attempt_at) {
|
|
syncIssueAttempt.classList.remove('hidden');
|
|
syncIssueAttempt.textContent = 'Последняя попытка: ' + new Date(data.last_pricelist_attempt_at).toLocaleString('ru-RU');
|
|
} else {
|
|
syncIssueAttempt.classList.add('hidden');
|
|
syncIssueAttempt.textContent = '';
|
|
}
|
|
if (data.last_pricelist_sync_error) {
|
|
syncIssueError.classList.remove('hidden');
|
|
syncIssueError.textContent = data.last_pricelist_sync_error;
|
|
} else {
|
|
syncIssueError.classList.add('hidden');
|
|
syncIssueError.textContent = '';
|
|
}
|
|
}
|
|
|
|
// Section 2: Statistics
|
|
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
|
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
|
document.getElementById('modal-config-count').textContent = data.config_count.toLocaleString();
|
|
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
|
|
|
|
// Section 3: Pending changes
|
|
const pendingSection = document.getElementById('modal-pending-section');
|
|
const pendingList = document.getElementById('modal-pending-list');
|
|
if (data.pending_changes && data.pending_changes.length > 0) {
|
|
pendingSection.classList.remove('hidden');
|
|
pendingList.innerHTML = data.pending_changes.map(ch => {
|
|
const shortUUID = ch.entity_uuid.substring(0, 8);
|
|
const time = new Date(ch.created_at).toLocaleString('ru-RU');
|
|
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
|
|
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
|
|
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
|
|
<span class="font-medium">${ch.operation}</span>
|
|
<span class="text-gray-500">${ch.entity_type}</span>
|
|
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
|
|
<span class="text-gray-400 text-xs ml-1">${time}</span>
|
|
${errorLine}
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
pendingSection.classList.add('hidden');
|
|
}
|
|
|
|
// Section 4: Errors
|
|
const errorsSection = document.getElementById('modal-errors-section');
|
|
const errorsList = document.getElementById('modal-errors-list');
|
|
const hasErrors = data.errors && data.errors.length > 0;
|
|
if (hasErrors) {
|
|
errorsSection.classList.remove('hidden');
|
|
errorsList.innerHTML = data.errors.map(error => {
|
|
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
|
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
|
|
<span class="text-xs text-red-400">${time}</span>: ${error.message}
|
|
</div>`;
|
|
}).join('');
|
|
} else {
|
|
errorsSection.classList.add('hidden');
|
|
}
|
|
|
|
// Section 5: Repair (show only if errors exist)
|
|
const repairSection = document.getElementById('modal-repair-section');
|
|
const repairResult = document.getElementById('repair-result');
|
|
if (hasErrors) {
|
|
repairSection.classList.remove('hidden');
|
|
repairResult.classList.add('hidden');
|
|
repairResult.innerHTML = '';
|
|
} else {
|
|
repairSection.classList.add('hidden');
|
|
}
|
|
} catch(e) {
|
|
console.error('Failed to load sync info:', e);
|
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
|
}
|
|
}
|
|
|
|
// Repair pending changes
|
|
async function repairPendingChanges() {
|
|
const button = document.getElementById('repair-button');
|
|
const resultDiv = document.getElementById('repair-result');
|
|
|
|
button.disabled = true;
|
|
button.textContent = 'Исправление...';
|
|
resultDiv.classList.add('hidden');
|
|
|
|
try {
|
|
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
resultDiv.classList.remove('hidden');
|
|
if (data.repaired > 0) {
|
|
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
|
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
|
// Reload sync info after repair
|
|
setTimeout(() => loadSyncInfo(), 1000);
|
|
} else {
|
|
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
|
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
|
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
|
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
|
}
|
|
}
|
|
} else {
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
|
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
|
}
|
|
} catch (e) {
|
|
resultDiv.classList.remove('hidden');
|
|
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
|
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = 'ИСПРАВИТЬ';
|
|
}
|
|
}
|
|
|
|
// Event delegation for sync dropdown and actions
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadDBUser();
|
|
checkWritePermission();
|
|
});
|
|
|
|
// Event delegation for sync actions
|
|
document.body.addEventListener('click', function(e) {
|
|
// Handle sync button click (full sync only)
|
|
const syncButton = e.target.closest('#sync-button');
|
|
if (syncButton) {
|
|
e.stopPropagation();
|
|
const button = syncButton;
|
|
|
|
// Add loading state
|
|
const originalHTML = button.innerHTML;
|
|
button.disabled = true;
|
|
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
|
|
|
fullSync(button, originalHTML);
|
|
}
|
|
});
|
|
|
|
// Refactored sync action function to reduce duplication
|
|
async function syncAction(endpoint, successMessage, button, originalHTML) {
|
|
try {
|
|
const resp = await fetch(endpoint, { method: 'POST' });
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
showToast(successMessage, 'success');
|
|
// Update last sync time - removed since dropdown is gone
|
|
// loadLastSyncTime();
|
|
|
|
// Dispatch custom event for pages to react to sync completion
|
|
window.dispatchEvent(new CustomEvent('sync-completed', {
|
|
detail: {
|
|
endpoint: endpoint,
|
|
data: data
|
|
}
|
|
}));
|
|
} else if (resp.status === 423) {
|
|
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
|
showToast(reason, 'error');
|
|
openSyncModal();
|
|
loadSyncInfo();
|
|
} else {
|
|
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
|
}
|
|
|
|
htmx.trigger('#sync-status', 'refresh');
|
|
} catch (error) {
|
|
showToast('Ошибка: ' + error.message, 'error');
|
|
} finally {
|
|
// Reset button state
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.innerHTML = originalHTML;
|
|
}
|
|
}
|
|
}
|
|
|
|
function pushPendingChanges(button, originalHTML) {
|
|
syncAction('/api/sync/push', 'Изменения синхронизированы', button, originalHTML);
|
|
}
|
|
|
|
function fullSync(button, originalHTML) {
|
|
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
|
}
|
|
|
|
async function loadDBUser() {
|
|
try {
|
|
const resp = await fetch('/api/db-status');
|
|
const data = await resp.json();
|
|
const userEl = document.getElementById('db-user');
|
|
if (data.connected && data.db_user) {
|
|
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
|
}
|
|
} catch(e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// Admin pricing link is now always visible
|
|
// Write permission is checked at operation time (create/delete)
|
|
async function checkWritePermission() {
|
|
// No longer needed - link always visible in offline-first mode
|
|
// Operations will check online status when executed
|
|
}
|
|
|
|
// Load last sync time for dropdown (removed since dropdown is gone)
|
|
// async function loadLastSyncTime() {
|
|
// try {
|
|
// const resp = await fetch('/api/sync/status');
|
|
// const data = await resp.json();
|
|
// if (data.last_pricelist_sync) {
|
|
// const date = new Date(data.last_pricelist_sync);
|
|
// document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
|
// } else {
|
|
// document.getElementById('last-sync-time').textContent = 'Нет данных';
|
|
// }
|
|
// } catch(e) {
|
|
// console.error('Failed to load last sync time:', e);
|
|
// }
|
|
// }
|
|
|
|
// Call functions immediately to ensure they run even before DOMContentLoaded
|
|
// This ensures username and admin link are visible ASAP
|
|
loadDBUser();
|
|
checkWritePermission();
|
|
|
|
// Load last sync time - removed since dropdown is gone
|
|
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|