199 lines
7.0 KiB
JavaScript
199 lines
7.0 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const grid = document.querySelector("#project-grid");
|
|
const refreshButton = document.querySelector("#refresh-projects");
|
|
const status = document.querySelector("#refresh-status");
|
|
const projectCounts = document.querySelectorAll("[data-project-count]");
|
|
const liveStatus = document.querySelector("#live-status");
|
|
const liveStatusLabel = document.querySelector("#live-status-label");
|
|
|
|
if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
|
|
return;
|
|
}
|
|
|
|
document.body.classList.add("is-enhanced");
|
|
|
|
const createProjectCard = (project) => {
|
|
if (
|
|
typeof project.name !== "string" ||
|
|
typeof project.route_path !== "string" ||
|
|
!project.route_path.startsWith("/") ||
|
|
project.route_path.startsWith("//")
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const link = document.createElement("a");
|
|
link.className = "site-item";
|
|
link.href = project.route_path;
|
|
link.title = project.name;
|
|
|
|
const cardTop = document.createElement("div");
|
|
cardTop.className = "site-card-top";
|
|
|
|
const avatar = document.createElement("span");
|
|
avatar.className = "site-avatar";
|
|
avatar.setAttribute("aria-hidden", "true");
|
|
avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
|
|
|
|
const open = document.createElement("span");
|
|
open.className = "site-open";
|
|
open.setAttribute("aria-hidden", "true");
|
|
open.innerHTML = `
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M7 17 17 7M8 7h9v9"></path>
|
|
</svg>
|
|
`;
|
|
cardTop.append(avatar, open);
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "site-info";
|
|
|
|
const name = document.createElement("h3");
|
|
name.className = "site-name";
|
|
name.textContent = project.name;
|
|
|
|
const description = document.createElement("span");
|
|
description.className = "site-desc";
|
|
description.textContent = "本地静态应用";
|
|
info.append(name, description);
|
|
|
|
const footer = document.createElement("div");
|
|
footer.className = "site-card-footer";
|
|
|
|
const path = document.createElement("span");
|
|
path.className = "site-path";
|
|
path.textContent = project.route_path;
|
|
|
|
const ready = document.createElement("span");
|
|
ready.className = "site-ready";
|
|
ready.textContent = "READY";
|
|
|
|
footer.append(path, ready);
|
|
link.append(cardTop, info, footer);
|
|
return link;
|
|
};
|
|
|
|
let renderedSignature = JSON.stringify(
|
|
Array.from(grid.querySelectorAll(".site-item")).map((link) => [
|
|
link.title,
|
|
link.getAttribute("href"),
|
|
]),
|
|
);
|
|
|
|
const projectsSignature = (projects) =>
|
|
JSON.stringify(
|
|
projects.map((project) => [project.name, project.route_path]),
|
|
);
|
|
|
|
const renderProjects = (projects) => {
|
|
const nextSignature = projectsSignature(projects);
|
|
if (nextSignature === renderedSignature) {
|
|
return false;
|
|
}
|
|
|
|
grid.replaceChildren();
|
|
|
|
if (projects.length === 0) {
|
|
const emptyState = document.createElement("div");
|
|
emptyState.className = "empty-state";
|
|
emptyState.innerHTML = `
|
|
<span class="empty-visual" aria-hidden="true">
|
|
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>
|
|
<path d="M8 13h8M8 16h5"></path>
|
|
</svg>
|
|
</span>
|
|
<h3>这里还没有项目</h3>
|
|
<p>在托管根目录下创建子目录,并放入 <code>index.html</code>,刷新后即可在这里看到它。</p>
|
|
`;
|
|
grid.append(emptyState);
|
|
renderedSignature = nextSignature;
|
|
return true;
|
|
}
|
|
|
|
const cards = projects.map(createProjectCard).filter(Boolean);
|
|
grid.append(...cards);
|
|
renderedSignature = nextSignature;
|
|
return true;
|
|
};
|
|
|
|
const updateProjectCounts = (count) => {
|
|
projectCounts.forEach((element) => {
|
|
element.textContent = String(count);
|
|
});
|
|
};
|
|
|
|
refreshButton.addEventListener("click", async () => {
|
|
refreshButton.disabled = true;
|
|
refreshButton.classList.add("is-loading");
|
|
grid.setAttribute("aria-busy", "true");
|
|
status.textContent = "正在重新扫描项目...";
|
|
|
|
try {
|
|
const response = await fetch("/api/projects", {
|
|
cache: "no-store",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
if (!Array.isArray(payload.projects)) {
|
|
throw new Error("项目列表格式无效");
|
|
}
|
|
|
|
const changed = renderProjects(payload.projects);
|
|
updateProjectCounts(payload.projects.length);
|
|
status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
|
|
} catch (error) {
|
|
console.error("刷新项目列表失败", error);
|
|
status.textContent = "刷新失败,请稍后重试";
|
|
} finally {
|
|
refreshButton.disabled = false;
|
|
refreshButton.classList.remove("is-loading");
|
|
grid.removeAttribute("aria-busy");
|
|
}
|
|
});
|
|
|
|
if (!("EventSource" in window)) {
|
|
liveStatusLabel.textContent = "手动刷新";
|
|
return;
|
|
}
|
|
|
|
const eventSource = new EventSource("/api/projects/events");
|
|
|
|
eventSource.addEventListener("open", () => {
|
|
liveStatusLabel.textContent = "实时同步";
|
|
liveStatus.classList.add("is-live");
|
|
liveStatus.classList.remove("is-reconnecting");
|
|
});
|
|
|
|
eventSource.addEventListener("projects", (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data);
|
|
if (!Array.isArray(payload.projects)) {
|
|
throw new Error("项目列表格式无效");
|
|
}
|
|
|
|
const changed = renderProjects(payload.projects);
|
|
updateProjectCounts(payload.projects.length);
|
|
if (changed) {
|
|
status.textContent = "检测到目录变化,项目列表已自动更新";
|
|
}
|
|
} catch (error) {
|
|
console.error("处理项目更新事件失败", error);
|
|
status.textContent = "收到项目更新,但无法解析";
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener("error", () => {
|
|
liveStatusLabel.textContent = "同步重连中";
|
|
liveStatus.classList.remove("is-live");
|
|
liveStatus.classList.add("is-reconnecting");
|
|
});
|
|
})();
|