loras_gallery / app.js
waveshaker's picture
Update app.js
63e47ba verified
Raw History Blame Contribute Delete
73.9 kB
// ==========================================
// ⚙️ Config: 参数配置
// ==========================================
// --------------------------------------------------
// 🌟 动画与物理参数配置区
// --------------------------------------------------
const ANIM_CONFIG = {
startY: 24,
stiffness: 280,
damping: 0.7,
staggerDelay: 30,
opacitySpeed: 0.1,
// 🌟 悬停缩放配置
zoomScale: 1.05,
zoomDuration: "0.6s",
zoomEasing: "cubic-bezier(0.25, 0.1, 0.25, 1)" // 🌟 新增:平滑减速曲线
};
// --------------------------------------------------
// 基础仓库配置
// --------------------------------------------------
const REPO_ID = "waveshaker/loras";
const CONFIG_URL = "./models.json";
const RESOLVE_BASE = `https://huggingface.co/${REPO_ID}/resolve/main/`;
const HF_BASE = "https://huggingface.co";
const DATASET_BASE = `${HF_BASE}/datasets/waveshaker/dataset_collections/tree/main/`;
const SHOWCASE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/comfyui_showcase/`;
const GUIDE_BASE = `${HF_BASE}/waveshaker/loras/tree/main/usage_guide/`;
// 全局动态配置
let siteConfig = {
models: [],
categories: []
};
// --------------------------------------------------
// 配置全局状态与分页配置
// --------------------------------------------------
const ITEMS_PER_PAGE = 30;
let currentPage = 1;
let allItems = [];
let filteredItems = [];
let isLoading = false;
let isFiltering = false;
// 🌟 核心状态:只有这里声明一次
let currentFilter = { model: [], category: [] };
let currentSort = "newest"; // 排序状态
let searchQuery = ""; // 用于保存搜索关键词;
// ==========================================
// 🚀 Core: 核心入口
// ==========================================
async function main() {
const container = document.getElementById("app");
const navContainer = document.getElementById("nav-container");
if (!container || !navContainer) return;
renderFullSkeletons(container, navContainer);
// 🌟 核心:必须把变量声明在 try 外面
let itemsConfig = {};
try {
const siteConfigRes = await fetchWithRetry("./site_config.json", { cache: "no-store" });
if (siteConfigRes.ok) siteConfig = await siteConfigRes.json();
} catch (e) {
console.warn("site_config.json 未找到");
}
if (!container) return;
async function fetchWithRetry(url, options, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, delay));
}
}
}
try {
let tree = [];
let nextUrl = `https://huggingface.co/api/models/${REPO_ID}/tree/main?recursive=true`;
while (nextUrl) {
const res = await fetchWithRetry(nextUrl, { cache: "no-store" });
if (!res.ok) throw new Error(`HF API: ${res.status}`);
const data = await res.json();
tree = tree.concat(data);
const link = res.headers.get("Link");
if (link && link.includes('rel="next"')) {
const match = link.match(/<([^>]+)>;\s*rel="next"/);
nextUrl = match ? match[1] : null;
} else {
nextUrl = null;
}
}
// 1. 缓存获取 (避免 JSON.stringify 的巨大性能开销)
try {
const cachedData = localStorage.getItem("loras_config_cache");
if (cachedData) itemsConfig = JSON.parse(cachedData);
const configRes = await fetchWithRetry(`${CONFIG_URL}?t=${Date.now()}`, { cache: "no-store" });
if (configRes.ok) {
const newItems = (await configRes.json()).items || {};
// 🌟 修复:直接覆盖,不再做深比对
itemsConfig = newItems;
localStorage.setItem("loras_config_cache", JSON.stringify(itemsConfig));
console.log("✅ 已加载最新 models.json");
}
} catch (e) {
console.warn("models.json 加载失败,使用旧缓存数据");
}
// 2. 获取下载量
try {
const statRes = await fetchWithRetry(`https://huggingface.co/api/models/${REPO_ID}`);
if (statRes.ok) {
const statData = await statRes.json();
const totalEl = document.getElementById("total-downloads");
if (totalEl) {
totalEl.textContent = `${(statData.downloads || 0).toLocaleString()} Total Downloads`;
totalEl.style.display = "inline-block";
}
}
} catch (e) {}
// 4. 解析模型数据
const safetensors = tree.filter(i => i.type === "file" && i.path.endsWith(".safetensors"));
if (safetensors.length === 0) throw new Error("No safetensors files found.");
const allImagePaths = new Set(tree.filter(i => i.type === "file" && /\.(jpg|jpeg|webp)$/i.test(i.path)).map(i => i.path));
allItems = safetensors.map(file => {
const parts = file.path.split("/");
const filename = parts[parts.length - 1];
const dirs = parts.slice(0, -1);
const modelRaw = dirs.length >= 1 ? dirs[0].toLowerCase() : "other";
const categoryRaw = dirs.length >= 2 ? dirs[1] : "unknown";
const fileData = itemsConfig[file.path] || {};
const title = (typeof fileData.title === "string" && fileData.title.trim() !== "") ? fileData.title : filename.replace(/\.safetensors$/i, "");
const baseCoverPath = file.path.replace(/\.safetensors$/i, "");
let finalCoverExt = ".jpg";
if (allImagePaths.has(baseCoverPath + ".webp")) finalCoverExt = ".webp";
else if (allImagePaths.has(baseCoverPath + ".jpeg")) finalCoverExt = ".jpeg";
const rawUrl = RESOLVE_BASE + encodePath(baseCoverPath + finalCoverExt);
let finalPreviewUrl = rawUrl;
const cleanTitle = (str) => {
if (!str) return "";
const match = str.match(/^.*?[::]\s*(.*)/);
return match ? match[1].trim() : str.trim();
};
return {
path: file.path, filename, title,
cleanTitle: cleanTitle(title),
// 🌟 核心:这里的 commitTimes 终于能正确读取到了
lastModified: fileData.update_time || 0,
modelRaw, modelLabel: formatModelName(modelRaw),
categoryRaw, categoryLabel: formatCategory(categoryRaw),
// 分流后的 URL
previewUrl: finalPreviewUrl,
downloadUrl: RESOLVE_BASE + encodePath(file.path) + "?download=true",
trigger: fileData.trigger || "-",
dataset: fileData.dataset ? DATASET_BASE + fileData.dataset : "",
showcase: fileData.showcase ? SHOWCASE_BASE + fileData.showcase : "",
guide: fileData.guide ? GUIDE_BASE + fileData.guide : ""
};
});
// 5. 排序、渲染等后续逻辑
allItems.sort((a, b) => {
const idxA = siteConfig.models.findIndex(m => m.key.toLowerCase() === a.modelRaw.toLowerCase());
const idxB = siteConfig.models.findIndex(m => m.key.toLowerCase() === b.modelRaw.toLowerCase());
if (idxA !== idxB) return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
const catA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.categoryRaw.toLowerCase());
const catB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.categoryRaw.toLowerCase());
if (catA !== catB) return (catA === -1 ? 999 : catA) - (catB === -1 ? 999 : catB);
return a.title.localeCompare(b.title);
});
const uniqueModels = [...new Set(allItems.map(i => i.modelRaw))];
const uniqueCategories = [...new Set(allItems.map(i => i.categoryRaw))];
// 🌟 加上这一句,强行按 JSON 配置排序
uniqueCategories.sort((a, b) => {
const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase());
const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase());
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
// if (currentFilter.model.length === 0) currentFilter.model = [...uniqueModels];
// if (currentFilter.category.length === 0) currentFilter.category = [...uniqueCategories ];
// -----------------------------------------
// 🌟 默认选中的配置 (首次加载时生效)
// -----------------------------------------
if (currentFilter.model.length === 0) {
// 默认选中:Krea 2, ANIMA, Flux 1 Dev
currentFilter.model = ["moss", "krea2", "anima", "flux1_dev"];
}
if (currentFilter.category.length === 0) {
currentFilter.category = ["CN", "KR", "JP", "WE", "Fictional"];
}
renderNav(navContainer, uniqueModels, uniqueCategories);
document.querySelectorAll('.skeleton-hide').forEach(el => {
el.classList.remove('skeleton-hide');
});
const searchInput = document.getElementById("search-input");
if (searchInput) {
searchInput.disabled = false;
searchInput.style.opacity = "1";
searchInput.style.animation = "none";
searchInput.style.pointerEvents = "auto";
searchInput.style.background = "#1e1e1e";
searchInput.style.borderColor = "#333";
searchInput.style.color = "#fff";
searchInput.placeholder = "Search title or LoRA...";
searchInput.addEventListener("input", (e) => {
searchQuery = e.target.value.toLowerCase().trim();
applyFilter();
});
}
const sortSelect = document.getElementById("sort-select");
if (sortSelect) {
sortSelect.addEventListener("change", (e) => {
currentSort = e.target.value;
applyFilter();
});
}
isFiltering = false;
applyFilter();
} catch (err) {
console.error(err);
container.innerHTML = `<div class="error" style="text-align:center; padding:2rem; color:#e55;">
<h3>Failed to load</h3>
<p>${escapeHtml(err.message)}</p>
<button onclick="location.reload()" style="margin-top:1rem; padding:0.5rem 1rem; cursor:pointer;">Refresh Page</button>
</div>`;
}
}
// ==========================================
// 🧩 Component: 骨架屏 (彻底防位移版)
// ==========================================
function renderFullSkeletons(container, navContainer) {
if (navContainer && navContainer.innerHTML === "") {
let fakeNavHtml = `
<!-- 1. Title 骨架 -->
<div class="sidebar-header" style="display: block; opacity: 1; ">
<div style="height: 26px; width: 85%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite;"></div>
</div>
<!-- 2. Search 骨架 -->
<div class="search-box" style="display: block; opacity: 1; margin-bottom: 0.6rem;">
<div style="height: 34px; width: 100%; background: #222; border-radius: 8px; animation: pulse 1.5s infinite;"></div>
</div>
<!-- 3. Base Model 骨架 -->
<div class="nav-section" style="margin-bottom: 0.5rem;">
<div style="height: 14px; width: 70px; background: #222; border-radius: 4px; margin-bottom: 0rem; animation: pulse 1.5s infinite;"></div>
<div class="nav-buttons" style="display: flex; flex-direction: column; gap: 0.4rem;">
`;
for (let i = 0; i < 6; i++) {
fakeNavHtml += `<div class="nav-btn-skeleton" style="background: #222; border: none; height: 30px; border-radius: 6px; animation: pulse 1.5s infinite; pointer-events: none;"></div>`;
}
fakeNavHtml += `</div></div>`;
// 4. Category 骨架
fakeNavHtml += `
<div class="nav-section" style="margin-bottom: -0.85rem;">
<div style="height: 14px; width: 60px; background: #222; border-radius: 4px; margin-bottom: 0rem; animation: pulse 1.5s infinite;"></div>
<div class="nav-buttons" style="display: flex; flex-direction: column; gap: 0.4rem;">
`;
for (let i = 0; i < 7; i++) {
fakeNavHtml += `<div class="nav-btn-skeleton" style="background: #222; border: none; height: 30px; border-radius: 6px; animation: pulse 1.5s infinite; pointer-events: none;"></div>`;
}
fakeNavHtml += `</div></div>`;
// 5. Footer 骨架 (完全对齐真实的 margin-top 1rem 及内部 gap)
fakeNavHtml += `
<div style="display: flex; flex-direction: column; padding-top: 1.25rem; border-top: 1px solid #2a2a2a; margin-top: 1rem;">
<div style="height: 14px; width: 75%; background: #222; border-radius: 4px; margin-bottom: 0.3rem; animation: pulse 1.5s infinite;"></div>
<div style="height: 14px; width: 85%; background: #222; border-radius: 4px; margin-bottom: 0.3rem; animation: pulse 1.5s infinite;"></div>
<div style="height: 14px; width: 60%; background: #222; border-radius: 4px; margin-bottom: 0.7rem; animation: pulse 1.5s infinite;"></div>
<div style="height: 12px; width: 90px; background: #222; border-radius: 6px; margin-left: auto; margin-top: 0.5rem; animation: pulse 1.5s infinite;"></div>
</div>
`;
navContainer.innerHTML = fakeNavHtml;
}
// 主内容区卡片骨架
let html = `<div class="grid">`;
for (let i = 0; i < 15; i++) {
html += `
<div class="card skeleton-card" style="pointer-events: none; padding: 0; overflow: hidden;">
<!-- 1. 图片骨架:严格贴合 3:4 比例,只有顶部圆角 -->
<div class="card-image-wrap" style="background: #1a1a1a; aspect-ratio: 3/4; border-radius: 10px 10px 0 0; margin-bottom: 0; animation: pulse 1.5s infinite;"></div>
<!-- 2. 信息区骨架:完美对齐你 CSS 里的 card-info 内边距 (0.6rem) -->
<div style="padding-top:0.6rem; padding-left: 0.6rem; padding-right: 0.6rem; padding-bottom: 0.6rem; display: flex; flex-direction: column; flex: 1;">
<!-- 模拟 LoRA 和 Trigger 区域 -->
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.6rem;">
<!-- LoRA -->
<div>
<div style="height: 10px; width: 30%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite; margin-bottom: 6px;"></div>
<div style="height: 32px; width: 100%; background: #2a2a2a; border-radius: 6px; animation: pulse 1.5s infinite;"></div>
</div>
<!-- Trigger -->
<div>
<div style="height: 10px; width: 30%; background: #222; border-radius: 4px; animation: pulse 1.5s infinite; margin-bottom: 6px;"></div>
<div style="height: 32px; width: 100%; background: #2a2a2a; border-radius: 6px; animation: pulse 1.5s infinite;"></div>
</div>
</div>
<!-- 模拟底部按钮区 (Margin-top: auto 保证推到最底部) -->
<div style="margin-top: auto; padding-top: 0.8rem; border-top: 1.1px solid #2a2a2a;">
<div style="height: 34px; width: 100%; background: #222; border-radius: 6px; animation: pulse 1.5s infinite;"></div>
</div>
</div>
</div>
`;
}
html += `</div>`;
container.innerHTML = html;
}
// ==========================================
// 📊 Data: 筛选与渲染主流程
// ==========================================
function applyFilter() {
// 1. 核心过滤
const itemsUnderCurrentNav = allItems.filter(c => {
const matchModel = currentFilter.model.length > 0 && currentFilter.model.includes(c.modelRaw);
const matchCat = currentFilter.category.length > 0 && currentFilter.category.includes(c.categoryRaw);
return matchModel && matchCat;
});
// 2. 搜索词过滤
filteredItems = itemsUnderCurrentNav.filter(c => {
if (searchQuery === "") return true;
const matchTitle = c.title.toLowerCase().includes(searchQuery);
const loraName = c.filename.replace(/\.safetensors$/i, "").toLowerCase();
const matchLora = loraName.includes(searchQuery);
return matchTitle || matchLora;
});
// 🌟 3. 排序逻辑 (支持 Default, A-Z, Newest)
// 🌟 3. 排序逻辑 (完善版:支持 Default, A-Z, Newest)
filteredItems.sort((a, b) => {
// 1. 默认排序 (Default)
if (currentSort === "default") {
// 返回 0 意味着保留数组原本的顺序(即来自 siteConfig 配置或 HF 仓库默认拉取的自然顺序)
return 0;
}
// 2. 按名字 A-Z 排序
else if (currentSort === "a-z") {
const strA = a.cleanTitle;
const strB = b.cleanTitle;
// 提取首字符
const charA = strA.charAt(0);
const charB = strB.charAt(0);
// 定义正则,用于判断字符类型
const isNum = /[0-9]/;
const isEn = /[a-zA-Z]/;
const isZh = /[\u4e00-\u9fa5]/;
// 辅助函数:获取权重 (数字: 1, 英文: 2, 中文: 3, 其他: 4)
const getWeight = (char) => {
if (isNum.test(char)) return 1;
if (isEn.test(char)) return 2;
if (isZh.test(char)) return 3;
return 4;
};
const weightA = getWeight(charA);
const weightB = getWeight(charB);
// 优先按权重排序(数字排最前,英文其次,中文最后)
if (weightA !== weightB) {
return weightA - weightB;
}
// 如果权重相同,说明处于同一类别,再调用 localeCompare 进行内部排序
if (weightA === 1 || weightA === 2) {
// 都是数字或都是英文:忽略大小写进行严格 A-Z 排序
return strA.localeCompare(strB, 'en', { numeric: true, sensitivity: 'base' });
} else if (weightA === 3) {
// 都是中文:按拼音 A-Z 排序
return strA.localeCompare(strB, 'zh-CN');
} else {
// 都是其他符号
return strA.localeCompare(strB);
}
}
// 3. 按最新上传时间排序
else if (currentSort === "newest") {
// 这里根据 HF 文件最后修改时间(时间戳)降序排列,最新的在最前面
const timeA = a.lastModified || 0;
const timeB = b.lastModified || 0;
return timeB - timeA;
}
// 兜底:如果 currentSort 出现未知的值,保持原样
return 0;
});
// 4. 渲染卡片
currentPage = 1;
isFiltering = true;
// 🌟 更新底部动态筛选结果
const countText = document.getElementById("selected-count-text");
if (countText) countText.textContent = filteredItems.length;
const container = document.getElementById("app");
if (!container) return;
if (filteredItems.length === 0) {
container.innerHTML = '<div class="error" style="text-align:center; padding:2rem;">No models found for this query.</div>';
isFiltering = false;
} else {
container.style.transition = 'opacity 0.2s ease-in-out';
container.style.opacity = '0';
setTimeout(() => {
renderGrid();
container.style.opacity = '1';
}, 200);
}
}
// ==========================================
// 🧩 Component: 渲染卡片网格
// ==========================================
function renderGrid() {
const container = document.getElementById("app");
if (!container) return;
container.innerHTML = `<div class="grid" id="card-grid"></div>`;
const grid = document.getElementById("card-grid");
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE);
let html = "";
itemsToRender.forEach((item, index) => {
const baseBadge = `<div class="card-badge badge-tl">${escapeHtml(item.modelLabel)}</div>`;
// --------------------------------------------------
// 🌟 按钮定义区 (集中在一起,方便管理)
// --------------------------------------------------
const datasetHtml = item.dataset
? `<button class="action-btn" onclick="openDatasetModal('${escapeAttr(item.dataset)}')">📊 Dataset</button>`
: "";
const usageHtml = item.guide
? `<button class="action-btn" onclick="openUsageModal('${escapeAttr(item.guide)}')">📖 Usage</button>`
: "";
// Showcase 单独占一行的样式
const showcaseRow = item.showcase
? `<button class="action-btn showcase-full-btn" onclick="openShowcaseModal('${escapeAttr(item.showcase)}')">✨ ComfyUI Showcase</button>`
: "";
// --------------------------------------------------
// 🌟 文本与内容处理区
// --------------------------------------------------
let triggerHtml = "";
// 辅助函数:检查末尾逗号
const formatTrigger = (text) => {
if (!text) return text;
let t = text.trim();
if (!t.endsWith(",")) {
t += ",";
}
return t;
};
if (item.trigger && item.trigger !== "-") {
if (Array.isArray(item.trigger)) {
item.trigger.forEach(t => {
const formattedT = formatTrigger(t);
triggerHtml += `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedT)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedT)}</span></div>`;
});
} else {
const formattedTrigger = formatTrigger(item.trigger);
triggerHtml = `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedTrigger)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedTrigger)}</span></div>`;
}
} else {
triggerHtml = `<span class="trigger-empty">-</span>`;
}
const loraName = item.filename.replace(/\.safetensors$/i, "");
const loraHtml = `<div class="path-box" onclick="navigator.clipboard.writeText('${escapeAttr(loraName)}'); const el=this.querySelector('.path-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="path-text">${escapeHtml(loraName)}</span></div>`;
// 首屏图片优先级判断
const isFirstScreen = currentPage === 1 && index < 8;
// const loadingAttr = isFirstScreen ? 'fetchpriority="high" loading="eager"' : 'loading="lazy"';
const loadingAttr = isFirstScreen ? 'fetchpriority="high" loading="eager"' : '';
// 注入弹簧动画初始状态
const springStyle = isFiltering ? `opacity: 0; transform: translateY(${ANIM_CONFIG.startY}px); will-change: transform, opacity;` : '';
const springAttr = isFiltering ? `data-spring="true" data-index="${index}"` : '';
// --------------------------------------------------
// 🌟 HTML 模板拼接
// --------------------------------------------------
html += `
<div class="card" style="${springStyle}" ${springAttr} onmouseenter="if(this.dataset.animating !== 'true') { const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(${ANIM_CONFIG.zoomScale})'; }"
onmouseleave="const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(1)';">
<div class="card-image-wrap img-skeleton">
${baseBadge}
<img class="preview reveal-img" data-src="${escapeAttr(item.previewUrl)}" alt="${escapeAttr(item.title)}" ${loadingAttr} decoding="async" onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');" onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" style="transition: transform ${ANIM_CONFIG.zoomDuration} ${ANIM_CONFIG.zoomEasing};" />
<div class="title-overlay"></div>
<div class="image-bottom-info"><div class="card-title">${escapeHtml(item.title)}</div><div class="card-category">${escapeHtml(item.categoryLabel)}</div></div>
</div>
<div class="card-info">
<div class="card-details">
<div class="detail-block"><div class="detail-label">LoRA:</div>${loraHtml}</div>
<div class="detail-block"><div class="detail-label">Trigger:</div><div class="trigger-list">${triggerHtml}</div></div>
</div>
<div class="card-actions">
${showcaseRow}
${datasetHtml}
${usageHtml}
<a href="${escapeAttr(item.downloadUrl)}" download class="action-btn primary">⬇️ Download</a>
</div>
</div>
</div>`;
});
grid.innerHTML = html;
observeImages(grid);
if (isFiltering) {
const cards = grid.querySelectorAll('.card[data-spring="true"]');
requestAnimationFrame(() => {
cards.forEach(card => {
setTimeout(() => {
applySpringAnimation(card);
}, parseInt(card.getAttribute('data-index')) * ANIM_CONFIG.staggerDelay);
});
});
isFiltering = false;
}
}
// 🌟 新增:全局图片懒加载监听器
let imgObserver = null;
function observeImages(container) {
if (!('IntersectionObserver' in window)) {
// 老浏览器兜底:直接加载
container.querySelectorAll('img[data-src]').forEach(img => {
if (img.dataset.src) img.src = img.dataset.src;
});
return;
}
// 只创建一次 Observer 实例,复用性能更好
if (!imgObserver) {
imgObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src; // 真正开始加载图片
}
imgObserver.unobserve(img); // 加载完就不再监听
}
});
}, { rootMargin: "600px" }); // 提前 200px 开始加载
}
// 把新渲染出来的图片加入监听
container.querySelectorAll('img[data-src]').forEach(img => imgObserver.observe(img));
}
// ==========================================
// 🪄 Animation: 物理弹簧动画
// ==========================================
function applySpringAnimation(element) {
if (!element || element.dataset.animating === 'true') return;
element.dataset.animating = 'true';
const startY = ANIM_CONFIG.startY;
const omega0 = Math.sqrt(ANIM_CONFIG.stiffness);
const zeta = ANIM_CONFIG.damping;
const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);
const startTime = performance.now();
element.style.transition = '';
element.style.opacity = '0';
element.style.transform = `translate3d(0, ${startY}px, 0)`;
element.style.willChange = 'transform, opacity';
element.style.backfaceVisibility = 'hidden';
void element.offsetWidth;
function step(currentTime) {
const t = (currentTime - startTime) / 1000;
const envelope = Math.exp(-zeta * omega0 * t);
if (Math.abs(envelope * startY) < 0.1) {
// 🌟 核心修复:不要用 translate3d,直接清空 transform,让父级彻底“干干净净”
element.style.transform = '';
element.style.opacity = 1;
element.style.willChange = 'auto';
element.style.transition = '';
element.dataset.animating = 'false';
return;
}
const positionY = startY * envelope * Math.cos(omegaD * t);
const opacity = Math.min(t / 0.25, 1);
element.style.transform = `translate3d(0, ${positionY}px, 0)`;
element.style.opacity = opacity;
requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// 🌟 新增:滑块的专用动画
function animateIndicator(indicator, targetY, targetHeight) {
if (!indicator) return;
// 取消正在进行的旧动画
if (indicator._anim) {
indicator._anim.cancel();
}
const startY = indicator.dataset.currentY ? parseFloat(indicator.dataset.currentY) : targetY;
// 高度直接瞬间改变,保证边框顺滑
indicator.style.height = `${targetHeight}px`;
indicator.style.opacity = "1";
// 🌟 使用原生 WAAPI,配合 ease-in-out 曲线
indicator._anim = indicator.animate(
[
{ transform: `translate3d(0, ${startY}px, 0)` },
{ transform: `translate3d(0, ${targetY}px, 0)` }
],
{
duration: 200, // 🌟 改这里:0.2s,干脆利落
easing: "cubic-bezier(0.25, 0.1, 0.25, 1)", // 🌟 偏向 ease-out 的平滑减速,比 pure ease-in-out 更敏捷
fill: "forwards"
}
);
// 记录最新的 Y 坐标
indicator.dataset.currentY = targetY;
}
// ==========================================
// 🧩 Component: 渲染导航
// ==========================================
function renderNav(container, models, categories) {
if (!container) return;
// 🌟 强制排序,确保按 JSON 顺序渲染
categories.sort((a, b) => {
const idxA = siteConfig.categories.findIndex(c => c.key.toLowerCase() === a.toLowerCase());
const idxB = siteConfig.categories.findIndex(c => c.key.toLowerCase() === b.toLowerCase());
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
// Base Model 区域
let modelHtml = `<div class="nav-section">
<div class="nav-label">BASE MODEL</div>
<div class="nav-buttons" id="nav-models">`;
models.forEach(m => {
const isActive = currentFilter.model.includes(m);
const count = allItems.filter(c => c.modelRaw === m).length;
const matchModel = siteConfig.models.find(x => x.key.toLowerCase() === m.toLowerCase());
const label = matchModel ? matchModel.label : m;
modelHtml += `<button class="nav-btn ${isActive ? 'active' : ''}" data-type="model" data-value="${escapeAttr(m)}">${escapeHtml(label)} <span>(${count})</span></button>`;
});
modelHtml += `</div></div>`;
// ==========================================
// Category 区域
// ==========================================
// 算出当前勾选的 Base Model 下有多少数据,用于动态更新 Category 的数字
const itemsUnderCurrentModel = allItems.filter(c => currentFilter.model.length === 0 || currentFilter.model.includes(c.modelRaw));
let catHtml = `<div class="nav-section">
<div class="nav-label">CATEGORY</div>
<div class="nav-buttons" id="nav-cats">`;
categories.forEach(cat => {
const count = itemsUnderCurrentModel.filter(c => c.categoryRaw === cat).length;
const isActive = currentFilter.category.includes(cat);
const matchCat = siteConfig.categories.find(c => c.key.toLowerCase() === cat.toLowerCase());
const label = matchCat ? matchCat.label : cat;
catHtml += `<button class="nav-btn ${isActive ? 'active' : ''}" data-type="category" data-value="${escapeAttr(cat)}">${escapeHtml(label)} <span>(${count})</span></button>`;
});
catHtml += `</div></div>`;
// 🌟 核心:必须把生成的 HTML 塞进容器里,不然页面什么都不显示!
container.innerHTML = modelHtml + catHtml;
// 绑定点击事件 (智能兜底)
container.querySelectorAll('.nav-btn').forEach(btn => {
btn.onclick = (e) => {
const targetBtn = e.target.closest('.nav-btn');
if (!targetBtn) return;
const type = targetBtn.getAttribute('data-type');
const value = targetBtn.getAttribute('data-value');
// 切换选中状态
if (currentFilter[type].includes(value)) {
currentFilter[type] = currentFilter[type].filter(v => v !== value);
} else {
currentFilter[type].push(value);
}
// 🌟 核心智能兜底逻辑 (互相联动)
if (type === 'model') {
// 选了底座,如果分类为空,自动全选分类
if (currentFilter.model.length > 0 && currentFilter.category.length === 0) {
currentFilter.category = [...categories];
}
// 底座全空,分类也跟着清空
if (currentFilter.model.length === 0) {
currentFilter.category = [];
}
}
if (type === 'category') {
// 选了分类,如果底座为空,自动全选底座
if (currentFilter.category.length > 0 && currentFilter.model.length === 0) {
currentFilter.model = [...models];
}
// 分类全空,底座也跟着清空
if (currentFilter.category.length === 0) {
currentFilter.model = [];
}
}
// 重新渲染界面(刷新高亮)
renderNav(container, models, categories);
// 触发过滤
applyFilter();
};
});
}
// ==========================================
// 📍 Event: 滚动无限加载
// ==========================================
let lastScrollY = window.scrollY;
window.addEventListener('scroll', () => {
// 🌟 2. 处理拦截逻辑(注意:这里 return 只拦截“无限加载”,不影响上面的游标)
if (isLoading || isFiltering) return;
const currentScrollY = window.scrollY;
if (currentScrollY <= lastScrollY) {
lastScrollY = currentScrollY;
return; // 向上滚动,不触发无限加载
}
lastScrollY = currentScrollY;
if (filteredItems.length === 0 || allItems.length === 0) return;
// 🌟 3. 触底判定与无限加载
const scrollPosition = window.innerHeight + currentScrollY;
const threshold = document.body.offsetHeight - 500;
if (scrollPosition >= threshold) {
const endIndex = currentPage * ITEMS_PER_PAGE;
if (endIndex < filteredItems.length) {
isLoading = true;
currentPage++;
const grid = document.getElementById("card-grid");
if (grid) {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const itemsToRender = filteredItems.slice(startIndex, startIndex + ITEMS_PER_PAGE);
let html = "";
itemsToRender.forEach(item => {
const baseBadge = `<div class="card-badge badge-tl">${escapeHtml(item.modelLabel)}</div>`;
// --------------------------------------------------
// 🌟 按钮定义区 (集中、工整)
// --------------------------------------------------
const datasetHtml = item.dataset
? `<button class="action-btn" onclick="openDatasetModal('${escapeAttr(item.dataset)}')">📊 Dataset</button>`
: "";
const usageHtml = item.guide
? `<button class="action-btn" onclick="openUsageModal('${escapeAttr(item.guide)}')">📖 Usage</button>`
: "";
const showcaseRow = item.showcase
? `<button class="action-btn showcase-full-btn" onclick="openShowcaseModal('${escapeAttr(item.showcase)}')">✨ ComfyUI Showcase</button>`
: "";
// --------------------------------------------------
// 🌟 文本与内容处理区
// --------------------------------------------------
let triggerHtml = "";
const formatTrigger = (text) => {
if (!text) return text;
let t = text.trim();
if (!t.endsWith(",")) {
t += ",";
}
return t;
};
if (item.trigger && item.trigger !== "-") {
if (Array.isArray(item.trigger)) {
item.trigger.forEach(t => {
const formattedT = formatTrigger(t);
triggerHtml += `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedT)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedT)}</span></div>`;
});
} else {
const formattedTrigger = formatTrigger(item.trigger);
triggerHtml = `<div class="trigger-box" onclick="navigator.clipboard.writeText('${escapeAttr(formattedTrigger)}'); const el=this.querySelector('.trigger-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="trigger-text">${escapeHtml(formattedTrigger)}</span></div>`;
}
} else {
triggerHtml = `<span class="trigger-empty">-</span>`;
}
const loraName = item.filename.replace(/\.safetensors$/i, "");
const loraHtml = `<div class="path-box" onclick="navigator.clipboard.writeText('${escapeAttr(loraName)}'); const el=this.querySelector('.path-text'); const o=el.innerText; el.innerText='Copied!'; setTimeout(()=>{el.innerText=o;}, 800);"><span class="path-text">${escapeHtml(loraName)}</span></div>`;
// --------------------------------------------------
// 🌟 HTML 模板拼接
// --------------------------------------------------
html += `
<div class="card">
<div class="card-image-wrap img-skeleton" onmouseenter="if(this.dataset.animating !== 'true') { const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(${ANIM_CONFIG.zoomScale})'; }" onmouseleave="const img = this.querySelector('.preview'); if(img) img.style.transform = 'scale(1)';">
${baseBadge}
<img class="preview reveal-img" data-src="${escapeAttr(item.previewUrl)}" alt="${escapeAttr(item.title)}" decoding="async" onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');" onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" style="transition: opacity 0.5s ease-in-out, transform ${ANIM_CONFIG.zoomDuration} ${ANIM_CONFIG.zoomEasing};" />
<div class="title-overlay"></div>
<div class="image-bottom-info"><div class="card-title">${escapeHtml(item.title)}</div><div class="card-category">${escapeHtml(item.categoryLabel)}</div></div>
</div>
<div class="card-info">
<div class="card-details">
<div class="detail-block"><div class="detail-label">LoRA:</div>${loraHtml}</div>
<div class="detail-block"><div class="detail-label">Trigger:</div><div class="trigger-list">${triggerHtml}</div></div>
</div>
<div class="card-actions">
${showcaseRow}
${datasetHtml}
${usageHtml}
<a href="${escapeAttr(item.downloadUrl)}" download class="action-btn primary">⬇️ Download</a>
</div>
</div>
</div>`;
});
grid.insertAdjacentHTML('beforeend', html);
observeImages(grid);
}
setTimeout(() => { isLoading = false; }, 500);
}
}
});
// ==========================================
// 🪟 Modal: Dataset/Usage/Showcase 弹窗逻辑 (彻底解决缩略图卡顿 + 滚动锁定)
// ==========================================
let currentModalData = { repoId: "", branch: "", images: [], currentIndex: 0 };
let currentGridPage = 1;
let GRID_ITEMS_PER_PAGE = 30;
// 🌟 动态计算列数和每页数量 (监听容器真实宽度)
function calculateGridLayout() {
const gridView = document.getElementById("grid-view");
if (!gridView) return;
// 使用 getBoundingClientRect 获取精确宽度
const containerWidth = gridView.getBoundingClientRect().width || 1200;
const minImgWidth = 120;
const gap = 12.8;
const columns = Math.max(1, Math.floor((containerWidth + gap) / (minImgWidth + gap)));
// 每页显示 3 行,正好填满,不会留空
GRID_ITEMS_PER_PAGE = columns * 3;
}
window.openShowcaseModal = async function(showcaseUrl) {
// 🌟 顶部动态 UI 更新
document.querySelector(".modal-header h3").textContent = "ComfyUI Showcase Viewer";
const infoLink = document.getElementById("modal-info-link");
const compareLink = document.getElementById("modal-compare-link");
// 设置右侧外链(真实的 Showcase 目录链接)
infoLink.href = showcaseUrl;
infoLink.target = "_blank";
infoLink.innerHTML = "🔗 View Showcase Folder ↗";
// 智能反查对应的 Dataset 链接
let inferredDatasetUrl = "";
try {
const currentItem = allItems.find(item => item.showcase === showcaseUrl);
if (currentItem && currentItem.dataset) {
inferredDatasetUrl = currentItem.dataset;
}
} catch (e) { console.warn("查找 Dataset 失败", e); }
if (inferredDatasetUrl) {
compareLink.href = `javascript:openDatasetModal('${inferredDatasetUrl}')`;
compareLink.innerHTML = "📊 Compare with Training Dataset";
compareLink.style.display = "inline-block";
} else {
compareLink.style.display = "none";
}
const modal = document.getElementById("dataset-modal");
//const repoLink = document.getElementById("modal-repo-link");
if (!modal) return;
modal.style.display = "flex";
document.body.style.overflow = "hidden";
// UI 切换:强制显示网格,隐藏详情,隐藏子文件夹选择器
document.getElementById("grid-view-container").style.display = "flex";
document.getElementById("detail-view").style.display = "none";
document.getElementById("back-to-grid").style.display = "none";
document.getElementById("select-container").style.display = "none";
// 🌟 核心修复:重新严谨解析 URL
let repoId = "waveshaker/loras"; // 默认兜底
let branch = "main";
let basePath = "";
try {
// 把 URL 去掉域名,变成 "waveshaker/loras/tree/main/comfyui_showcase/..."
const cleanPath = showcaseUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", "");
const pathParts = cleanPath.split("/");
// pathParts 应为: ["waveshaker", "loras", "tree", "main", "comfyui_showcase", "krea2", "LeeJian_KR2"]
const treeIndex = pathParts.indexOf("tree");
if (treeIndex !== -1) {
// repoId 是 tree 前面的部分(例如 waveshaker/loras)
repoId = pathParts.slice(0, treeIndex).join("/");
// branch 是 tree 后面的第一个元素(例如 main)
branch = pathParts[treeIndex + 1] || "main";
// basePath 是 branch 后面的所有部分
basePath = pathParts.slice(treeIndex + 2).join("/");
}
} catch (e) {
console.error("URL 解析失败:", e);
}
//repoLink.href = showcaseUrl;
console.log("解析结果 -> Repo:", repoId, "| Branch:", branch, "| BasePath:", basePath);
// 初始化当前弹窗的状态
currentModalData = {
repoId: repoId,
branch: branch,
images: [],
currentIndex: 0,
mode: 'showcase',
jsonData: []
};
const gridView = document.getElementById("grid-view");
gridView.innerHTML = `<div style="grid-column: 1/-1; width: 100%; text-align: center; color: #888; padding: 3rem; box-sizing: border-box;">Loading Showcase...</div>`;
try {
// 1. 获取 gallery.json
// 🌟 这里必须加上 encodeURIComponent 处理,防止中文或空格导致 404
// 拼出的 URL 示例:https://huggingface.co/waveshaker/loras/resolve/main/comfyui_showcase/krea2/LeeJian_KR2/gallery.json
const jsonUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/gallery.json`;
console.log("正在请求:", jsonUrl); // 你在 F12 控制台里应该能看到这个 URL 是否正确
const res = await fetch(jsonUrl);
if (!res.ok) throw new Error(`Failed to load gallery.json (HTTP ${res.status})`);
const galleryData = await res.json();
if (!Array.isArray(galleryData) || galleryData.length === 0) {
throw new Error("Gallery is empty or invalid format.");
}
// 2. 映射图片信息 (拼凑完整路径)
currentModalData.jsonData = galleryData;
currentModalData.images = galleryData.map(item => ({
path: `${basePath}/${item.file_name}`, // 组合成 相对路径,供大图展示
jsonInfo: item // 把该图片的专属参数存起来
}));
// 3. 调用现有的渲染函数
currentGridPage = 1;
document.getElementById("grid-pagination").style.display = "flex"; // 显示分页器
calculateGridLayout();
renderGridPage();
} catch (err) {
console.error(err);
gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #e55; padding: 2rem;">Failed: ${err.message}</div>`;
document.getElementById("grid-pagination").style.display = "none";
}
};
window.openDatasetModal = async function(datasetUrl) {
// 🌟 顶部动态 UI 更新
document.querySelector(".modal-header h3").textContent = "Dataset Viewer";
const infoLink = document.getElementById("modal-info-link");
const compareLink = document.getElementById("modal-compare-link");
// 设置右侧外链(真实的 Dataset 仓库链接)
infoLink.href = datasetUrl;
infoLink.target = "_blank";
infoLink.innerHTML = "🔗 View Dataset Repo ↗";
// 智能反查对应的 Showcase 链接
let inferredShowcaseUrl = "";
try {
const currentItem = allItems.find(item => item.dataset === datasetUrl);
if (currentItem && currentItem.showcase) {
inferredShowcaseUrl = currentItem.showcase;
}
} catch (e) { console.warn("查找 Showcase 失败", e); }
if (inferredShowcaseUrl) {
compareLink.href = `javascript:openShowcaseModal('${inferredShowcaseUrl}')`;
compareLink.innerHTML = "✨ Compare with ComfyUI Showcase";
compareLink.style.display = "inline-block";
} else {
compareLink.style.display = "none";
}
const modal = document.getElementById("dataset-modal");
const select = document.getElementById("folder-select");
//const repoLink = document.getElementById("modal-repo-link");
if (!modal) return;
modal.style.display = "flex";
document.body.style.overflow = "hidden";
document.getElementById("grid-view-container").style.display = "none";
document.getElementById("detail-view").style.display = "none";
document.getElementById("back-to-grid").style.display = "none";
document.getElementById("select-container").style.display = "flex";
select.innerHTML = `<option>Loading...</option>`;
let parts;
if (datasetUrl.startsWith("http")) {
const pathParts = new URL(datasetUrl).pathname.split("/").filter(Boolean);
parts = [pathParts[1], pathParts[2], pathParts[4], ...pathParts.slice(5)];
} else { parts = datasetUrl.split("/"); }
const repoId = `${parts[0]}/${parts[1]}`, branch = parts[2] || "main", basePath = parts.slice(3).join("/");
//repoLink.href = `https://huggingface.co/datasets/${repoId}/tree/${branch}/${basePath}`;
currentModalData = { repoId, branch, images: [], currentIndex: 0 };
try {
const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${basePath}`);
if (!res.ok) throw new Error(`API: ${res.status}`);
const files = await res.json();
const subfolders = files.filter(f => f.type === "directory").map(f => f.path);
// 🌟 修复 1:智能排序,处理 NaN 的情况
subfolders.sort((a, b) => {
const nameA = a.split("/").pop();
const nameB = b.split("/").pop();
// 提取开头的数字,如果没有数字则视为 0
const matchA = nameA.match(/^(\d+)/);
const matchB = nameB.match(/^(\d+)/);
const numA = matchA ? parseInt(matchA[1], 10) : 0;
const numB = matchB ? parseInt(matchB[1], 10) : 0;
// 1. 优先按数字降序排列 (核心:让最新的 5_Portrait 排在最上面)
if (numA !== numB) {
return numB - numA;
}
// 2. 如果数字相同,或者都没有数字,再按名字字母升序排列 (A-Z)
return nameA.localeCompare(nameB);
});
if (subfolders.length === 0) {
select.innerHTML = `<option value="${basePath}">Root</option>`;
loadFolderImages(basePath);
} else {
// 🌟 修复 2:给渲染出来的 option 绑定是否默认选中的状态
select.innerHTML = subfolders.map((f, index) => {
const isSelected = index === 0 ? "selected" : ""; // 默认选中第一个(也就是最大的)
return `<option value="${f}" ${isSelected}>${f.split("/").pop()}</option>`;
}).join('');
select.onchange = (e) => loadFolderImages(e.target.value);
// 触发加载第一个文件夹
loadFolderImages(subfolders[0]);
}
} catch (err) {
alert(`Failed to load dataset: ${err.message}`);
closeModal();
}
};
async function loadFolderImages(targetFolder) {
const { repoId, branch } = currentModalData;
const gridContainer = document.getElementById("grid-view-container");
const detailView = document.getElementById("detail-view");
const gridView = document.getElementById("grid-view");
gridContainer.style.display = "flex";
detailView.style.display = "none";
gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #888; padding: 3rem; box-sizing: border-box;">Loading Dataset...</div>`;
currentGridPage = 1;
try {
const res = await fetch(`https://huggingface.co/api/datasets/${repoId}/tree/${branch}/${targetFolder}`);
if (!res.ok) throw new Error(`Access failed (${res.status})`);
const files = await res.json();
const images = files.filter(f => f.type === "file" && /\.(jpg|jpeg)$/i.test(f.path));
if (images.length === 0) {
gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #888; padding: 2rem;">No .jpg images found.</div>`;
document.getElementById("grid-pagination").style.display = "none";
return;
}
currentModalData.images = images;
document.getElementById("grid-pagination").style.display = "flex";
// 🌟 关键:等待一帧,确保 DOM 已经渲染出真实的宽度,再执行计算和渲染
requestAnimationFrame(() => {
calculateGridLayout();
renderGridPage();
});
} catch (err) {
gridView.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: #e55; padding: 2rem;">Failed: ${err.message}</div>`;
}
}
window.openUsageModal = async function(guideUrl) {
const modal = document.getElementById("usage-modal");
const body = document.getElementById("usage-modal-body");
const title = document.getElementById("usage-modal-title");
const repoLink = document.getElementById("usage-repo-link");
if (!modal) return;
modal.style.display = "flex";
document.body.style.overflow = "hidden";
body.innerHTML = `<div style="text-align: center; color: #888; padding: 2rem;">Loading guide...</div>`;
// 🌟 核心修复:解析完整 URL,提取 repoId, branch 和 path
let repoId = "waveshaker/loras";
let branch = "main";
let basePath = "";
try {
// 变成 "waveshaker/loras/tree/main/usage_guide/10inPH_KR2"
const cleanPath = guideUrl.replace("https://huggingface.co/", "").replace("http://huggingface.co/", "");
const pathParts = cleanPath.split("/");
const treeIndex = pathParts.indexOf("tree");
if (treeIndex !== -1) {
repoId = pathParts.slice(0, treeIndex).join("/");
branch = pathParts[treeIndex + 1] || "main";
basePath = pathParts.slice(treeIndex + 2).join("/");
}
} catch (e) {
console.error("URL 解析失败:", e);
}
// 正确拼凑出 Resolve 下载链接
const mdUrl = `https://huggingface.co/${repoId}/resolve/${branch}/${basePath}/guide.md`;
repoLink.href = guideUrl; // 打开新窗口用的还是原网页链接
try {
const res = await fetch(mdUrl);
if (!res.ok) throw new Error("Guide not found");
const mdText = await res.text();
// 使用 marked.js 解析 Markdown 为 HTML
const htmlContent = marked.parse(mdText, { breaks: true });
// 提取路径的最后一部分作为标题
const guideName = basePath.split("/").pop();
title.textContent = `Usage Guide: ${guideName}`;
// 渲染 HTML
body.innerHTML = `<div class="markdown-body">${htmlContent}</div>`;
// 🌟 核心修复:处理 Usage 里的图片,加骨架和防抖动
const mdImages = body.querySelectorAll('.markdown-body img');
mdImages.forEach(img => {
// 1. 获取原始相对路径,并拼接成完整的绝对路径
const rawSrc = img.getAttribute('src');
if (!rawSrc) return;
// 如果是相对路径,根据你的 mdUrl 来拼凑
// 假设 mdUrl 是 https://huggingface.co/.../guide.md
// 那么图片应该去 https://huggingface.co/.../ 目录下找
const baseDir = mdUrl.substring(0, mdUrl.lastIndexOf('/') + 1);
const realSrc = rawSrc.startsWith('http') ? rawSrc : baseDir + rawSrc;
// 2. 移除原来的 src
img.removeAttribute('src');
// 3. 给图片包裹一个外层容器
const wrapper = document.createElement('div');
wrapper.className = 'md-img-wrapper img-skeleton';
// 给个默认的占位高度,或者你可以根据图片原始宽高比例来算
wrapper.style.minHeight = '200px';
// 4. 替换 DOM 结构
img.parentNode.insertBefore(wrapper, img);
wrapper.appendChild(img);
// 5. 定义加载逻辑
const loadImg = () => {
img.src = realSrc;
img.onload = () => {
wrapper.classList.remove('img-skeleton');
img.classList.add('loaded');
};
img.onerror = () => {
wrapper.classList.remove('img-skeleton');
img.classList.add('preview-missing');
console.warn("Usage image failed to load:", realSrc);
};
};
// 6. 监听 wrapper(因为 img 现在没有高度,监听 img 没用)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImg();
observer.unobserve(wrapper);
}
});
}, { rootMargin: "400px" });
observer.observe(wrapper); // 监听包裹容器
});
} catch (err) {
body.innerHTML = `<div style="text-align: center; color: #e55; padding: 2rem;">❌ Failed to load guide: ${err.message}</div>`;
}
};
// 关闭弹窗
document.getElementById("close-usage-modal").onclick = () => {
document.getElementById("usage-modal").style.display = "none";
document.body.style.overflow = "";
};
document.getElementById("usage-modal").onclick = (e) => {
if (e.target.id === "usage-modal") {
document.getElementById("usage-modal").style.display = "none";
document.body.style.overflow = "";
}
};
function renderGridPage() {
const { repoId, branch, images } = currentModalData;
const gridView = document.getElementById("grid-view");
const paginationContainer = document.getElementById("grid-pagination");
calculateGridLayout();
const totalPages = Math.ceil(images.length / GRID_ITEMS_PER_PAGE);
const startIndex = (currentGridPage - 1) * GRID_ITEMS_PER_PAGE;
const endIndex = startIndex + GRID_ITEMS_PER_PAGE;
const itemsToRender = images.slice(startIndex, endIndex);
let html = "";
itemsToRender.forEach((img, idx) => {
const globalIndex = startIndex + idx;
// 🌟 修改点 1:套上 wsrv.nl 代理,限制宽度,转 WebP
// 强制把空格转义,防止 Frame 412.jpg 这种文件名导致 403/404
// const safePath = img.path.replace(/ /g, "%20");
// const rawUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`;
// const proxiedUrl = `https://wsrv.nl/?url=${encodeURIComponent(rawUrl)}&w=200&output=webp&q=80`;
const safePath = img.path.replace(/ /g, "%20");
//const proxiedUrl = `https://huggingface.co/datasets/${repoId}/resolve/${branch}/${safePath}`;
const repoType = currentModalData.mode === 'showcase' ? '' : 'datasets/';
const proxiedUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`;
// 🌟 修改点 2:把 src 换成 data-src
html += `
<div class="grid-thumb-container img-skeleton">
<img class="grid-thumb reveal-img" data-src="${proxiedUrl}" onclick="openDetailView(${globalIndex})" alt="thumb"
onload="this.classList.add('loaded'); this.parentElement.classList.remove('img-skeleton');"
onerror="this.classList.remove('reveal-img'); this.classList.add('preview-missing'); this.parentElement.classList.remove('img-skeleton');" />
</div>
`;
});
gridView.innerHTML = html;
// 🌟 修改点 3:调用懒加载监听器
observeImages(gridView);
// 渲染高级分页器(保持原样)
renderPagination(paginationContainer, currentGridPage, totalPages, (newPage) => {
currentGridPage = newPage;
renderGridPage();
gridView.scrollTop = 0;
});
}
// 🌟 核心:智能分页器生成逻辑
function renderPagination(container, currentPage, totalPages, onPageChange) {
let html = "";
// 上一页按钮
html += `<button class="page-btn nav-arrow" ${currentPage === 1 ? 'disabled' : ''} data-page="${currentPage - 1}">&#10094;</button>`;
// 生成页码逻辑(1 ... 4 5 6 ... 20)
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
if (startPage > 1) {
html += `<button class="page-btn" data-page="1">1</button>`;
if (startPage > 2) html += `<span class="page-dots">...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += `<span class="page-dots">...</span>`;
html += `<button class="page-btn" data-page="${totalPages}">${totalPages}</button>`;
}
// 下一页按钮
html += `<button class="page-btn nav-arrow" ${currentPage === totalPages ? 'disabled' : ''} data-page="${currentPage + 1}">&#10095;</button>`;
// 底部小字显示总页数
html += `<span style="color: #888; font-size: 0.8rem; margin-left: 1rem;">Total: ${totalPages} pages</span>`;
container.innerHTML = html;
// 绑定点击事件
container.querySelectorAll('.page-btn').forEach(btn => {
btn.onclick = (e) => {
const page = parseInt(e.target.getAttribute('data-page'), 10);
if (page && page !== currentPage && !e.target.disabled) {
onPageChange(page);
}
};
});
}
window.openDetailView = function(index) {
// 🌟 核心:必须彻底隐藏网格容器!
document.getElementById("grid-view-container").style.display = "none";
// 显示详情页,变为 Flex 布局
document.getElementById("detail-view").style.display = "flex";
// 显示返回按钮,隐藏下拉框
document.getElementById("back-to-grid").style.display = "inline-block";
document.getElementById("select-container").style.display = "none";
currentModalData.currentIndex = index;
if (currentModalData.images.length > 1) {
document.getElementById("btn-prev").style.display = "flex";
document.getElementById("btn-next").style.display = "flex";
}
renderCurrentImage();
};
document.getElementById("back-to-grid").onclick = () => {
document.getElementById("grid-view-container").style.display = "flex";
document.getElementById("detail-view").style.display = "none";
document.getElementById("back-to-grid").style.display = "none";
// 🌟 关键:如果是 Dataset 模式才显示下拉框,Showcase 模式保持隐藏
if (currentModalData.mode !== 'showcase') {
document.getElementById("select-container").style.display = "flex";
}
};
// 在全局声明一个变量,记录当前正在预加载的临时图片对象
let currentPreloadImg = null;
function renderCurrentImage() {
const { repoId, branch, images, currentIndex, mode } = currentModalData;
const img = document.getElementById("viewer-img");
const txt = document.getElementById("viewer-txt");
const loading = document.getElementById("viewer-loading");
const copyBtn = document.getElementById("copy-caption-btn");
const charCountEl = document.getElementById("char-count");
const currentImage = images[currentIndex];
document.getElementById("image-counter").textContent = `${currentIndex + 1} / ${images.length}`;
// 1. 重置 UI 状态,显示扫光
loading.classList.add("viewer-loading-skeleton");
loading.style.display = "flex";
loading.innerHTML = "<span>Loading...</span>";
txt.textContent = "Loading...";
charCountEl.textContent = "0 Tokens";
copyBtn.textContent = "Copy";
copyBtn.classList.remove("copied");
// 2. 彻底隐藏旧图
img.style.display = "none";
// 3. 准备 URL (区分 Model Repo 和 Dataset Repo)
const safePath = currentImage.path.replace(/ /g, "%20");
// 🌟 核心修复:Showcase 在 Model Repo,Dataset 在 Dataset Repo
const repoType = mode === 'showcase' ? '' : 'datasets/';
const rawUrl = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safePath}`;
let imgUrl = rawUrl;
const requestedPath = currentImage.path;
// 🌟 核心保护:中断上一次未加载完的图片请求
if (currentPreloadImg) {
currentPreloadImg.onload = null; // 🌟 关键:移除加载完成的事件监听
currentPreloadImg.onerror = null; // 🌟 关键:移除加载失败的事件监听
currentPreloadImg.src = ""; // 清空 src 中断网络请求
}
currentPreloadImg = new Image();
currentPreloadImg.src = imgUrl;
currentPreloadImg.onload = () => {
// 如果加载完时,用户已经切到了别的图,则放弃这次渲染
if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return;
// 🌟 第一步:把 src 赋给真实的 img 标签,但先别显示它
img.src = imgUrl;
// 🌟 第二步:等待浏览器把这个图彻底解码完毕
img.decode().then(() => {
// 🌟 第三步:解码完毕,立刻瞬间显示,并隐藏 loading 扫光
img.style.display = "block";
loading.style.display = "none";
currentPreloadImg = null;
}).catch((err) => {
// 如果解码失败(极少情况),也让它显示出来
img.style.display = "block";
loading.style.display = "none";
currentPreloadImg = null;
console.warn("图片解码失败", err);
});
};
// 4. 获取文本 (区分 Showcase JSON 和 Dataset TXT)
const paramsPanel = document.getElementById("params-panel");
const dragTip = document.getElementById("drag-tip");
const promptLabel = document.querySelector(".caption-container label");
if (mode === 'showcase') {
txt.style.fontSize = "0.7rem";
txt.style.lineHeight = "1.4";
if (promptLabel) promptLabel.textContent = "PROMPT:";
if (dragTip) dragTip.style.display = "flex";
const info = currentImage.jsonInfo;
if (info) {
const promptText = info.pos || info.prompt || "No prompt provided.";
// txt.textContent = promptText;
txt.innerText = promptText;
const estimatedTokens = Math.ceil(promptText.trim().length / 4);
charCountEl.textContent = `~${estimatedTokens} Tokens`;
// 🌟 渲染参数面板
if (paramsPanel) {
let html = "";
// 辅助函数:生成统一的一行
const addRow = (key, value) => {
if (!value) return;
html += `
<div class="param-row">
<div class="param-key">${key}:</div>
<div class="param-value">${value}</div>
</div>`;
};
addRow("Size", info.size);
addRow("Model", info.model);
addRow("VAE", info.vae);
addRow("CLIP", info.clip);
addRow("Seed", info.seed);
addRow("Steps", info.steps);
addRow("CFG", info.cfg);
addRow("Sampler", info.sampler);
addRow("Scheduler", info.scheduler);
addRow("Denoise", info.denoise);
// LoRAs 特殊处理
if (info.loras && info.loras.length > 0) {
const loraList = Array.isArray(info.loras) ? info.loras : info.loras.split(',').map(s => s.trim());
let loraHtml = `
<div class="param-row">
<div class="param-key">LoRAs:</div>
<div class="lora-container">`;
loraList.forEach(lora => {
loraHtml += `<div class="param-value">${lora}</div>`;
});
loraHtml += `</div></div>`;
html += loraHtml;
}
paramsPanel.innerHTML = html;
paramsPanel.style.display = "flex";
}
} else {
txt.textContent = "No JSON data for this image.";
}
} else {
// 🌟 Dataset 模式:彻底隐藏专属组件,恢复默认状态
txt.style.fontSize = "0.75rem";
txt.style.lineHeight = "1.5";
if (promptLabel) promptLabel.textContent = "CAPTION:";
if (paramsPanel) paramsPanel.style.display = "none";
if (dragTip) dragTip.style.display = "none";
// 原有的 Dataset fetch .txt 逻辑
const txtFileName = currentImage.path.replace(/\.[^/.]+$/, ".txt");
fetch(`https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${txtFileName}`)
.then(res => {
if (!res.ok) return "Caption not found.";
return res.text();
})
.then(text => {
if (currentModalData.images[currentModalData.currentIndex].path !== requestedPath) return;
txt.textContent = text;
if (text !== "Caption not found.") {
const estimatedTokens = Math.ceil(text.trim().length / 4);
charCountEl.textContent = `~${estimatedTokens} Tokens`;
}
})
.catch(() => {
if (currentModalData.images[currentModalData.currentIndex].path === requestedPath) {
txt.textContent = "Failed to read text.";
}
});
}
// 5. 静默预加载前后张图片
// 🌟 清理上一次的全部缓存
if (!window._preloadImgs) window._preloadImgs = [];
window._preloadImgs.forEach(img => { img.src = ""; });
window._preloadImgs = [];
const nextIndex = (currentIndex + 1) % images.length;
const prevIndex = (currentIndex - 1 + images.length) % images.length;
const preloadImage = (path) => {
if (!path) return;
const safeP = path.replace(/ /g, "%20");
const raw = `https://huggingface.co/${repoType}${repoId}/resolve/${branch}/${safeP}`;
const img = new Image();
img.src = raw;
window._preloadImgs.push(img); // 🌟 存入数组等待下一次清理
};
if (images[nextIndex]) preloadImage(images[nextIndex].path);
if (images[prevIndex]) preloadImage(images[prevIndex].path);
}
document.getElementById("copy-caption-btn").onclick = function() {
const txt = document.getElementById("viewer-txt").textContent;
if (txt === "Loading..." || txt === "Caption not found.") return;
navigator.clipboard.writeText(txt).then(() => {
this.textContent = "Copied!";
this.classList.add("copied");
setTimeout(() => {
this.textContent = "Copy";
this.classList.remove("copied");
}, 1500);
});
};
function changeImage(dir) {
const { images, currentIndex } = currentModalData;
let n = currentIndex + dir;
if (n < 0) n = images.length - 1;
if (n >= images.length) n = 0;
currentModalData.currentIndex = n;
renderCurrentImage();
}
// 🌟 统一的关闭弹窗函数
function closeModal() {
document.getElementById("dataset-modal").style.display = "none";
document.getElementById("viewer-img").src = "";
document.body.style.overflow = ""; // 🌟 恢复主屏滚动
}
document.getElementById("btn-prev").onclick = () => changeImage(-1);
document.getElementById("btn-next").onclick = () => changeImage(1);
document.getElementById("close-modal").onclick = closeModal;
document.getElementById("dataset-modal").onclick = (e) => {
if (e.target.id === "dataset-modal") closeModal();
};
// 🌟 记录上一次的窗口宽度
let lastWindowWidth = window.innerWidth;
// 监听容器尺寸变化,自动重新排版(防抖处理)
let resizeTimer;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const gridContainer = document.getElementById("grid-view-container");
// 🌟 核心:只有窗口宽度真正变了才重绘,避免 display 切换引发闪烁
const currentWindowWidth = window.innerWidth;
if (currentWindowWidth === lastWindowWidth) return;
if (gridContainer && gridContainer.style.display !== "none" && currentModalData.images.length > 0) {
calculateGridLayout();
renderGridPage();
}
}, 150);
});
window.addEventListener('resize', () => {
lastWindowWidth = window.innerWidth;
});
window.addEventListener('DOMContentLoaded', () => {
const gridView = document.getElementById("grid-view");
if (gridView) resizeObserver.observe(gridView);
});
// ==========================================
// 🪟 弹窗: Disclaimer 弹窗逻辑
// ==========================================
const discModal = document.getElementById("disclaimer-modal");
const openDiscBtn = document.getElementById("open-disclaimer-btn");
const closeDiscBtn = document.getElementById("close-disclaimer-modal");
if (openDiscBtn && discModal) {
openDiscBtn.onclick = () => {
discModal.style.display = "flex";
document.body.style.overflow = "hidden"; // 🌟 锁定主屏滚动
};
}
if (closeDiscBtn && discModal) {
closeDiscBtn.onclick = () => {
discModal.style.display = "none";
document.body.style.overflow = ""; // 🌟 恢复主屏滚动
};
}
if (discModal) {
discModal.onclick = (e) => {
if (e.target.id === "disclaimer-modal") {
discModal.style.display = "none";
document.body.style.overflow = ""; // 🌟 恢复主屏滚动
}
};
}
// ==========================================
// 📍 Event:键盘左右键切换图片支持
// ==========================================
// 🌟 新增一个全局变量记录上次按键时间
let lastKeyTime = 0;
// 🌟 核心:定义一个全局标志位,记录是否在拖拽
window.isDragging = false;
// 监听全局拖拽状态(兜底)
window.addEventListener('dragstart', () => { window.isDragging = true; });
window.addEventListener('dragend', () => {
// 延迟 100ms 解除状态,防止拖拽释放瞬间按下按键
setTimeout(() => { window.isDragging = false; }, 100);
});
window.addEventListener('keydown', (e) => {
// 1. 如果弹窗没开,或者没进详情页,直接退出
const modal = document.getElementById("dataset-modal");
if (!modal || modal.style.display === "none") return;
const detailView = document.getElementById("detail-view");
if (!detailView || detailView.style.display === "none") return;
// 2. 如果是输入框,退出
const activeTag = document.activeElement.tagName;
if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') return;
// 🌟 3. 核心修复:如果正在拖拽图片,绝对不响应键盘!
if (window.isDragging) {
console.warn("正在拖拽中,忽略键盘事件防止卡死");
return;
}
// 4. 300ms 防连点
const now = Date.now();
if (now - lastKeyTime < 300) return;
// 5. 执行切图
if (e.key === "ArrowLeft") {
e.preventDefault();
lastKeyTime = now;
changeImage(-1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
lastKeyTime = now;
changeImage(1);
} else if (e.key === "Escape") {
closeModal();
}
});
// ==========================================
// 📍 Event: 移动端侧边栏抽屉交互
// ==========================================
const mobileMenuBtn = document.getElementById("mobile-menu-btn");
const mainSidebar = document.getElementById("main-sidebar");
if (mobileMenuBtn && mainSidebar) {
// 1. 点击按钮切换
mobileMenuBtn.onclick = (e) => {
e.stopPropagation(); // 阻止事件冒泡到 document
mainSidebar.classList.toggle("open");
};
// 2. 点击页面其他地方(比如卡片),自动关闭侧边栏
document.addEventListener("click", (e) => {
// 如果侧边栏是打开的,且用户点击的不是侧边栏内部,也不是菜单按钮
if (mainSidebar.classList.contains("open") &&
!mainSidebar.contains(e.target) &&
e.target !== mobileMenuBtn) {
mainSidebar.classList.remove("open");
}
});
}
// ==========================================
// 🛠️ Utils:核心工具函数
// ==========================================
async function fetchWithRetry(url, options, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
if (i === retries - 1) throw err;
console.warn(`Fetch failed for ${url}, retrying in ${delay}ms... (Attempt ${i + 1}/${retries})`);
await new Promise(r => setTimeout(r, delay));
}
}
}
function formatModelName(dir) {
if (!dir) return "Other";
// 去动态配置里找对应的 label
const match = siteConfig.models.find(m => m.key.toLowerCase() === dir.toLowerCase());
if (match) return match.label;
return dir.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function formatCategory(dir) {
if (!dir) return "Unknown";
const match = siteConfig.categories.find(c => c.key.toLowerCase() === dir.toLowerCase());
if (match) return match.label;
return dir;
}
function encodePath(path) {
// 🌟 核心修复:先把原路径里的空格全部替换成 %20,然后再进行 encodeURIComponent
return path.split("/").map(seg => encodeURIComponent(seg.replace(/ /g, "%20"))).join("/");
}
function escapeHtml(str) {
if (str === null || str === undefined) return "";
return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escapeAttr(str) { return escapeHtml(str); }
// ==========================================
// 🚀 Launch: 启动
// ==========================================
main();