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
+1
View File
@@ -53,6 +53,7 @@ Config explanation:
| `file`-`repos` | `["data/files"]` | List of directories where source files are stored | | `file`-`repos` | `["data/files"]` | List of directories where source files are stored |
| `file`-`dw` | `"data/files"` | Directory for new downloads | | `file`-`dw` | `"data/files"` | Directory for new downloads |
| `file`-`thumb` | `"data/thumb"` | Directory for thumbnails | | `file`-`thumb` | `"data/thumb"` | Directory for thumbnails |
| `file`-`data` | `"data"` | Directory for database |
1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly. 1. You must obtain your Gelbooru User ID and Hash and add them to the config file for the system to function correctly.
2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js). 2. Install the script in [VioletMonkey](https://git.nekono.su/Nyako/Local-Booru/raw/branch/main/button_script.user.js).
+1
View File
@@ -54,6 +54,7 @@
| `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы | | `file`-`repos` | `["data/files"]` | Список каталогов где храняться полные файлы |
| `file`-`dw` | `"data/files"` | Каталог хранения загрузок | | `file`-`dw` | `"data/files"` | Каталог хранения загрузок |
| `file`-`thumb` | `"data/thumb"` | Каталог обложек | | `file`-`thumb` | `"data/thumb"` | Каталог обложек |
| `file`-`data` | `"data"` | Каталог базы данных |
1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы! 1. Обязательно надо получить ID и Hash пользователя и внести в конфиг для работы!
+6 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import requests import requests
from flask import (Flask, jsonify, render_template, render_template_string, from flask import (Flask, jsonify, render_template, render_template_string,
request, send_from_directory, make_response) request, send_from_directory, make_response, send_file)
from system_module import db, gelbooru, image_processor, logger, tasks from system_module import db, gelbooru, image_processor, logger, tasks
@@ -26,12 +26,13 @@ GB_HEADERS = CONFIG_ROOT["gb"]["headers"]
FILE_FOLDERS = CONFIG_ROOT["file"]["repos"] FILE_FOLDERS = CONFIG_ROOT["file"]["repos"]
FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"] FILE_DW_FOLDERS = CONFIG_ROOT["file"]["dw"]
THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"] THUMB_FOLDER = CONFIG_ROOT["file"]["thumb"]
DATA_FOLDER = CONFIG_ROOT["file"]["data"]
gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS) gb = gelbooru.GB(GB_ID, GB_HASH, GB_HEADERS, FILE_DW_FOLDERS)
log = logger.Logger() log = logger.Logger()
log.init_global_exception_hook() log.init_global_exception_hook()
database = db.DB(True) database = db.DB(DATA_FOLDER)
app = Flask(__name__) app = Flask(__name__)
if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!") if not image_processor.check_ffmpeg(): print("WARNING!\n\nУ вас не установлен ffmpeg. Генерация превью для видео не доступна!")
@@ -54,7 +55,7 @@ def post(post_id):
@app.route('/file/<path:filename>') @app.route('/file/<path:filename>')
def serve_image(filename): def serve_image(filename):
folder_path = task.find_file(filename, FILE_FOLDERS) folder_path = tasks.find_file(filename, FILE_FOLDERS)
if folder_path == None: if folder_path == None:
return make_response("Not found", 404) return make_response("Not found", 404)
else: else:
@@ -91,6 +92,8 @@ def api():
post_id = data["post_id"] post_id = data["post_id"]
result_post = database.get_from_id(post_id) result_post = database.get_from_id(post_id)
return jsonify(result_post) return jsonify(result_post)
elif data["type"] == "analytic":
return send_file(os.path.join(DATA_FOLDER, "analytic.json"), mimetype="application/json")
elif data["type"] == "task": elif data["type"] == "task":
if data["task"] == "analytic": if data["task"] == "analytic":
if tasks.calculate_analytic(database, CONFIG_ROOT): if tasks.calculate_analytic(database, CONFIG_ROOT):
+1 -5
View File
@@ -7,11 +7,7 @@ from system_module.gelbooru import gelbooru_date_parse
class DB: class DB:
def __init__(self, debug: bool): def __init__(self, db_path: str):
if debug:
db_path = "data_test"
else:
db_path = "data"
if not os.path.exists(db_path): if not os.path.exists(db_path):
os.makedirs(db_path) os.makedirs(db_path)
+91 -21
View File
@@ -2,13 +2,13 @@ import os
import json import json
from pathlib import Path from pathlib import Path
def find_file(filename, file_folders): def find_file(filename: str, file_folders: list[str], is_folder: bool = True):
for folder in file_folders: for folder in file_folders:
folder_path = Path(folder) folder_path = Path(folder)
if not folder_path.exists(): if not folder_path.exists():
continue continue
for file_path in folder_path.rglob(filename): for file_path in folder_path.rglob(filename):
return folder_path return str(folder_path) if is_folder else str(file_path)
return None return None
def get_size_format(b, factor=1024, suffix="B"): def get_size_format(b, factor=1024, suffix="B"):
@@ -36,9 +36,10 @@ def get_file_list_size(file_list: list[str]):
def get_size_edge(type_searсh: bool, file_list: list[str]): def get_size_edge(type_searсh: bool, file_list: list[str]):
if type_searсh: if type_searсh:
return min(file_list, key=os.path.getsize) result = min(file_list, key=lambda f: Path(f).stat().st_size)
else: else:
return max(file_list, key=os.path.getsize) result = max(file_list, key=lambda f: Path(f).stat().st_size)
return str(result)
def calculate_analytic(database, config): def calculate_analytic(database, config):
all_db = database.get_all() all_db = database.get_all()
@@ -50,8 +51,9 @@ def calculate_analytic(database, config):
type_list_file_path = {"images":[], "videos":[], "gifs":[]} type_list_file_path = {"images":[], "videos":[], "gifs":[]}
files_not_found = 0 files_not_found = 0
for one_post in all_db: for one_post in all_db:
file_path = find_file(one_post["local_filename"], config["file"]["repos"]) file_path = find_file(one_post["local_filename"], config["file"]["repos"], is_folder=False)
if file_path != None: if file_path != None:
file_path = str(file_path)
rating_list_file_path[one_post["rating"]].append(file_path) rating_list_file_path[one_post["rating"]].append(file_path)
if one_post["is_gif"] == 1: if one_post["is_gif"] == 1:
type_list_file_path["gifs"].append(file_path) type_list_file_path["gifs"].append(file_path)
@@ -88,11 +90,78 @@ def calculate_analytic(database, config):
} }
} }
total_file_data_size = file_size_dict["general"]+file_size_dict["questionable"]+file_size_dict["sensitive"]+file_size_dict["explicit"] total_file_data_size = file_size_dict["general"]+file_size_dict["questionable"]+file_size_dict["sensitive"]+file_size_dict["explicit"]
total_thumb_size = get_dir_size(config[["file"]["thumb"]]) total_thumb_size = get_dir_size(config["file"]["thumb"])
total_log_size = get_file_list_size(["logs.log"]) total_log_size = get_file_list_size(["logs.log"])
total_db_size = get_file_list_size([os.path.join("data", "db.sqlite")]) total_db_size = get_file_list_size([os.path.join(config["file"]["data"], "db.sqlite")])
weight_stats_smallest = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"]) weight_stats_smallest_file = get_size_edge(True, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
weight_stats_largest = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"]) weight_stats_largest_file = get_size_edge(False, type_list_file_path["gifs"]+type_list_file_path["images"]+type_list_file_path["videos"])
weight_stats_data = {
"smallest":{
"file":"",
"md5":"",
"size":0
},
"largest":{
"file":"",
"md5":"",
"size":0
}
}
resolution_stats_data = {
"smallest":{
"file":"",
"md5":"",
"res":{
"width":0,
"height":0,
"total":99999999999999999999999999
}
},
"largest":{
"file":"",
"md5":"",
"res":{
"width":0,
"height":0,
"total":0
}
}
}
for one_post_scan in all_db:
if one_post_scan["local_filename"] == os.path.basename(weight_stats_smallest_file):
weight_stats_data["smallest"] = {
"file":os.path.basename(weight_stats_smallest_file),
"md5":one_post_scan["md5"],
"size":get_file_list_size([weight_stats_smallest_file])
}
if one_post_scan["local_filename"] == os.path.basename(weight_stats_largest_file):
weight_stats_data["largest"] = {
"file":os.path.basename(weight_stats_largest_file),
"md5":one_post_scan["md5"],
"size":get_file_list_size([weight_stats_largest_file])
}
print(get_file_list_size([os.path.basename(weight_stats_largest_file)]))
one_post_res_total = one_post_scan["width"]*one_post_scan["height"]
if resolution_stats_data["largest"]["res"]["total"] < one_post_res_total:
resolution_stats_data["largest"] = {
"file":one_post_scan["local_filename"],
"md5":one_post_scan["md5"],
"res":{
"width":one_post_scan["width"],
"height":one_post_scan["height"],
"total":one_post_res_total
}
}
if resolution_stats_data["smallest"]["res"]["total"] > one_post_res_total:
resolution_stats_data["smallest"] = {
"file":one_post_scan["local_filename"],
"md5":one_post_scan["md5"],
"res":{
"width":one_post_scan["width"],
"height":one_post_scan["height"],
"total":one_post_res_total
}
}
result = { result = {
"total_images_analyzed": len(all_db), "total_images_analyzed": len(all_db),
"files_not_found":files_not_found, "files_not_found":files_not_found,
@@ -117,29 +186,30 @@ def calculate_analytic(database, config):
"ratings_stats": ratings_stats, "ratings_stats": ratings_stats,
"resolution_stats": { "resolution_stats": {
"largest": { "largest": {
"filename": max_res["filename"], "filename": resolution_stats_data["largest"]["file"],
"md5": max_res["md5"], "md5": resolution_stats_data["largest"]["md5"],
"resolution": f'{max_res["width"]}x{max_res["height"]}' "resolution": str(resolution_stats_data["largest"]["res"]["width"]) + " x " + str(resolution_stats_data["largest"]["res"]["height"])
}, },
"smallest": { "smallest": {
"filename": min_res["filename"], "filename": resolution_stats_data["smallest"]["file"],
"md5": min_res["md5"], "md5": resolution_stats_data["smallest"]["md5"],
"resolution": f'{min_res["width"]}x{min_res["height"]}' "resolution": str(resolution_stats_data["smallest"]["res"]["width"]) + " x " + str(resolution_stats_data["smallest"]["res"]["height"])
} }
}, },
"weight_stats": { "weight_stats": {
"largest": { "largest": {
"filename": weight_stats_largest, "filename": os.path.basename(weight_stats_data["largest"]["file"]),
"md5": max_weight["md5"], "md5": weight_stats_data["largest"]["md5"],
"size_formatted": get_size_format(max_weight["size"]) "size_formatted": get_size_format(weight_stats_data["largest"]["size"])
}, },
"smallest": { "smallest": {
"filename": weight_stats_smallest, "filename": os.path.basename(weight_stats_data["smallest"]["file"]),
"md5": min_weight["md5"], "md5": weight_stats_data["smallest"]["md5"],
"size_formatted": get_size_format(min_weight["size"]) "size_formatted": get_size_format(weight_stats_data["smallest"]["size"])
} }
}, },
"top_tag":database.get_top_tag("") "top_tag":database.get_top_tag("")
} }
with open(os.path.join(config["file"]["data"], "analytic.json"), 'w', encoding='utf-8') as out_f: with open(os.path.join(config["file"]["data"], "analytic.json"), 'w', encoding='utf-8') as out_f:
json.dump(result, out_f, indent=4, ensure_ascii=False) json.dump(result, out_f, indent=4, ensure_ascii=False)
return True
+79 -86
View File
@@ -41,22 +41,21 @@
.ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; } .ext-block a { color: var(--link-color); text-decoration: none; word-break: break-all; }
.ext-block a:hover { text-decoration: underline; } .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: 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;} } @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> </style>
</head> </head>
<body> <body>
<header> <header>
<a href="index.html" class="logo">LocalBooru</a> <a href="/" 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>
</header> </header>
<main> <main>
<h1>Database Analytics</h1> <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="dashboard-grid" id="analyticsGrid" style="display: none;">
<div class="left-col"> <div class="left-col">
<div class="panel"> <div class="panel">
@@ -68,7 +67,7 @@
<div class="data-list" id="storageContainer"></div> <div class="data-list" id="storageContainer"></div>
</div> </div>
<div class="panel"> <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 class="data-list" id="mediaContainer"></div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -78,125 +77,119 @@
</div> </div>
<div class="panel"> <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="table-header"><span>Tag</span><span>Count</span></div>
<div class="data-list" id="tagsListContainer"></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> </div>
<!-- Заменено, так как в новом JSON нет top_pairs -->
<div class="panel"> <div class="panel">
<div class="panel-header">🔗 Top 30 Tag Pairs</div> <div class="panel-header">️ System Overview</div>
<div class="table-header"><span>Pair</span><span>Count</span></div> <div class="data-list" id="overviewContainer"></div>
<div class="data-list" id="pairsContainer"></div>
</div> </div>
</div> </div>
</main> </main>
<script> <script>
const searchInput = document.getElementById('searchInput');
const list = document.getElementById('autocompleteList');
let debounceTimer; 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; let allTags = [], currentTagPage = 1, tagsPerPage = 22;
async function fetchAnalytics() { // Укорачиваем длинные имена файлов (хэши) для красивого отображения
const data = await mockAnalyticsAPI({ type: "analytics" }); const shortenName = (name) => name.length > 20 ? name.substring(0, 10) + '...' + name.substring(name.length - 6) : name;
document.getElementById('ratingContainer').innerHTML = Object.keys(data.ratings).map(k => ` async function fetchAnalytics() {
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: "analytic" })
});
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';
// 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 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="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="color:var(--text-muted); margin-bottom:3px;">${rStats[k].count} items</div>
<div style="font-weight:bold;">${data.ratings[k].size}</div> <div style="font-weight:bold;">${rStats[k].size_formatted}</div>
</div> </div>
`).join(''); `).join('');
const s = data.storage; // 2. Хранилище (Storage)
const s = data.storage_stats.folders;
document.getElementById('storageContainer').innerHTML = ` document.getElementById('storageContainer').innerHTML = `
<div class="data-row"><span>Logs:</span><span class="val">${s.logs}</span></div> <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>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>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"><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>${s.total}</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>
`; `;
const m = data.media; // 3. Медиа типы
const m = data.storage_stats.types;
document.getElementById('mediaContainer').innerHTML = ` document.getElementById('mediaContainer').innerHTML = `
<div class="data-row"><span>IMG:</span><span class="val">${m.img_size}</span></div> <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.vid_size}</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.gif_size}</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>${m.total_items}</b> | Avg: <b>${m.avg_weight}</b></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>
`; `;
const e = data.extremes; // 4. Рекорды (Extremes)
const res = data.resolution_stats;
const weight = data.weight_stats;
document.getElementById('extremesContainer').innerHTML = ` 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>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:</b> ${e.smallest.val}<br>(<a href="post.html?id=${e.smallest.id}">${e.smallest.file}</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> ${e.heaviest.val}<br>(<a href="post.html?id=${e.heaviest.id}">${e.heaviest.file}</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> ${e.lightest.val}<br>(<a href="post.html?id=${e.lightest.id}">${e.lightest.file}</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>
`; `;
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(''); // 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>
`;
allTags = data.all_tags; renderTagsPage(); // 6. Теги
document.getElementById('analyticsGrid').style.display = 'grid'; 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() { 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; 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.prevTagPage = () => { if(currentTagPage > 1) { currentTagPage--; renderTagsPage(); }};
window.nextTagPage = () => { if(currentTagPage < Math.ceil(allTags.length/tagsPerPage)) { 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(); fetchAnalytics();
</script> </script>
</body> </body>
-2
View File
@@ -64,9 +64,7 @@
<header> <header>
<a href="/" class="logo">LocalBooru</a> <a href="/" class="logo">LocalBooru</a>
<!--
<a class="analytics" href="/analytics">📊 Analytics</a> <a class="analytics" href="/analytics">📊 Analytics</a>
-->
<a class="tasks" href="/task">⚙️ Tasks</a> <a class="tasks" href="/task">⚙️ Tasks</a>
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="searchInput" placeholder="Search tags..."> <input type="text" id="searchInput" placeholder="Search tags...">
-42
View File
@@ -56,10 +56,6 @@
<body> <body>
<header> <header>
<a href="/" class="logo">LocalBooru</a> <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> </header>
<main> <main>
@@ -139,44 +135,6 @@
btn.innerText = "Запустить"; 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> </script>
</body> </body>
</html> </html>