feat: implement site variant support and enhance SEO with dynamic meta tags

This commit is contained in:
2026-08-20 18:51:50 +08:00
parent d892c9751b
commit 4f702493cf
18 changed files with 506 additions and 57 deletions
+5 -1
View File
@@ -1,3 +1,5 @@
import { IS_CN_SITE } from "../site-config";
export interface Contact {
icon: string;
labelKey: string;
@@ -8,5 +10,7 @@ export interface Contact {
export const contacts: Contact[] = [
{ icon: "ph-envelope-simple", labelKey: "contact.email", href: "mailto:me@zhouhaoze.com" },
{ icon: "ph-github-logo", labelKey: "contact.github", href: "https://github.com/YIYI-16", external: true },
{ icon: "ph-telegram-logo", labelKey: "contact.telegram", href: "https://t.me/yiyitelegram_bot", external: true },
...(!IS_CN_SITE
? [{ icon: "ph-telegram-logo", labelKey: "contact.telegram", href: "https://t.me/yiyitelegram_bot", external: true }]
: []),
];
+41 -1
View File
@@ -16,12 +16,47 @@ interface GlobePoint {
}
function buildPoints(snapshot: InfraSnapshot): GlobePoint[] {
const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location]));
const points = snapshot.nodes.flatMap((node) => {
const location = locationsById.get(node.uuid);
if (location) {
const place = [location.city, location.country].filter(Boolean).join(", ");
return [{ lat: location.lat, lng: location.lng, name: place ? `${node.name} · ${place}` : node.name, count: 1 }];
}
const fallback = flagToRegionInfo(node.region);
return fallback ? [{ lat: fallback.lat, lng: fallback.lng, name: node.name, count: 1 }] : [];
});
if (points.length) return spreadOverlappingPoints(points);
return snapshot.regionBreakdown.flatMap(({ flag, count }) => {
const info = flagToRegionInfo(flag);
return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : [];
});
}
function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] {
const groups = new Map<string, GlobePoint[]>();
for (const point of points) {
const key = `${point.lat.toFixed(3)},${point.lng.toFixed(3)}`;
const group = groups.get(key) ?? [];
group.push(point);
groups.set(key, group);
}
return Array.from(groups.values()).flatMap((group) => {
if (group.length === 1) return group;
return group.map((point, index) => {
const angle = (index / group.length) * Math.PI * 2;
const radius = 0.28 + Math.floor(index / 8) * 0.12;
return {
...point,
lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)),
lng: point.lng + Math.cos(angle) * radius,
};
});
});
}
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
@@ -61,6 +96,8 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
container.replaceChildren(renderer.domElement);
renderer.domElement.style.cursor = "grab";
renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions");
renderer.domElement.dataset.nodeCount = String(points.length);
renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length);
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const frontLight = new THREE.DirectionalLight(0xffffff, 0.65);
@@ -97,7 +134,10 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
.labelsData(points)
.labelLat("lat")
.labelLng("lng")
.labelText((d) => `${(d as GlobePoint).name} · ${(d as GlobePoint).count}`)
.labelText((d) => {
const point = d as GlobePoint;
return point.count > 1 ? `${point.name} · ${point.count}` : point.name;
})
.labelSize(1.15)
.labelColor(() => textColor)
.labelDotRadius(0.32)
+2 -1
View File
@@ -1,6 +1,7 @@
import en from "./locales/en.json";
import zh from "./locales/zh.json";
import { updateSeo } from "./seo";
import { IS_CN_SITE } from "./site-config";
export type Lang = "en" | "zh";
@@ -9,7 +10,7 @@ const dictionaries: Record<Lang, Record<string, string>> = { en, zh };
const STORAGE_KEY = "pw-lang";
function detectLang(): Lang {
if (window.location.hostname.endsWith(".cn")) return "zh";
if (IS_CN_SITE) return "zh";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored;
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
+63 -4
View File
@@ -3,6 +3,7 @@ import { infraFallback } from "./data/infra";
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
interface KomariNode {
uuid: string;
name: string;
region: string; // flag emoji per Komari's public API
}
@@ -11,6 +12,22 @@ interface KomariNodesResponse {
data: KomariNode[];
}
export interface NodeLocation {
uuid: string;
name: string;
lat: number;
lng: number;
city: string;
country: string;
countryCode: string;
}
interface NodeLocationsFile {
version: number;
nodes: KomariNode[];
locations: NodeLocation[];
}
export interface RegionBreakdown {
flag: string;
count: number;
@@ -21,24 +38,61 @@ export interface InfraSnapshot {
regionCount: number;
regions: string[];
regionBreakdown: RegionBreakdown[];
nodes: KomariNode[];
nodeLocations: NodeLocation[];
live: boolean;
}
interface NodeMapData {
nodes: KomariNode[];
locations: NodeLocation[];
}
async function fetchNodeMap(): Promise<NodeMapData> {
try {
const response = await fetch("/node-locations.json", { signal: AbortSignal.timeout(5000) });
if (!response.ok) return { nodes: [], locations: [] };
const body: NodeLocationsFile = await response.json();
if (body.version !== 1 || !Array.isArray(body.nodes) || !Array.isArray(body.locations)) {
return { nodes: [], locations: [] };
}
const nodes = body.nodes.filter(
(node) => typeof node.uuid === "string" && typeof node.name === "string" && typeof node.region === "string",
);
const locations = body.locations.filter(
(location) =>
typeof location.uuid === "string" &&
Number.isFinite(location.lat) &&
Number.isFinite(location.lng) &&
Math.abs(location.lat) <= 90 &&
Math.abs(location.lng) <= 180,
);
return { nodes, locations };
} catch {
return { nodes: [], locations: [] };
}
}
export async function fetchInfraSnapshot(): Promise<InfraSnapshot> {
const nodeMapPromise = fetchNodeMap();
if (!KOMARI_BASE_URL) {
const nodeMap = await nodeMapPromise;
return {
serverCount: infraFallback.serverCount,
regionCount: infraFallback.regionCount,
regions: [],
regionBreakdown: [],
nodes: nodeMap.nodes,
nodeLocations: nodeMap.locations,
live: false,
};
}
try {
const res = await fetch(`${KOMARI_BASE_URL}/api/nodes`, {
signal: AbortSignal.timeout(5000),
});
const [res, nodeMap] = await Promise.all([
fetch(`${KOMARI_BASE_URL}/api/nodes`, { signal: AbortSignal.timeout(5000) }),
nodeMapPromise,
]);
if (!res.ok) throw new Error(`Komari responded ${res.status}`);
const body: KomariNodesResponse = await res.json();
const nodes = body.data;
@@ -56,14 +110,19 @@ export async function fetchInfraSnapshot(): Promise<InfraSnapshot> {
regionCount: regions.length || infraFallback.regionCount,
regions,
regionBreakdown,
nodes,
nodeLocations: nodeMap.locations,
live: true,
};
} catch {
const nodeMap = await nodeMapPromise;
return {
serverCount: infraFallback.serverCount,
serverCount: nodeMap.nodes.length || infraFallback.serverCount,
regionCount: infraFallback.regionCount,
regions: [],
regionBreakdown: [],
nodes: nodeMap.nodes,
nodeLocations: nodeMap.locations,
live: false,
};
}
+3 -3
View File
@@ -7,11 +7,11 @@
"nav.langToggle": "Switch language",
"hero.eyebrow": "Student / Developer",
"hero.name": "Haoze Zhou",
"hero.name": "ZaneChow",
"hero.tagline": "CS undergraduate at Universiti Malaya, building and running a few of my own services on the side.",
"hero.cta": "View projects",
"hero.more": "About me",
"hero.note": "Currently running 38 servers across 19 regions, live-monitored via Komari.",
"hero.note": "Currently maintaining multiple servers across several regions, live-monitored via Komari.",
"hero.stat.servers": "Servers",
"hero.stat.regions": "Regions",
"hero.stat.projects": "Projects",
@@ -61,5 +61,5 @@
"contact.github": "GitHub",
"contact.telegram": "Telegram",
"footer.text": "© 2026 Haoze Zhou"
"footer.text": "© 2026 ZaneChow"
}
+1 -1
View File
@@ -11,7 +11,7 @@
"hero.tagline": "马来亚大学计算机科学在读,业余搭建和维护一些自己的服务与工具。",
"hero.cta": "查看项目",
"hero.more": "关于我",
"hero.note": "目前维护着 38 台服务器,分布在 19 个地区,由 Komari 实时监控。",
"hero.note": "目前维护着多台服务器,分布在多个地区,由 Komari 实时监控。",
"hero.stat.servers": "服务器",
"hero.stat.regions": "地区",
"hero.stat.projects": "项目",
+1 -1
View File
@@ -260,7 +260,7 @@ function setupInfraGlobe(): void {
async function mountGlobeWhenReady(container: HTMLElement, canvasMount: HTMLElement): Promise<void> {
const snapshot = await infraSnapshotPromise;
if (!snapshot.regionBreakdown.length) return;
if (!snapshot.regionBreakdown.length && !snapshot.nodes.length && !snapshot.nodeLocations.length) return;
const { mountGlobe } = await import("./globe");
container.classList.add("has-live");
mountGlobe(canvasMount, snapshot);
+14 -7
View File
@@ -1,10 +1,11 @@
import type { Lang } from "./i18n";
import { IS_CN_SITE, SITE_ORIGIN, SITE_URL } from "./site-config";
const SEO = {
en: {
title: "Haoze Zhou | Computer Science Student & Developer",
description: "Haoze Zhou is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.",
imageAlt: "Haoze Zhou personal website",
title: "ZaneChow | Computer Science Student & Developer",
description: "ZaneChow is a Computer Science undergraduate at Universiti Malaya building software, self-hosted infrastructure, and open-source projects.",
imageAlt: "ZaneChow personal website",
},
zh: {
title: "周昊泽 | 计算机科学学生与开发者",
@@ -17,7 +18,7 @@ const EN_URL = "https://www.zhouhaoze.com/";
const ZH_URL = "https://www.zhouhaoze.cn/";
function currentUrl(): string {
return `${window.location.origin}${window.location.pathname}`;
return `${SITE_ORIGIN}${window.location.pathname}`;
}
function setMeta(selector: string, content: string): void {
@@ -39,10 +40,16 @@ export function updateSeo(lang: Lang): void {
setMeta('meta[property="og:title"]', copy.title);
setMeta('meta[property="og:description"]', copy.description);
setMeta('meta[property="og:url"]', canonical);
setMeta('meta[property="og:image"]', `${SITE_URL}favicon.png`);
setMeta('meta[property="og:image:alt"]', copy.imageAlt);
setMeta('meta[property="og:locale"]', lang === "zh" ? "zh_CN" : "en_US");
setMeta('meta[property="og:locale:alternate"]', lang === "zh" ? "en_US" : "zh_CN");
setMeta('meta[name="twitter:title"]', copy.title);
setMeta('meta[name="twitter:description"]', copy.description);
setMeta('meta[name="twitter:image"]', `${SITE_URL}favicon.png`);
const sameAs = ["https://github.com/YIYI-16"];
if (!IS_CN_SITE) sameAs.push("https://t.me/yiyitelegram_bot");
const structuredData = {
"@context": "https://schema.org",
@@ -50,11 +57,11 @@ export function updateSeo(lang: Lang): void {
{
"@type": "Person",
"@id": `${canonical}#person`,
name: lang === "zh" ? "周昊泽" : "Haoze Zhou",
alternateName: lang === "zh" ? "Haoze Zhou" : "周昊泽",
name: lang === "zh" ? "周昊泽" : "ZaneChow",
alternateName: lang === "zh" ? "ZaneChow" : "周昊泽",
url: canonical,
email: "mailto:me@zhouhaoze.com",
sameAs: ["https://github.com/YIYI-16", "https://t.me/yiyitelegram_bot"],
sameAs,
jobTitle: lang === "zh" ? "计算机科学本科生" : "Computer Science undergraduate",
affiliation: { "@type": "CollegeOrUniversity", name: "Universiti Malaya" },
},
+6
View File
@@ -0,0 +1,6 @@
export type SiteVariant = "com" | "cn";
export const SITE_VARIANT: SiteVariant = __SITE_VARIANT__;
export const IS_CN_SITE = SITE_VARIANT === "cn";
export const SITE_ORIGIN = IS_CN_SITE ? "https://www.zhouhaoze.cn" : "https://www.zhouhaoze.com";
export const SITE_URL = `${SITE_ORIGIN}/`;
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __SITE_VARIANT__: "com" | "cn";