// static/lm_stats_ui.js /** * CivitAI Stats UI — card badges, sort dropdown options, fetch button. * * Injected into LoRA Manager pages by the LM-Remote proxy. * Reads stats from enriched /api/lm/loras/list responses and * patches the DOM to show badges, sort options, and a fetch button. */ (function () { "use strict"; // ── Compact number formatting ────────────────────────────────── function formatCompact(n) { if (n == null) return null; if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; return String(n); } // ── Badge creation ───────────────────────────────────────────── function createStatBadge(icon, value, title) { if (value == null) return null; const badge = document.createElement("span"); badge.className = "lm-stat-badge"; badge.title = title; badge.innerHTML = ` ${formatCompact(value)}`; return badge; } // ── Inject CSS ───────────────────────────────────────────────── function injectStyles() { if (document.getElementById("lm-stats-styles")) return; const style = document.createElement("style"); style.id = "lm-stats-styles"; style.textContent = ` .lm-stat-badges { display: flex; gap: 6px; flex-wrap: wrap; padding: 2px 6px; } .lm-stat-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 1px 5px; border-radius: 4px; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.8); white-space: nowrap; } .lm-stat-badge i { font-size: 10px; opacity: 0.7; } `; document.head.appendChild(style); } // ── Intercept list API to capture stats ──────────────────────── const _statsMap = {}; const _origFetch = window.fetch; window.fetch = async function (...args) { const response = await _origFetch.apply(this, args); const url = typeof args[0] === "string" ? args[0] : args[0]?.url; if (url && url.includes("/api/lm/") && url.includes("/list")) { try { const clone = response.clone(); const data = await clone.json(); (data.items || []).forEach((item) => { if (item.sha256 && item.download_count != null) { _statsMap[item.sha256] = { download_count: item.download_count, rating: item.rating, rating_count: item.rating_count, thumbs_up_count: item.thumbs_up_count, }; } }); } catch (e) { /* ignore parse errors */ } } return response; }; // ── Patch model cards ────────────────────────────────────────── function patchCards() { const cards = document.querySelectorAll(".model-card:not([data-stats-patched])"); cards.forEach((card) => { card.setAttribute("data-stats-patched", "1"); const sha = card.dataset.sha256; if (!sha || !_statsMap[sha]) return; const stats = _statsMap[sha]; const container = document.createElement("div"); container.className = "lm-stat-badges"; const dlBadge = createStatBadge("download", stats.download_count, "Downloads"); const ratingBadge = createStatBadge("star", stats.rating ? Number(stats.rating.toFixed(1)) : null, "Rating"); const thumbsBadge = createStatBadge("thumbs-up", stats.thumbs_up_count, "Likes"); [dlBadge, ratingBadge, thumbsBadge].forEach((b) => { if (b) container.appendChild(b); }); if (container.children.length > 0) { // Insert inside .model-info, after .model-name const modelInfo = card.querySelector(".model-info"); if (modelInfo) { modelInfo.appendChild(container); } } }); } // ── Patch sort dropdown ──────────────────────────────────────── function patchSortDropdown() { const select = document.getElementById("sortSelect"); if (!select || select.querySelector('[value="downloads:desc"]')) return; const group = document.createElement("optgroup"); group.label = "CivitAI Stats"; const options = [ ["downloads:desc", "Most downloaded"], ["downloads:asc", "Least downloaded"], ["rating:desc", "Highest rated"], ["rating:asc", "Lowest rated"], ["thumbsup:desc", "Most liked"], ["thumbsup:asc", "Least liked"], ]; options.forEach(([value, label]) => { const opt = document.createElement("option"); opt.value = value; opt.textContent = label; group.appendChild(opt); }); select.appendChild(group); } // ── Toolbar "Fetch Stats" button ─────────────────────────────── function addFetchStatsButton() { const toolbar = document.querySelector(".action-buttons"); if (!toolbar || document.getElementById("fetchStatsBtn")) return; const group = document.createElement("div"); group.className = "control-group"; group.innerHTML = ` `; // Insert before the bulk operations button const bulkBtn = document.getElementById("bulkOperationsBtn"); if (bulkBtn && bulkBtn.closest(".control-group")) { toolbar.insertBefore(group, bulkBtn.closest(".control-group")); } else { toolbar.appendChild(group); } group.querySelector("button").addEventListener("click", async () => { const btn = document.getElementById("fetchStatsBtn"); btn.disabled = true; btn.innerHTML = ' Fetching...'; try { const resp = await _origFetch("/api/lm-extra/fetch-stats", { method: "POST" }); const data = await resp.json(); if (data.success) { btn.innerHTML = ` ${data.updated} updated`; setTimeout(() => { btn.innerHTML = ' Fetch Stats'; btn.disabled = false; }, 3000); // Trigger page reload to show new stats const sortSelect = document.getElementById("sortSelect"); if (sortSelect) { sortSelect.dispatchEvent(new Event("change")); } } else { throw new Error(data.error || "Unknown error"); } } catch (err) { btn.innerHTML = ' Error'; console.error("[LM-Stats] Fetch failed:", err); setTimeout(() => { btn.innerHTML = ' Fetch Stats'; btn.disabled = false; }, 3000); } }); } // ── Observe DOM for card rendering ───────────────────────────── function startObserver() { injectStyles(); patchSortDropdown(); addFetchStatsButton(); patchCards(); const observer = new MutationObserver(() => { patchCards(); patchSortDropdown(); addFetchStatsButton(); }); observer.observe(document.body, { childList: true, subtree: true }); } // ── Init ─────────────────────────────────────────────────────── if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startObserver); } else { startObserver(); } })();