This commit is contained in:
Nyako
2026-06-06 14:52:48 +03:00
parent 36a044c8da
commit 2c2aa65947
8 changed files with 193 additions and 173 deletions
+92 -99
View File
@@ -41,22 +41,21 @@
.ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; }
.ext-block a:hover { text-decoration: underline; }
.loader { color: var(--text-muted); font-size: 16px; }
@media (max-width: 1000px) { .dashboard-grid { grid-template-columns: 1fr 1fr; } .left-col { grid-column: 1/-1; display: grid; grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .dashboard-grid, .left-col { grid-template-columns: 1fr; } header { flex-wrap: wrap; } .search-wrapper { order: 3; min-width: 100%; margin-top: 10px;} }
</style>
</head>
<body>
<header>
<a href="index.html" class="logo">LocalBooru</a>
<a href="analytics.html" class="analytics-link">📊 Analytics</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
<ul id="autocompleteList" class="autocomplete-list"></ul>
</div>
<a href="/" class="logo">LocalBooru</a>
</header>
<main>
<h1>Database Analytics</h1>
<div id="loadingIndicator" class="loader">Fetching data from API...</div>
<div class="dashboard-grid" id="analyticsGrid" style="display: none;">
<div class="left-col">
<div class="panel">
@@ -68,7 +67,7 @@
<div class="data-list" id="storageContainer"></div>
</div>
<div class="panel">
<div class="panel-header">🎨 Media Types (in img/)</div>
<div class="panel-header">🎨 Media Types</div>
<div class="data-list" id="mediaContainer"></div>
</div>
<div class="panel">
@@ -78,125 +77,119 @@
</div>
<div class="panel">
<div class="panel-header">🏷️ All Tags List</div>
<div class="panel-header">🏷️ Top Tags List</div>
<div class="table-header"><span>Tag</span><span>Count</span></div>
<div class="data-list" id="tagsListContainer"></div>
<div class="pagination"><button class="page-btn" onclick="prevTagPage()">&lsaquo;</button><span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span><button class="page-btn" onclick="nextTagPage()">&rsaquo;</button></div>
<div class="pagination">
<button class="page-btn" onclick="prevTagPage()">&lsaquo;</button>
<span id="tagPageInfo" style="font-weight:bold; padding:0 10px;"></span>
<button class="page-btn" onclick="nextTagPage()">&rsaquo;</button>
</div>
</div>
<!-- Заменено, так как в новом JSON нет top_pairs -->
<div class="panel">
<div class="panel-header">🔗 Top 30 Tag Pairs</div>
<div class="table-header"><span>Pair</span><span>Count</span></div>
<div class="data-list" id="pairsContainer"></div>
<div class="panel-header">️ System Overview</div>
<div class="data-list" id="overviewContainer"></div>
</div>
</div>
</main>
<script>
const searchInput = document.getElementById('searchInput');
const list = document.getElementById('autocompleteList');
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const words = searchInput.value.split(' ');
const currentWord = words[words.length - 1];
if (currentWord.length < 2) { list.style.display = 'none'; return; }
debounceTimer = setTimeout(async () => {
const req = { type: "autocomplete", q: currentWord };
const data = await mockAutocompleteAPI(req);
if (data.length === 0) { list.style.display = 'none'; return; }
list.innerHTML = data.map(item => `<li class="autocomplete-item" data-tag="${item.tag}"><span>${item.tag}</span><span class="autocomplete-count">${item.count}</span></li>`).join('');
list.style.display = 'block';
list.querySelectorAll('.autocomplete-item').forEach(li => {
li.addEventListener('click', () => {
words[words.length - 1] = li.getAttribute('data-tag');
searchInput.value = words.join(' ') + ' ';
list.style.display = 'none';
searchInput.focus();
});
});
}, 200);
});
document.addEventListener('click', (e) => { if (e.target !== searchInput && e.target !== list) list.style.display = 'none'; });
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; });
let allTags = [], currentTagPage = 1, tagsPerPage = 22;
// Укорачиваем длинные имена файлов (хэши) для красивого отображения
const shortenName = (name) => name.length > 20 ? name.substring(0, 10) + '...' + name.substring(name.length - 6) : name;
async function fetchAnalytics() {
const data = await mockAnalyticsAPI({ type: "analytics" });
document.getElementById('ratingContainer').innerHTML = Object.keys(data.ratings).map(k => `
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
<div style="color:var(--text-muted); margin-bottom:3px;">${data.ratings[k].count} items</div>
<div style="font-weight:bold;">${data.ratings[k].size}</div>
</div>
`).join('');
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: "analytic" })
});
const s = data.storage;
document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
<div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
<div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div>
<div class="data-row"><span>Img:</span><span class="val">${s.main}</span></div>
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${s.total}</span></div>
`;
if (!response.ok) throw new Error("Network response was not ok");
const data = await response.json();
// Скрываем лоадер, показываем сетку
document.getElementById('loadingIndicator').style.display = 'none';
document.getElementById('analyticsGrid').style.display = 'grid';
const m = data.media;
document.getElementById('mediaContainer').innerHTML = `
<div class="data-row"><span>IMG:</span><span class="val">${m.img_size}</span></div>
<div class="data-row"><span>VID:</span><span class="val">${m.vid_size}</span></div>
<div class="data-row"><span>GIF:</span><span class="val">${m.gif_size}</span></div>
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${m.total_items}</b> | Avg: <b>${m.avg_weight}</b></div>
`;
// 1. Рейтинги
const rStats = data.ratings_stats;
document.getElementById('ratingContainer').innerHTML = Object.keys(rStats).map(k => `
<div class="rating-box" style="border-top: 2px solid var(--color-${k[0]})">
<div style="font-weight:bold; color:var(--color-${k[0]}); margin-bottom:5px;">${k.charAt(0).toUpperCase() + k.slice(1)}</div>
<div style="color:var(--text-muted); margin-bottom:3px;">${rStats[k].count} items</div>
<div style="font-weight:bold;">${rStats[k].size_formatted}</div>
</div>
`).join('');
const e = data.extremes;
document.getElementById('extremesContainer').innerHTML = `
<div class="ext-block"><b>Largest:</b> ${e.largest.val}<br>(<a href="post.html?id=${e.largest.id}">${e.largest.file}</a>)</div>
<div class="ext-block"><b>Smallest:</b> ${e.smallest.val}<br>(<a href="post.html?id=${e.smallest.id}">${e.smallest.file}</a>)</div>
<div class="ext-block"><b>Heaviest:</b> ${e.heaviest.val}<br>(<a href="post.html?id=${e.heaviest.id}">${e.heaviest.file}</a>)</div>
<div class="ext-block"><b>Lightest:</b> ${e.lightest.val}<br>(<a href="post.html?id=${e.lightest.id}">${e.lightest.file}</a>)</div>
`;
// 2. Хранилище (Storage)
const s = data.storage_stats.folders;
document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div>
<div class="data-row"><span>DB:</span><span class="val">${s.db}</span></div>
<div class="data-row"><span>Thumb:</span><span class="val">${s.thumb}</span></div>
<div class="data-row"><span>Data (Img):</span><span class="val">${s.data}</span></div>
<div class="data-row" style="color:var(--color-g); font-weight:bold; border-top:1px solid #444; border-bottom:none; padding-top:8px;"><span>GRAND TOTAL:</span><span>${data.storage_stats.grand_total}</span></div>
`;
document.getElementById('pairsContainer').innerHTML = data.top_pairs.map(p => `<div class="tag-row"><a href="index.html?query=${p.pair.replace(/ \+ /g, '+')}">${p.pair}</a> <span>${p.count}</span></div>`).join('');
// 3. Медиа типы
const m = data.storage_stats.types;
document.getElementById('mediaContainer').innerHTML = `
<div class="data-row"><span>IMG:</span><span class="val">${m.images}</span></div>
<div class="data-row"><span>VID:</span><span class="val">${m.videos}</span></div>
<div class="data-row"><span>GIF:</span><span class="val">${m.gifs}</span></div>
<div style="font-size:11px; color:var(--text-muted); margin-top:5px;">Total: <b>${data.total_images_analyzed}</b> | Avg: <b>${data.average_weight.formatted}</b></div>
`;
allTags = data.all_tags; renderTagsPage();
document.getElementById('analyticsGrid').style.display = 'grid';
// 4. Рекорды (Extremes)
const res = data.resolution_stats;
const weight = data.weight_stats;
document.getElementById('extremesContainer').innerHTML = `
<div class="ext-block"><b>Largest Res:</b> ${res.largest.resolution}<br>(<a href="post/${res.largest.md5}">${shortenName(res.largest.filename)}</a>)</div>
<div class="ext-block"><b>Smallest Res:</b> ${res.smallest.resolution}<br>(<a href="post/${res.smallest.md5}">${shortenName(res.smallest.filename)}</a>)</div>
<div class="ext-block"><b>Heaviest:</b> ${weight.largest.size_formatted}<br>(<a href="post/${weight.largest.md5}">${shortenName(weight.largest.filename)}</a>)</div>
<div class="ext-block"><b>Lightest:</b> ${weight.smallest.size_formatted}<br>(<a href="post/${weight.smallest.md5}">${shortenName(weight.smallest.filename)}</a>)</div>
`;
// 5. System Overview (вместо Top Pairs)
document.getElementById('overviewContainer').innerHTML = `
<div class="data-row"><span>Images Analyzed:</span><span class="val" style="font-weight:bold;">${data.total_images_analyzed}</span></div>
<div class="data-row"><span>Files Not Found:</span><span class="val" style="font-weight:bold; color: ${data.files_not_found > 0 ? 'var(--color-e)' : 'inherit'};">${data.files_not_found}</span></div>
`;
// 6. Теги
allTags = data.top_tag;
renderTagsPage();
} catch (error) {
console.error('Error fetching analytics:', error);
document.getElementById('loadingIndicator').innerHTML = `<span style="color: var(--color-e)">Failed to load data. See console for details.</span>`;
}
}
// Пагинация тегов
function renderTagsPage() {
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${Math.ceil(allTags.length / tagsPerPage)}`;
if (!allTags || allTags.length === 0) return;
const totalPages = Math.ceil(allTags.length / tagsPerPage) || 1;
document.getElementById('tagPageInfo').innerText = `${currentTagPage} / ${totalPages}`;
const start = (currentTagPage - 1) * tagsPerPage;
document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t => `<div class="tag-row"><a href="index.html?query=${t.name}">${t.name}</a> <span>${t.count}</span></div>`).join('');
document.getElementById('tagsListContainer').innerHTML = allTags.slice(start, start + tagsPerPage).map(t =>
`<div class="tag-row"><a href="index.html?query=${encodeURIComponent(t.tag)}">${t.tag}</a> <span>${t.count}</span></div>`
).join('');
}
window.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }};
window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { currentTagPage++; renderTagsPage(); }};
// --- ЗАГЛУШКИ ---
function mockAutocompleteAPI(req) { return new Promise(res => { const db = [{tag: "long_hair", count: 8184}, {tag: "1girl", count: 11061}]; res(db.filter(t => t.tag.includes(req.q.toLowerCase()))); }); }
function mockAnalyticsAPI() {
return new Promise(res => { setTimeout(() => { res({
ratings: { explicit: { count: 1605, size: "4.52 GB" }, sensitive: { count: 6623, size: "19.35 GB" }, questionable: { count: 1591, size: "4.72 GB" }, general: { count: 4184, size: "10.29 GB" } },
storage: { logs: "14.44 MB", db: "24.39 MB", thumb: "331.45 MB", main: "38.88 GB", total: "39.24 GB" },
media: { img_size: "38.56 GB", vid_size: "140.80 MB", gif_size: "181.30 MB", total_items: 14003, avg_weight: "2.84 MB" },
extremes: {
largest: { val: "9921x14031", id: "5a63c", file: "5a63c28906dc7e0e...png" },
smallest: { val: "240x280", id: "8fc61", file: "8fc614e712ae2ad...gif" },
heaviest: { val: "65.21 MB", id: "4ab6a", file: "4ab6acf0a32c61...png" },
lightest: { val: "6.49 KB", id: "28c67", file: "28c679ea3483e1...png" }
},
all_tags: Array.from({length: 100}, (_,i) => ({name:`tag_${i}`, count:100-i})),
top_pairs: [{ pair: "1girl + highres", count: 10236 }, { pair: "1girl + solo", count: 8902 }]
}); }, 300);});
}
// Запуск
fetchAnalytics();
</script>
</body>
-2
View File
@@ -64,9 +64,7 @@
<header>
<a href="/" class="logo">LocalBooru</a>
<!--
<a class="analytics" href="/analytics">📊 Analytics</a>
-->
<a class="tasks" href="/task">⚙️ Tasks</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
-42
View File
@@ -56,10 +56,6 @@
<body>
<header>
<a href="/" class="logo">LocalBooru</a>
<div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags...">
<ul id="autocompleteList" class="autocomplete-list"></ul>
</div>
</header>
<main>
@@ -139,44 +135,6 @@
btn.innerText = "Запустить";
}
}
// === ЛОГИКА ПОИСКА (оставлена из предыдущей версии) ===
const searchInput = document.getElementById('searchInput');
const list = document.getElementById('autocompleteList');
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const words = searchInput.value.split(' ');
const currentWord = words[words.length - 1];
if (currentWord.length < 2) { list.style.display = 'none'; return; }
debounceTimer = setTimeout(async () => {
const req = { type: "autocomplete", q: currentWord };
const data = await mockAutocompleteAPI(req);
if (data.length === 0) { list.style.display = 'none'; return; }
list.innerHTML = data.map(item => `<li class="autocomplete-item" data-tag="${item.tag}"><span>${item.tag}</span><span class="autocomplete-count">${item.count}</span></li>`).join('');
list.style.display = 'block';
list.querySelectorAll('.autocomplete-item').forEach(li => {
li.addEventListener('click', () => {
words[words.length - 1] = li.getAttribute('data-tag');
searchInput.value = words.join(' ') + ' ';
list.style.display = 'none';
searchInput.focus();
});
});
}, 200);
});
document.addEventListener('click', (e) => { if (e.target !== searchInput && e.target !== list) list.style.display = 'none'; });
searchInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') window.location.href = `index.html?query=${searchInput.value.trim()}`; });
// Заглушка для поиска (оставьте или замените на ваш fetch)
function mockAutocompleteAPI(req) { return new Promise(res => { const db = [{tag: "long_hair", count: 8184}, {tag: "1girl", count: 11061}]; res(db.filter(t => t.tag.includes(req.q.toLowerCase()))); }); }
</script>
</body>
</html>