Initial stun navigation app

This commit is contained in:
zeer
2026-07-12 16:22:32 +08:00
commit 8570eb84da
12 changed files with 2086 additions and 0 deletions

118
public/app.js Normal file
View File

@@ -0,0 +1,118 @@
const servicesEl = document.querySelector("#services");
const statusBar = document.querySelector("#statusBar");
const searchInput = document.querySelector("#searchInput");
const refreshButton = document.querySelector("#refreshButton");
const groupTemplate = document.querySelector("#groupTemplate");
const cardTemplate = document.querySelector("#cardTemplate");
const appBrand = document.querySelector("#appBrand");
const appTitle = document.querySelector("#appTitle");
const appSubtitle = document.querySelector("#appSubtitle");
let services = [];
let settings = {};
function formatTime(value) {
if (!value) return "尚未更新";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function setStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.classList.toggle("error", isError);
}
function applySettings(nextSettings = {}) {
settings = nextSettings;
document.title = settings.browserTitle || "家中服务导航";
appBrand.textContent = settings.brand || "STUN NAV";
appTitle.textContent = settings.title || "家中服务";
appSubtitle.textContent = settings.subtitle || "";
appSubtitle.hidden = !appSubtitle.textContent.trim();
}
function serviceMatches(service, keyword) {
if (!keyword) return true;
const text = `${service.name} ${service.group} ${service.note} ${service.url}`.toLowerCase();
return text.includes(keyword.toLowerCase());
}
function groupServices(items) {
return items.reduce((groups, service) => {
const group = service.group || "默认";
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(service);
return groups;
}, new Map());
}
async function copyUrl(url, button) {
await navigator.clipboard.writeText(url);
const oldTitle = button.title;
button.title = "已复制";
setTimeout(() => {
button.title = oldTitle;
}, 1200);
}
function render() {
const keyword = searchInput.value.trim();
const filtered = services.filter((service) => serviceMatches(service, keyword));
servicesEl.replaceChildren();
if (!filtered.length) {
setStatus(services.length ? "没有匹配的服务" : "还没有收到 Lucky 推送的地址");
return;
}
setStatus(`${filtered.length} 个服务可用,最近刷新 ${formatTime(new Date().toISOString())}`);
for (const [group, items] of groupServices(filtered)) {
const groupNode = groupTemplate.content.firstElementChild.cloneNode(true);
groupNode.querySelector("h2").textContent = group;
const grid = groupNode.querySelector(".grid");
for (const service of items) {
const card = cardTemplate.content.firstElementChild.cloneNode(true);
card.querySelector(".service-mark").dataset.initial = (service.name || service.id || "?").trim().slice(0, 1).toUpperCase();
card.querySelector("h3").textContent = service.name;
card.querySelector(".note").textContent = service.note || "";
card.querySelector(".meta").textContent = `${service.url} · ${formatTime(service.updatedAt)}`;
const openButton = card.querySelector(".open-button");
openButton.href = service.url;
const copyButton = card.querySelector(".copy-button");
copyButton.addEventListener("click", () => copyUrl(service.url, copyButton));
grid.append(card);
}
servicesEl.append(groupNode);
}
}
async function loadServices() {
try {
setStatus("正在读取最新地址...");
const response = await fetch("/api/services", { cache: "no-store" });
if (!response.ok) throw new Error(`读取失败:${response.status}`);
const data = await response.json();
services = Array.isArray(data.services) ? data.services : [];
applySettings(data.settings || {});
render();
} catch (error) {
setStatus(error.message || "读取失败", true);
}
}
searchInput.addEventListener("input", render);
refreshButton.addEventListener("click", loadServices);
loadServices();
setInterval(loadServices, 30000);