-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
297 lines (286 loc) · 18.9 KB
/
Copy pathapp.js
File metadata and controls
297 lines (286 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
import { siteConfig as config } from "./site.config.js";
const icons = {
back: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14.5 5-7 7 7 7M8 12h9"/></svg>',
arrow: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 19 19 5M8 5h11v11"/></svg>',
sun: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>',
moon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5 8.5 8.5 0 1 0 20.5 15.2Z"/></svg>',
github: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 19c-4 1.2-4-2-5.5-2.5M15.5 21v-3.1a2.7 2.7 0 0 0-.8-2.1c2.7-.3 5.5-1.3 5.5-6A4.6 4.6 0 0 0 19 6.6 4.3 4.3 0 0 0 18.9 3s-1-.3-3.4 1.3a11.7 11.7 0 0 0-6.1 0C7 2.7 6 3 6 3a4.3 4.3 0 0 0-.1 3.6 4.6 4.6 0 0 0-1.2 3.2c0 4.7 2.8 5.7 5.5 6a2.7 2.7 0 0 0-.8 2.1V21"/></svg>',
wechat: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.5 5.2C10.2 2.4 4 4.2 4 8.7c0 2.4 2.1 4.3 4.8 4.8l-.6 2.1 2.8-1.6c.8.2 1.7.3 2.5.2"/><path d="M12.4 12.1c0-3.2 3-5.6 6.3-5.1 2.2.3 3.9 1.8 3.9 4 0 3-3 5.1-6 4.3l-2.5 1.4.5-1.9c-1.4-.8-2.2-1.7-2.2-2.8Z"/><path d="M8.2 8.7h.01M11.6 8.7h.01M16.7 11.5h.01M19.5 11.5h.01"/></svg>',
qq: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="10" r="5.5"/><path d="M14.9 14.1 20 20M11 7.5v5M8.5 10h5"/></svg>',
mail: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/></svg>',
screen: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="12" rx="4"/><path d="M8 3v3M16 3v3M9 12h.01M15 12h.01"/></svg>',
link: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13.8a4 4 0 0 0 5.7.1l2.1-2.1a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10.2a4 4 0 0 0-5.7-.1l-2.1 2.1a4 4 0 0 0 5.7 5.7l1.2-1.2"/></svg>',
blocks: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="8.5" y="13" width="7" height="7" rx="1"/></svg>',
paper: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h9l3 3v15H6z"/><path d="M15 3v4h4M9 12h6M9 16h6"/></svg>',
archive: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4zM3 4h18v3H3zM10 12h4"/></svg>',
spark: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2 1.7 6.3L20 10l-6.3 1.7L12 18l-1.7-6.3L4 10l6.3-1.7ZM19 16l.7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7Z"/></svg>',
people: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><path d="M3.5 20c.4-3.5 2.3-5.3 5.5-5.3s5.1 1.8 5.5 5.3M16 5.5a2.5 2.5 0 0 1 0 5M16.5 14.8c2.3.2 3.7 1.9 4 5.2"/></svg>',
cube: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/></svg>',
shield: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 19 6v5c0 4.3-2.7 7.8-7 10-4.3-2.2-7-5.7-7-10V6Z"/><path d="m8.5 12 2.2 2.2 4.8-4.8"/></svg>',
pulse: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l2-7 4 14 2-7h6"/></svg>',
server: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>',
cloud: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>',
database: '<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5.5" rx="8" ry="2.8"/><path d="M4 5.5v13c0 1.55 3.58 2.8 8 2.8s8-1.25 8-2.8v-13M4 12c0 1.55 3.58 2.8 8 2.8s8-1.25 8-2.8"/></svg>',
terminal: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 7 5 5-5 5M12 17h7"/></svg>',
code: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8.5 8-4.5 4 4.5 4M15.5 8l4.5 4-4.5 4"/></svg>',
chart: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7.5 20v-6M12 20V7M16.5 20v-9"/></svg>',
globe: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a13.4 13.4 0 0 1 0 18 13.4 13.4 0 0 1 0-18Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 9a15 15 0 0 1 19 0M5.5 12.5a10 10 0 0 1 13 0M8.7 16a5 5 0 0 1 6.6 0M12 19.5h.01"/></svg>',
play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5Z"/></svg>',
music: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>',
image: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="9.5" r="1.5"/><path d="m4.5 17.5 4.5-4.5 4 4M14 15l2.5-2.5 4 4"/></svg>',
book: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 19.5A2.5 2.5 0 0 1 7.5 17H20"/><path d="M7.5 2H20v20H7.5A2.5 2.5 0 0 1 5 19.5v-15A2.5 2.5 0 0 1 7.5 2Z"/></svg>',
chat: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
search: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20.5 20.5-4-4"/></svg>',
download: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v11M7.5 9.5 12 14l4.5-4.5M4 20h16"/></svg>',
pen: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5ZM14.5 5.5l4 4"/></svg>',
calendar: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg>',
clock: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>',
folder: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18.5v-13A1.5 1.5 0 0 1 4.5 4h4L11 7h8.5A1.5 1.5 0 0 1 21 8.5v10a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5Z"/></svg>',
gamepad: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="8" width="19" height="10" rx="5"/><path d="M7 10.5v5M4.5 13h5M15.5 11.5h.01M18 14.5h.01"/></svg>',
bulb: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 18h5M10.5 21h3M12 3a6 6 0 0 1 3.7 10.7c-.7.55-.7 1.3-.7 2.3h-6c0-1 0-1.75-.7-2.3A6 6 0 0 1 12 3Z"/></svg>',
star: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z"/></svg>',
heart: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 14c1.5-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.5 4.04 3 5.5l7 7Z"/></svg>',
};
const external = (url) => /^https?:/i.test(url) ? ' target="_blank" rel="noreferrer"' : "";
const safe = (text) => String(text).replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[char]);
const icon = (name) => {
if (typeof name === "object" && name?.paths) {
const viewBox = safe(name.viewBox || "0 0 24 24");
return `<svg viewBox="${viewBox}" aria-hidden="true">${name.paths.map((path) => `<path d="${safe(path)}"/>`).join("")}</svg>`;
}
return icons[name] || icons.link;
};
const sourceUrl = (source) => {
if (typeof source === "string") return source;
if (!source) return "";
if (source.url) return source.url;
if (source.local?.startsWith("./public/")) return source.local.slice("./public".length);
return source.local || "";
};
const themedAsset = (asset, mode) => sourceUrl(typeof asset === "string" ? asset : asset?.[mode] || asset?.light || asset?.dark || asset);
const avatarFor = (mode) => themedAsset(config.profile.avatar, mode);
// 压力数据只允许在 Vite 开发服务器中启用,生产构建会固定关闭。
const stressMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("stress");
const stressItems = (kind) => Array.from({ length: 14 }, (_, index) => ({
label: `${kind} 压力测试条目 ${String(index + 1).padStart(2, "0")}:用于验证极长中英文混合名称不会挤压布局或截断交互区域`,
description: "this-is-an-intentionally-very-long-description-for-layout-and-rendering-performance-validation.example.com",
url: "https://example.com/",
icon: kind === "项目" ? "spark" : "paper",
}));
let renderedCollections = {};
let overviewCounts = {};
function resourceList(items) {
return items.map(({ label, description, url, icon: iconName }) => `
<a class="resource" href="${url}"${external(url)} title="${safe(label)}${description ? ` — ${safe(description)}` : ""}">
<span class="resource-icon">${icon(iconName)}</span>
<span><strong>${safe(label)}</strong><small>${safe(description || "")}</small></span>
<span class="outbound">${icon("arrow")}</span>
</a>`).join("");
}
function resourceGroup(title, items, key, limit) {
const preview = items.slice(0, limit);
const remaining = items.length - preview.length;
return `<div class="content-group"><h2>${title}${stressMode ? " · 压力测试" : ""}</h2>${resourceList(preview)}${remaining > 0 ? `<button class="group-more" data-collection="${key}">查看全部 <span>${items.length}</span>${icon("arrow")}</button>` : ""}</div>`;
}
function overviewMarkup() {
const sites = renderedCollections.sites || [];
const projects = renderedCollections.projects || [];
return `${sites.length ? resourceGroup("站点", sites, "sites", overviewCounts.sites || 0) : ""}${projects.length ? resourceGroup("项目", projects, "projects", overviewCounts.projects || 0) : ""}`;
}
function socialLink(item) {
return `<a class="social" href="${item.url}"${external(item.url)} aria-label="${safe(item.label)}">
<span>${icon(item.icon)}</span><em>${safe(item.label)}</em>
</a>`;
}
function render() {
const sites = stressMode ? [...(config.sites || []), ...stressItems("站点")] : (config.sites || []);
const projects = stressMode ? [...(config.projects || []), ...stressItems("项目")] : (config.projects || []);
renderedCollections = { sites: sites || [], projects: projects || [] };
overviewCounts = { sites: 0, projects: 0 };
const compliance = [config.compliance?.icp, config.compliance?.publicSecurity].filter((item) => item?.label);
const complianceHtml = compliance.length ? `<div class="compliance">${compliance.map((item, index) => `<a href="${item.url}" target="_blank" rel="noreferrer">${index === 1 ? icon("shield") : ""}${safe(item.label)}</a>`).join("")}</div>` : "";
const poweredBy = config.poweredBy?.show !== false && config.poweredBy?.url ? `<a class="repository" href="${config.poweredBy.url}" target="_blank" rel="noreferrer">${icon("github")}由 SimpleHomepage 驱动</a>` : "";
document.documentElement.style.setProperty("--accent-light", config.theme.accent.light);
document.documentElement.style.setProperty("--accent-dark", config.theme.accent.dark);
document.title = config.seo?.title || config.siteName || config.profile.name;
document.querySelector("#app").innerHTML = `
<section class="page-shell">
<header><button id="theme-toggle" class="theme-toggle" aria-label="切换颜色模式">${icon("sun")}<span>浅色</span></button></header>
<div class="main-grid">
<section class="identity" aria-label="个人介绍">
<img class="avatar" src="${avatarFor("light")}" alt="${safe(config.profile.name)}的头像" />
<p class="eyebrow">${safe(config.profile.handle)}</p>
<h1>${safe(config.profile.name)}</h1>
<p class="introduction">${safe(config.profile.introduction)}</p>
<p class="quote"><span id="typed-quote"></span><i aria-hidden="true"></i></p>
<section class="social-section"><p>联系</p><div class="social-wrap"><nav class="socials" aria-label="社交账号">${config.socials.map(socialLink).join("")}</nav><button id="more-button" class="more-button" aria-expanded="false" hidden>更多 <span></span></button><div id="more-popover" class="more-popover" aria-hidden="true"></div></div></section>
</section>
<section class="content" aria-label="站点与项目">
<div id="content-overview" class="content-view content-overview"></div>
<div id="content-detail" class="content-view content-detail" aria-hidden="true">
<div class="detail-heading"><button id="collection-back" class="collection-back">${icon("back")}<span>返回</span></button><h2 id="collection-title"></h2></div>
<div id="collection-list" class="collection-list"></div>
</div>
</section>
</div>
<footer><div class="footer-bottom">${complianceHtml}<span class="footer-meta">${poweredBy}© ${new Date().getFullYear()} ${safe(config.profile.name)}</span></div></footer>
</section>`;
}
function setupTheme() {
const key = "simple-homepage-theme";
const stored = localStorage.getItem(key) || config.theme.defaultMode;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const resolve = (mode) => mode === "system" ? (media.matches ? "dark" : "light") : mode;
let mode = stored;
const apply = () => {
const resolved = resolve(mode);
document.documentElement.dataset.theme = resolved;
document.querySelector("meta[name='theme-color']").content = resolved === "dark" ? "#121212" : "#f6f6f4";
const button = document.querySelector("#theme-toggle");
button.innerHTML = `${icon(resolved === "dark" ? "moon" : "sun")}<span>${resolved === "dark" ? "深色" : "浅色"}</span>`;
const avatar = document.querySelector(".avatar");
if (avatar) avatar.src = avatarFor(resolved);
const favicon = document.querySelector("link[rel='icon']");
if (favicon) favicon.href = themedAsset(config.favicon, resolved);
};
apply();
document.querySelector("#theme-toggle").addEventListener("click", () => {
mode = resolve(mode) === "dark" ? "light" : "dark";
localStorage.setItem(key, mode);
apply();
});
media.addEventListener("change", () => { if (mode === "system") apply(); });
}
function setupQuote() {
const target = document.querySelector("#typed-quote");
const quotes = config.quotes?.filter(Boolean) || [];
if (!quotes.length) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { target.textContent = quotes[0]; return; }
let quoteIndex = 0, charIndex = 0, deleting = false;
const tick = () => {
const quote = quotes[quoteIndex];
target.textContent = quote.slice(0, charIndex);
let delay = deleting ? 55 : 125;
if (!deleting && charIndex < quote.length) charIndex++;
else if (!deleting) { deleting = true; delay = 2600; }
else if (charIndex > 0) charIndex--;
else { deleting = false; quoteIndex = (quoteIndex + 1) % quotes.length; delay = 600; }
window.setTimeout(tick, delay);
};
tick();
}
render();
setupTheme();
setupQuote();
function setupSocialOverflow() {
const wrap = document.querySelector(".social-wrap");
const nav = document.querySelector(".socials");
const button = document.querySelector("#more-button");
const popover = document.querySelector("#more-popover");
const links = [...nav.querySelectorAll(".social")];
const close = () => {
popover.classList.remove("is-open");
popover.setAttribute("aria-hidden", "true");
button.setAttribute("aria-expanded", "false");
};
const update = () => {
close();
links.forEach((link) => { link.hidden = false; });
button.hidden = true;
if (nav.scrollWidth > nav.clientWidth) {
button.hidden = false;
while (nav.scrollWidth > nav.clientWidth && links.some((link) => !link.hidden)) {
[...links].reverse().find((link) => !link.hidden).hidden = true;
}
}
const hiddenItems = links.filter((link) => link.hidden);
button.querySelector("span").textContent = `+${hiddenItems.length}`;
popover.innerHTML = hiddenItems.map((link) => {
const clone = link.cloneNode(true);
clone.hidden = false;
return clone.outerHTML;
}).join("");
if (!hiddenItems.length) button.hidden = true;
};
button.addEventListener("click", () => {
const opening = !popover.classList.contains("is-open");
popover.classList.toggle("is-open", opening);
popover.setAttribute("aria-hidden", String(!opening));
button.setAttribute("aria-expanded", String(opening));
});
document.addEventListener("click", (event) => { if (!wrap.contains(event.target)) close(); });
document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); });
new ResizeObserver(update).observe(wrap);
update();
}
setupSocialOverflow();
function setupOverviewCapacity() {
const content = document.querySelector(".content");
const overview = document.querySelector("#content-overview");
const keys = ["sites", "projects"].filter((key) => renderedCollections[key].length);
let frame;
const draw = () => { overview.innerHTML = overviewMarkup(); };
const isOverflowing = () => {
const contentBottom = content.getBoundingClientRect().bottom;
const lastGroup = overview.lastElementChild;
if (!lastGroup) return false;
// 绝对定位视图在部分浏览器中不会把视觉溢出完整计入 scrollHeight,使用实际边界兜底。
return overview.scrollHeight > content.clientHeight || lastGroup.getBoundingClientRect().bottom > contentBottom - 3;
};
const fit = () => {
overviewCounts = Object.fromEntries(keys.map((key) => [key, 0]));
draw();
let cursor = 0;
let canFit = true;
while (canFit && keys.some((key) => overviewCounts[key] < renderedCollections[key].length)) {
const key = keys[cursor % keys.length];
cursor++;
if (overviewCounts[key] >= renderedCollections[key].length) continue;
overviewCounts[key]++;
draw();
if (isOverflowing()) {
overviewCounts[key]--;
draw();
canFit = false;
}
}
};
const scheduleFit = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(fit);
};
new ResizeObserver(scheduleFit).observe(content);
window.addEventListener("load", scheduleFit, { once: true });
document.fonts?.ready.then(scheduleFit);
scheduleFit();
}
setupOverviewCapacity();
function setupCollectionPanel() {
const content = document.querySelector(".content");
const overview = document.querySelector("#content-overview");
const detail = document.querySelector("#content-detail");
const title = document.querySelector("#collection-title");
const list = document.querySelector("#collection-list");
const backButton = document.querySelector("#collection-back");
const close = () => {
content.classList.remove("is-detail");
overview.setAttribute("aria-hidden", "false");
detail.setAttribute("aria-hidden", "true");
};
overview.addEventListener("click", (event) => {
const button = event.target.closest("[data-collection]");
if (!button) return;
const key = button.dataset.collection;
title.textContent = key === "sites" ? "全部站点" : "全部项目";
list.innerHTML = resourceList(renderedCollections[key]);
list.scrollTop = 0;
content.classList.add("is-detail");
overview.setAttribute("aria-hidden", "true");
detail.setAttribute("aria-hidden", "false");
backButton.focus();
});
backButton.addEventListener("click", close);
document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); });
}
setupCollectionPanel();