feat: add base styles, component styles, and theme management
- Created base CSS styles for typography, layout, and transitions. - Added component-specific styles for navigation, hero section, about section, infrastructure, projects, modal, contact, and footer. - Introduced design tokens for consistent theming across light and dark modes. - Implemented theme management functionality with local storage support and system preference detection. - Configured TypeScript settings for the project.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const tomlPath = path.join(os.homedir(), "AppData", "Roaming", "xdg.config", ".wrangler", "config", "default.toml");
|
||||
const toml = fs.readFileSync(tomlPath, "utf8");
|
||||
const match = toml.match(/oauth_token\s*=\s*"([^"]+)"/);
|
||||
if (!match) {
|
||||
console.error("no token found");
|
||||
process.exit(1);
|
||||
}
|
||||
const token = match[1];
|
||||
const accountId = "6a638c3c2047a3ce064af7e4febb0ead";
|
||||
const project = "personalweb";
|
||||
const domain = "www.zhouhaoze.com";
|
||||
|
||||
fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${project}/domains`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: domain }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
console.log(JSON.stringify(j, null, 2));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const tomlPath = path.join(os.homedir(), "AppData", "Roaming", "xdg.config", ".wrangler", "config", "default.toml");
|
||||
const toml = fs.readFileSync(tomlPath, "utf8");
|
||||
const match = toml.match(/oauth_token\s*=\s*"([^"]+)"/);
|
||||
const token = match[1];
|
||||
const zoneId = "5ed3652548a0e8c430910c4090b02357";
|
||||
|
||||
fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "CNAME",
|
||||
name: "www",
|
||||
content: "personalweb-bkl.pages.dev",
|
||||
proxied: true,
|
||||
ttl: 1,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => console.log(JSON.stringify(j, null, 2)))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Haoze Zhou</title>
|
||||
<meta name="description" content="Haoze Zhou — CS undergraduate at Universiti Malaya. Projects and self-hosted infrastructure." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1369
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "personalweb",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.183.0",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/outfit": "^5.3.0",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"three": "^0.183.0",
|
||||
"three-globe": "^2.45.2",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,12 @@
|
||||
export interface Contact {
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface InfraService {
|
||||
icon: string;
|
||||
nameKey: string;
|
||||
descKey: string;
|
||||
}
|
||||
|
||||
export const infraServices: InfraService[] = [
|
||||
{ icon: "ph-git-branch", nameKey: "infra.gitea.name", descKey: "infra.gitea.desc" },
|
||||
{ icon: "ph-envelope-simple", nameKey: "infra.mailcow.name", descKey: "infra.mailcow.desc" },
|
||||
{ icon: "ph-heartbeat", nameKey: "infra.uptime.name", descKey: "infra.uptime.desc" },
|
||||
{ icon: "ph-folder-open", nameKey: "infra.alist.name", descKey: "infra.alist.desc" },
|
||||
];
|
||||
|
||||
export const infraFallback = {
|
||||
serverCount: 38,
|
||||
regionCount: 19,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
detailKey?: string;
|
||||
stack: string[];
|
||||
repoUrl: string;
|
||||
featured?: boolean;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
id: "authrouter",
|
||||
titleKey: "projects.authrouter.title",
|
||||
descKey: "projects.authrouter.desc",
|
||||
detailKey: "projects.authrouter.detail",
|
||||
stack: ["Go", "OAuth2", "Reverse Proxy"],
|
||||
repoUrl: "https://github.com/YIYI-16/AuthRouter",
|
||||
featured: true,
|
||||
image: "/images/authrouter.png",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface RegionInfo {
|
||||
code: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REGION_TABLE: Record<string, RegionInfo> = {
|
||||
HK: { code: "HK", lat: 22.3193, lng: 114.1694, name: "Hong Kong" },
|
||||
SG: { code: "SG", lat: 1.3521, lng: 103.8198, name: "Singapore" },
|
||||
CN: { code: "CN", lat: 39.9042, lng: 116.4074, name: "China" },
|
||||
DE: { code: "DE", lat: 50.1109, lng: 8.6821, name: "Germany" },
|
||||
JP: { code: "JP", lat: 35.6762, lng: 139.6503, name: "Japan" },
|
||||
CA: { code: "CA", lat: 43.6532, lng: -79.3832, name: "Canada" },
|
||||
GB: { code: "GB", lat: 51.5072, lng: -0.1276, name: "United Kingdom" },
|
||||
US: { code: "US", lat: 37.3382, lng: -121.8863, name: "United States" },
|
||||
NL: { code: "NL", lat: 52.3676, lng: 4.9041, name: "Netherlands" },
|
||||
AU: { code: "AU", lat: -33.8688, lng: 151.2093, name: "Australia" },
|
||||
KR: { code: "KR", lat: 37.5665, lng: 126.978, name: "South Korea" },
|
||||
TR: { code: "TR", lat: 41.0082, lng: 28.9784, name: "Turkey" },
|
||||
IN: { code: "IN", lat: 19.076, lng: 72.8777, name: "India" },
|
||||
ID: { code: "ID", lat: -6.2088, lng: 106.8456, name: "Indonesia" },
|
||||
MY: { code: "MY", lat: 3.139, lng: 101.6869, name: "Malaysia" },
|
||||
BR: { code: "BR", lat: -23.5505, lng: -46.6333, name: "Brazil" },
|
||||
VN: { code: "VN", lat: 21.0278, lng: 105.8342, name: "Vietnam" },
|
||||
TW: { code: "TW", lat: 25.033, lng: 121.5654, name: "Taiwan" },
|
||||
};
|
||||
|
||||
const REGIONAL_INDICATOR_BASE = 0x1f1e6;
|
||||
|
||||
export function flagToRegionInfo(flag: string): RegionInfo | null {
|
||||
const chars = Array.from(flag);
|
||||
if (chars.length !== 2) return null;
|
||||
const letters = chars.map((c) => {
|
||||
const codePoint = c.codePointAt(0);
|
||||
if (codePoint === undefined) return null;
|
||||
const offset = codePoint - REGIONAL_INDICATOR_BASE;
|
||||
if (offset < 0 || offset > 25) return null;
|
||||
return String.fromCharCode(65 + offset);
|
||||
});
|
||||
if (letters.some((l) => l === null)) return null;
|
||||
const code = letters.join("");
|
||||
return REGION_TABLE[code] ?? null;
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import ThreeGlobe from "three-globe";
|
||||
import { feature } from "topojson-client";
|
||||
import type { GeometryCollection, Topology } from "topojson-specification";
|
||||
import countriesTopology from "world-atlas/countries-110m.json";
|
||||
import type { InfraSnapshot } from "./komari";
|
||||
import { flagToRegionInfo } from "./data/regions";
|
||||
import { onThemeChange } from "./theme";
|
||||
|
||||
interface GlobePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function buildPoints(snapshot: InfraSnapshot): GlobePoint[] {
|
||||
return snapshot.regionBreakdown.flatMap(({ flag, count }) => {
|
||||
const info = flagToRegionInfo(flag);
|
||||
return info ? [{ lat: info.lat, lng: info.lng, name: info.name, count }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.substring(0, 2), 16);
|
||||
const g = parseInt(clean.substring(2, 4), 16);
|
||||
const b = parseInt(clean.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function sizeOf(container: HTMLElement): { width: number; height: number } {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return { width: Math.max(1, Math.round(rect.width || 320)), height: Math.max(1, Math.round(rect.height || 320)) };
|
||||
}
|
||||
|
||||
export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () => void {
|
||||
const points = buildPoints(snapshot);
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let accent = cssVar("--accent", "#3b4fa3");
|
||||
let textColor = cssVar("--text", "#f5f4f1");
|
||||
let globeBase = cssVar("--bg-elevated", "#1b1e26");
|
||||
const topology = countriesTopology as unknown as Topology;
|
||||
const countries = feature(topology, topology.objects.countries as GeometryCollection);
|
||||
const initialSize = sizeOf(container);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(42, initialSize.width / initialSize.height, 0.1, 2000);
|
||||
camera.position.set(0, 0, 280);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(initialSize.width, initialSize.height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
container.replaceChildren(renderer.domElement);
|
||||
renderer.domElement.style.cursor = "grab";
|
||||
renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions");
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const frontLight = new THREE.DirectionalLight(0xffffff, 0.65);
|
||||
frontLight.position.set(200, 180, 250);
|
||||
scene.add(frontLight);
|
||||
const fillLightLeft = new THREE.DirectionalLight(0xffffff, 0.28);
|
||||
fillLightLeft.position.set(-260, 60, 120);
|
||||
scene.add(fillLightLeft);
|
||||
const rimLight = new THREE.DirectionalLight(0xffffff, 0.22);
|
||||
rimLight.position.set(0, -120, -260);
|
||||
scene.add(rimLight);
|
||||
|
||||
const globeMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(globeBase), shininess: 14, transparent: true, opacity: 0.96 });
|
||||
|
||||
const globe = new ThreeGlobe()
|
||||
.globeMaterial(globeMaterial)
|
||||
.polygonsData(countries.features)
|
||||
.polygonCapColor(() => hexToRgba(accent, 0.12))
|
||||
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
||||
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
||||
.polygonAltitude(0.004)
|
||||
.showGraticules(true)
|
||||
.showAtmosphere(true)
|
||||
.atmosphereColor(accent)
|
||||
.atmosphereAltitude(0.18)
|
||||
.pointsData(points)
|
||||
.pointLat("lat")
|
||||
.pointLng("lng")
|
||||
.pointColor(() => accent)
|
||||
.pointAltitude(0.018)
|
||||
.pointRadius(0.42);
|
||||
|
||||
globe
|
||||
.labelsData(points)
|
||||
.labelLat("lat")
|
||||
.labelLng("lng")
|
||||
.labelText((d) => `${(d as GlobePoint).name} · ${(d as GlobePoint).count}`)
|
||||
.labelSize(1.15)
|
||||
.labelColor(() => textColor)
|
||||
.labelDotRadius(0.32)
|
||||
.labelDotOrientation("bottom")
|
||||
.labelResolution(3)
|
||||
.labelAltitude(0.022);
|
||||
|
||||
if (!reduceMotion) {
|
||||
globe.ringsData(points).ringLat("lat").ringLng("lng").ringColor(() => (t: number) => hexToRgba(accent, 1 - t)).ringMaxRadius(5).ringPropagationSpeed(1.5).ringRepeatPeriod(2200);
|
||||
}
|
||||
scene.add(globe);
|
||||
|
||||
function repaint(): void {
|
||||
accent = cssVar("--accent", "#3b4fa3");
|
||||
textColor = cssVar("--text", "#f5f4f1");
|
||||
globeBase = cssVar("--bg-elevated", "#1b1e26");
|
||||
globeMaterial.color.set(globeBase);
|
||||
globe
|
||||
.polygonCapColor(() => hexToRgba(accent, 0.12))
|
||||
.polygonSideColor(() => hexToRgba(accent, 0.04))
|
||||
.polygonStrokeColor(() => hexToRgba(accent, 0.48))
|
||||
.atmosphereColor(accent)
|
||||
.pointColor(() => accent)
|
||||
.labelColor(() => textColor);
|
||||
if (!reduceMotion) {
|
||||
globe.ringColor(() => (t: number) => hexToRgba(accent, 1 - t));
|
||||
}
|
||||
}
|
||||
const unsubscribeTheme = onThemeChange(repaint);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableZoom = false;
|
||||
controls.enablePan = false;
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.rotateSpeed = 0.55;
|
||||
controls.autoRotate = !reduceMotion;
|
||||
controls.autoRotateSpeed = 0.45;
|
||||
controls.minDistance = 180;
|
||||
controls.maxDistance = 400;
|
||||
|
||||
let frameId = 0;
|
||||
const animate = () => {
|
||||
frameId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const { width, height } = sizeOf(container);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
renderer.domElement.style.cursor = "grabbing";
|
||||
};
|
||||
const handlePointerUp = () => {
|
||||
renderer.domElement.style.cursor = "grab";
|
||||
};
|
||||
renderer.domElement.addEventListener("pointerdown", handlePointerDown);
|
||||
renderer.domElement.addEventListener("pointerup", handlePointerUp);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeTheme();
|
||||
renderer.domElement.removeEventListener("pointerdown", handlePointerDown);
|
||||
renderer.domElement.removeEventListener("pointerup", handlePointerUp);
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
scene.clear();
|
||||
if (renderer.domElement.parentElement === container) container.removeChild(renderer.domElement);
|
||||
};
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
export type Lang = "en" | "zh";
|
||||
|
||||
const dictionaries: Record<Lang, Record<string, string>> = { en, zh };
|
||||
|
||||
const STORAGE_KEY = "pw-lang";
|
||||
|
||||
function detectLang(): Lang {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
return navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
|
||||
}
|
||||
|
||||
let currentLang: Lang = detectLang();
|
||||
const listeners = new Set<(lang: Lang) => void>();
|
||||
|
||||
export function getLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function t(key: string): string {
|
||||
return dictionaries[currentLang][key] ?? dictionaries.en[key] ?? key;
|
||||
}
|
||||
|
||||
export function setLang(lang: Lang): void {
|
||||
currentLang = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
||||
applyTranslations();
|
||||
listeners.forEach((fn) => fn(lang));
|
||||
}
|
||||
|
||||
export function onLangChange(fn: (lang: Lang) => void): void {
|
||||
listeners.add(fn);
|
||||
}
|
||||
|
||||
export function applyTranslations(): void {
|
||||
document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((el) => {
|
||||
const key = el.dataset.i18n;
|
||||
if (!key) return;
|
||||
el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>("[data-i18n-attr]").forEach((el) => {
|
||||
const spec = el.dataset.i18nAttr;
|
||||
if (!spec) return;
|
||||
spec.split(",").forEach((pair) => {
|
||||
const [attr, key] = pair.split(":").map((s) => s.trim());
|
||||
if (attr && key) el.setAttribute(attr, t(key));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initI18n(): void {
|
||||
document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en";
|
||||
applyTranslations();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { infraFallback } from "./data/infra";
|
||||
|
||||
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
|
||||
|
||||
interface KomariNode {
|
||||
name: string;
|
||||
region: string; // flag emoji per Komari's public API
|
||||
}
|
||||
|
||||
interface KomariNodesResponse {
|
||||
data: KomariNode[];
|
||||
}
|
||||
|
||||
export interface RegionBreakdown {
|
||||
flag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface InfraSnapshot {
|
||||
serverCount: number;
|
||||
regionCount: number;
|
||||
regions: string[];
|
||||
regionBreakdown: RegionBreakdown[];
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export async function fetchInfraSnapshot(): Promise<InfraSnapshot> {
|
||||
if (!KOMARI_BASE_URL) {
|
||||
return {
|
||||
serverCount: infraFallback.serverCount,
|
||||
regionCount: infraFallback.regionCount,
|
||||
regions: [],
|
||||
regionBreakdown: [],
|
||||
live: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${KOMARI_BASE_URL}/api/nodes`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Komari responded ${res.status}`);
|
||||
const body: KomariNodesResponse = await res.json();
|
||||
const nodes = body.data;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
if (!n.region) continue;
|
||||
counts.set(n.region, (counts.get(n.region) ?? 0) + 1);
|
||||
}
|
||||
const regionBreakdown = Array.from(counts, ([flag, count]) => ({ flag, count }));
|
||||
const regions = regionBreakdown.map((r) => r.flag);
|
||||
|
||||
return {
|
||||
serverCount: nodes.length || infraFallback.serverCount,
|
||||
regionCount: regions.length || infraFallback.regionCount,
|
||||
regions,
|
||||
regionBreakdown,
|
||||
live: true,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
serverCount: infraFallback.serverCount,
|
||||
regionCount: infraFallback.regionCount,
|
||||
regions: [],
|
||||
regionBreakdown: [],
|
||||
live: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"nav.about": "About",
|
||||
"nav.infra": "Infrastructure",
|
||||
"nav.projects": "Projects",
|
||||
"nav.contact": "Contact",
|
||||
"nav.themeToggle": "Toggle light and dark mode",
|
||||
"nav.langToggle": "Switch language",
|
||||
|
||||
"hero.eyebrow": "Student / Developer",
|
||||
"hero.name": "Haoze Zhou",
|
||||
"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.stat.servers": "Servers",
|
||||
"hero.stat.regions": "Regions",
|
||||
"hero.stat.projects": "Projects",
|
||||
"hero.focusLabel": "Currently focused on",
|
||||
"hero.focus1": "Frontend engineering",
|
||||
"hero.focus2": "AI / ML fundamentals",
|
||||
"hero.focus3": "Backend & self-hosting",
|
||||
|
||||
"about.title": "About",
|
||||
"about.identity": "Universiti Malaya (QS 56) · Computer Science · Year 1 · Expected graduation 2030",
|
||||
"about.bio1": "I'm a first-year Computer Science undergraduate at Universiti Malaya. I'm interested in frontend development, AI, and backend systems, and still figuring out which direction I want to go deeper on.",
|
||||
"about.bio2": "I like building and maintaining services myself, from writing code to running the servers behind them. I tend to learn best by actually doing.",
|
||||
"about.skillsTitle": "Skills and tools",
|
||||
"about.timelineTitle": "A few milestones",
|
||||
"about.timeline.2025": "Started Computer Science at Universiti Malaya",
|
||||
"about.timeline.2026": "Kept building and self-hosting side projects and services",
|
||||
|
||||
"infra.title": "Self-hosted infrastructure",
|
||||
"infra.lead": "A few services I build and maintain in my spare time, spread across several countries and regions, monitored live by Komari.",
|
||||
"infra.statServers": "servers",
|
||||
"infra.statRegions": "countries / regions",
|
||||
"infra.regionsFallback": "{count} regions monitored via Komari",
|
||||
"infra.gitea.name": "Gitea",
|
||||
"infra.gitea.desc": "Self-hosted code hosting for managing personal project repositories.",
|
||||
"infra.mailcow.name": "MailCow",
|
||||
"infra.mailcow.desc": "Self-hosted mail server handling email for my personal domain.",
|
||||
"infra.uptime.name": "Uptime Kuma",
|
||||
"infra.uptime.desc": "Tracks uptime and response health across my other services.",
|
||||
"infra.alist.name": "Alist",
|
||||
"infra.alist.desc": "File management and sharing across several storage sources.",
|
||||
"infra.globeHint": "Drag to rotate, showing node distribution",
|
||||
|
||||
"projects.title": "Projects",
|
||||
"projects.lead": "Things I've written and maintained. Click a card for more detail.",
|
||||
"projects.authrouter.tag": "Featured",
|
||||
"projects.authrouter.title": "AuthRouter",
|
||||
"projects.authrouter.desc": "Consolidates multiple identity providers behind a single login endpoint, with identity mapping across providers.",
|
||||
"projects.authrouter.detail": "Systems that connect to several identity providers often force users to jump between different login entry points, and identity data ends up scattered across them. AuthRouter merges these into one endpoint: callers integrate once, and requests get routed to the right upstream provider with identities mapped into a consistent format.",
|
||||
"projects.viewOnGithub": "View on GitHub",
|
||||
"projects.emptyNote": "More projects are on the way and will be added here over time.",
|
||||
"projects.media.placeholder": "Project screenshot coming soon",
|
||||
|
||||
"contact.title": "Contact",
|
||||
"contact.lead": "Feel free to reach me through any of the following.",
|
||||
"contact.email": "Email",
|
||||
"contact.github": "GitHub",
|
||||
"contact.telegram": "Telegram",
|
||||
|
||||
"footer.text": "© 2026 Haoze Zhou"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"nav.about": "关于",
|
||||
"nav.infra": "基础设施",
|
||||
"nav.projects": "项目",
|
||||
"nav.contact": "联系",
|
||||
"nav.themeToggle": "切换深浅色模式",
|
||||
"nav.langToggle": "切换语言",
|
||||
|
||||
"hero.eyebrow": "在读学生 / 开发爱好者",
|
||||
"hero.name": "周昊泽",
|
||||
"hero.tagline": "马来亚大学计算机科学在读,业余搭建和维护一些自己的服务与工具。",
|
||||
"hero.cta": "查看项目",
|
||||
"hero.more": "关于我",
|
||||
"hero.note": "目前维护着 38 台服务器,分布在 19 个地区,由 Komari 实时监控。",
|
||||
"hero.stat.servers": "服务器",
|
||||
"hero.stat.regions": "地区",
|
||||
"hero.stat.projects": "项目",
|
||||
"hero.focusLabel": "当前关注方向",
|
||||
"hero.focus1": "前端开发",
|
||||
"hero.focus2": "AI / 机器学习基础",
|
||||
"hero.focus3": "后端与自建服务",
|
||||
|
||||
"about.title": "关于我",
|
||||
"about.identity": "马来亚大学(QS 56) 计算机科学 · 大一 · 预计 2030 年毕业",
|
||||
"about.bio1": "目前在马来亚大学读计算机科学本科一年级,对前端开发、人工智能和后端系统都感兴趣,还在探索自己更想深入的方向。",
|
||||
"about.bio2": "平时喜欢自己动手搭建和维护一些服务,从写代码到管理服务器都会去尝试,更习惯在实践里学东西。",
|
||||
"about.skillsTitle": "技能与工具",
|
||||
"about.timelineTitle": "一些节点",
|
||||
"about.timeline.2025": "入学马来亚大学,计算机科学专业",
|
||||
"about.timeline.2026": "持续折腾自建服务与个人项目",
|
||||
|
||||
"infra.title": "自建基础设施",
|
||||
"infra.lead": "业余时间搭建和维护的一些服务,分布在多个国家和地区,状态由 Komari 实时监控。",
|
||||
"infra.statServers": "台服务器",
|
||||
"infra.statRegions": "个国家 / 地区",
|
||||
"infra.regionsFallback": "{count} 个地区由 Komari 实时监控",
|
||||
"infra.gitea.name": "Gitea",
|
||||
"infra.gitea.desc": "自建代码托管服务,存放和管理个人项目仓库。",
|
||||
"infra.mailcow.name": "MailCow",
|
||||
"infra.mailcow.desc": "自建邮件服务,负责个人域名下的邮箱收发。",
|
||||
"infra.uptime.name": "Uptime Kuma",
|
||||
"infra.uptime.desc": "监控各项服务的在线状态和响应情况。",
|
||||
"infra.alist.name": "Alist",
|
||||
"infra.alist.desc": "聚合多个存储来源的文件管理与分享服务。",
|
||||
"infra.globeHint": "拖拽旋转,查看节点分布",
|
||||
|
||||
"projects.title": "项目",
|
||||
"projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。",
|
||||
"projects.authrouter.tag": "重点项目",
|
||||
"projects.authrouter.title": "AuthRouter",
|
||||
"projects.authrouter.desc": "把多个身份提供方(IdP)整合到一个统一的登录入口,只需要调用一个端点就能完成登录,并支持在不同身份提供方之间做身份映射。",
|
||||
"projects.authrouter.detail": "很多系统会同时接入好几个身份提供方,用户登录时经常要在不同入口之间跳转,身份信息也难以统一。AuthRouter 把这些入口整合成一个,调用方只需要对接一个端点,就能把登录请求路由到正确的上游,并把不同上游的身份信息映射成统一的格式。",
|
||||
"projects.viewOnGithub": "在 GitHub 查看",
|
||||
"projects.emptyNote": "更多项目正在整理中,之后会陆续补充在这里。",
|
||||
"projects.media.placeholder": "项目截图待补充",
|
||||
|
||||
"contact.title": "联系方式",
|
||||
"contact.lead": "欢迎通过下面的方式找到我。",
|
||||
"contact.email": "邮箱",
|
||||
"contact.github": "GitHub",
|
||||
"contact.telegram": "Telegram",
|
||||
|
||||
"footer.text": "© 2026 周昊泽"
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/components.css";
|
||||
import { initI18n, setLang, getLang, onLangChange, applyTranslations, t } from "./i18n";
|
||||
import { initTheme, toggleTheme, getTheme, onThemeChange } from "./theme";
|
||||
import { projects } from "./data/projects";
|
||||
import { infraServices } from "./data/infra";
|
||||
import { contacts } from "./data/contacts";
|
||||
import { fetchInfraSnapshot, type InfraSnapshot } from "./komari";
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app")!;
|
||||
|
||||
app.innerHTML = `
|
||||
<nav class="nav">
|
||||
<div class="container nav__inner">
|
||||
<span class="nav__mark">HZ</span>
|
||||
<div class="nav__links">
|
||||
<a class="nav__link" href="#about" data-i18n="nav.about"></a>
|
||||
<a class="nav__link" href="#infra" data-i18n="nav.infra"></a>
|
||||
<a class="nav__link" href="#projects" data-i18n="nav.projects"></a>
|
||||
<a class="nav__link" href="#contact" data-i18n="nav.contact"></a>
|
||||
</div>
|
||||
<div class="nav__controls">
|
||||
<button id="theme-toggle" class="icon-btn" type="button" data-i18n-attr="aria-label:nav.themeToggle"></button>
|
||||
<button id="lang-toggle" class="icon-btn lang-btn" type="button" data-i18n-attr="aria-label:nav.langToggle"></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="container hero__grid">
|
||||
<div>
|
||||
<span class="hero__eyebrow" data-i18n="hero.eyebrow"></span>
|
||||
<h1 class="hero__name" data-i18n="hero.name"></h1>
|
||||
<p class="hero__tagline" data-i18n="hero.tagline"></p>
|
||||
<div class="hero__actions">
|
||||
<a class="btn btn--primary" href="#projects"><span data-i18n="hero.cta"></span><i class="ph ph-arrow-down-right"></i></a>
|
||||
<a class="btn btn--text" href="#about"><span data-i18n="hero.more"></span><i class="ph ph-arrow-right"></i></a>
|
||||
</div>
|
||||
<p class="hero__support"><i class="ph ph-terminal-window"></i><span data-i18n="hero.note"></span></p>
|
||||
<div class="hero__stats">
|
||||
<div class="hero__stat">
|
||||
<span class="hero__stat-value" id="hero-stat-servers">--</span>
|
||||
<span class="hero__stat-label" data-i18n="hero.stat.servers"></span>
|
||||
</div>
|
||||
<div class="hero__stat">
|
||||
<span class="hero__stat-value" id="hero-stat-regions">--</span>
|
||||
<span class="hero__stat-label" data-i18n="hero.stat.regions"></span>
|
||||
</div>
|
||||
<div class="hero__stat">
|
||||
<span class="hero__stat-value">${projects.length}</span>
|
||||
<span class="hero__stat-label" data-i18n="hero.stat.projects"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero__focus">
|
||||
<span class="hero__focus-label" data-i18n="hero.focusLabel"></span>
|
||||
<div class="hero__focus-list">
|
||||
<span data-i18n="hero.focus1"></span><span data-i18n="hero.focus2"></span><span data-i18n="hero.focus3"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="hero__mark" viewBox="0 0 200 200" fill="none" aria-hidden="true">
|
||||
<circle cx="100" cy="100" r="92" stroke="var(--border)" stroke-width="1" stroke-dasharray="4 6"/>
|
||||
<circle cx="100" cy="100" r="60" stroke="var(--accent)" stroke-width="1.5"/>
|
||||
<path d="M100 40 L100 160 M40 100 L160 100" stroke="var(--border)" stroke-width="1"/>
|
||||
<circle cx="100" cy="100" r="5" fill="var(--accent)"/>
|
||||
<g class="hero__orbit">
|
||||
<circle cx="100" cy="8" r="3.5" fill="var(--accent)"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" class="section reveal">
|
||||
<div class="container about__grid">
|
||||
<div class="about__bio">
|
||||
<span class="section__index">01</span>
|
||||
<h2 class="section__title" data-i18n="about.title"></h2>
|
||||
<span class="about__identity" data-i18n="about.identity"></span>
|
||||
<p data-i18n="about.bio1"></p>
|
||||
<p data-i18n="about.bio2"></p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="section__lead" data-i18n="about.skillsTitle" style="margin-bottom:16px;color:var(--text);font-weight:600;font-size:1rem;"></h3>
|
||||
<div class="skills" id="skills-list"></div>
|
||||
<h3 class="section__lead" data-i18n="about.timelineTitle" style="margin-top:32px;margin-bottom:0;color:var(--text);font-weight:600;font-size:1rem;"></h3>
|
||||
<div class="timeline">
|
||||
<div class="timeline__item">
|
||||
<span class="timeline__year">2025</span>
|
||||
<span class="timeline__label" data-i18n="about.timeline.2025"></span>
|
||||
</div>
|
||||
<div class="timeline__item">
|
||||
<span class="timeline__year">2026</span>
|
||||
<span class="timeline__label" data-i18n="about.timeline.2026"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="infra" class="infra section reveal">
|
||||
<div class="container">
|
||||
<div class="section__head">
|
||||
<span class="section__index">02</span>
|
||||
<h2 class="section__title" data-i18n="infra.title"></h2>
|
||||
<p class="section__lead" data-i18n="infra.lead"></p>
|
||||
</div>
|
||||
<div class="infra__grid">
|
||||
<div>
|
||||
<div class="infra__stats">
|
||||
<div>
|
||||
<div class="stat__value" id="stat-servers">--</div>
|
||||
<div class="stat__label" data-i18n="infra.statServers"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat__value" id="stat-regions">--</div>
|
||||
<div class="stat__label" data-i18n="infra.statRegions"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infra__globe" id="infra-globe">
|
||||
<div class="infra__globe-fallback" id="region-chips"></div>
|
||||
<span class="infra__globe-hint">
|
||||
<i class="ph ph-arrows-out-cardinal"></i>
|
||||
<span data-i18n="infra.globeHint"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="infra__services" id="infra-services"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="projects" class="section reveal">
|
||||
<div class="container">
|
||||
<div class="section__head">
|
||||
<span class="section__index">03</span>
|
||||
<h2 class="section__title" data-i18n="projects.title"></h2>
|
||||
<p class="section__lead" data-i18n="projects.lead"></p>
|
||||
</div>
|
||||
<div id="project-featured-slot"></div>
|
||||
<div class="project-grid" id="project-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="section contact reveal">
|
||||
<div class="container">
|
||||
<div class="section__head" style="margin-left:auto;margin-right:auto;text-align:center;">
|
||||
<span class="section__index">04</span>
|
||||
<h2 class="section__title" data-i18n="contact.title"></h2>
|
||||
<p class="section__lead" data-i18n="contact.lead"></p>
|
||||
</div>
|
||||
<div class="contact__links" id="contact-links"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container" data-i18n="footer.text"></div>
|
||||
</footer>
|
||||
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal" role="dialog" aria-modal="true" id="modal">
|
||||
<button class="icon-btn modal__close" id="modal-close" type="button" aria-label="Close">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
<div id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
`;
|
||||
|
||||
const SKILLS: Array<{ icon: string; label: string }> = [
|
||||
{ icon: "ph-code", label: "TypeScript" },
|
||||
{ icon: "ph-brackets-curly", label: "Go" },
|
||||
{ icon: "ph-terminal-window", label: "Linux" },
|
||||
{ icon: "ph-cloud", label: "Self-hosting" },
|
||||
{ icon: "ph-git-branch", label: "Git" },
|
||||
{ icon: "ph-brain", label: "AI / ML basics" },
|
||||
];
|
||||
|
||||
function renderSkills(): void {
|
||||
const el = document.getElementById("skills-list")!;
|
||||
el.innerHTML = SKILLS.map(
|
||||
(s) => `<div class="skill-tag"><i class="ph ${s.icon}"></i><span>${s.label}</span></div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function renderInfraServices(): void {
|
||||
const el = document.getElementById("infra-services")!;
|
||||
el.innerHTML = infraServices
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="service-row">
|
||||
<i class="ph ${s.icon}"></i>
|
||||
<div>
|
||||
<div class="service-row__name" data-i18n="${s.nameKey}"></div>
|
||||
<div class="service-row__desc" data-i18n="${s.descKey}"></div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderContacts(): void {
|
||||
const el = document.getElementById("contact-links")!;
|
||||
el.innerHTML = contacts
|
||||
.map(
|
||||
(c) => `
|
||||
<a class="contact-link" href="${c.href}"${c.external ? ` target="_blank" rel="noopener"` : ""}>
|
||||
<i class="ph ${c.icon}"></i>
|
||||
<span data-i18n="${c.labelKey}"></span>
|
||||
</a>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const infraSnapshotPromise = fetchInfraSnapshot();
|
||||
let infraSnapshotCache: InfraSnapshot | null = null;
|
||||
|
||||
function paintRegionChips(): void {
|
||||
if (!infraSnapshotCache) return;
|
||||
const chips = document.getElementById("region-chips")!;
|
||||
if (infraSnapshotCache.live && infraSnapshotCache.regions.length) {
|
||||
chips.innerHTML = infraSnapshotCache.regions
|
||||
.map((r) => `<span class="region-chip">${r}</span>`)
|
||||
.join("");
|
||||
} else {
|
||||
const label = t("infra.regionsFallback").replace("{count}", String(infraSnapshotCache.regionCount));
|
||||
chips.innerHTML = `<span class="region-chip">${label}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderInfraStats(): Promise<void> {
|
||||
infraSnapshotCache = await infraSnapshotPromise;
|
||||
document.getElementById("stat-servers")!.textContent = String(infraSnapshotCache.serverCount);
|
||||
document.getElementById("stat-regions")!.textContent = String(infraSnapshotCache.regionCount);
|
||||
document.getElementById("hero-stat-servers")!.textContent = String(infraSnapshotCache.serverCount);
|
||||
document.getElementById("hero-stat-regions")!.textContent = String(infraSnapshotCache.regionCount);
|
||||
paintRegionChips();
|
||||
}
|
||||
|
||||
function setupInfraGlobe(): void {
|
||||
const container = document.getElementById("infra-globe")!;
|
||||
const canvasMount = document.createElement("div");
|
||||
canvasMount.className = "infra__globe-canvas";
|
||||
container.prepend(canvasMount);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
mountGlobeWhenReady(container, canvasMount);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
observer.observe(container);
|
||||
}
|
||||
|
||||
async function mountGlobeWhenReady(container: HTMLElement, canvasMount: HTMLElement): Promise<void> {
|
||||
const snapshot = await infraSnapshotPromise;
|
||||
if (!snapshot.regionBreakdown.length) return;
|
||||
const { mountGlobe } = await import("./globe");
|
||||
container.classList.add("has-live");
|
||||
mountGlobe(canvasMount, snapshot);
|
||||
}
|
||||
|
||||
function openModal(html: string): void {
|
||||
const overlay = document.getElementById("modal-overlay")!;
|
||||
document.getElementById("modal-body")!.innerHTML = html;
|
||||
overlay.classList.add("is-open");
|
||||
}
|
||||
|
||||
function closeModal(): void {
|
||||
document.getElementById("modal-overlay")!.classList.remove("is-open");
|
||||
}
|
||||
|
||||
function renderProjects(): void {
|
||||
const featuredSlot = document.getElementById("project-featured-slot")!;
|
||||
const grid = document.getElementById("project-grid")!;
|
||||
const featured = projects.filter((p) => p.featured);
|
||||
const rest = projects.filter((p) => !p.featured);
|
||||
|
||||
featuredSlot.innerHTML = featured
|
||||
.map(
|
||||
(p) => `
|
||||
<article class="project-featured" data-project="${p.id}">
|
||||
${
|
||||
p.image
|
||||
? `<img class="project-featured__media" src="${p.image}" alt="" loading="lazy" />`
|
||||
: `<div class="project-featured__media" data-i18n="projects.media.placeholder"></div>`
|
||||
}
|
||||
<div>
|
||||
<span class="project-featured__tag" data-i18n="${p.id === "authrouter" ? "projects.authrouter.tag" : ""}"></span>
|
||||
<h3 class="project-featured__title" data-i18n="${p.titleKey}"></h3>
|
||||
<p class="project-featured__desc" data-i18n="${p.descKey}"></p>
|
||||
</div>
|
||||
</article>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
grid.innerHTML =
|
||||
rest
|
||||
.map(
|
||||
(p) => `
|
||||
<article class="project-card" data-project="${p.id}">
|
||||
<h3 class="project-card__title" data-i18n="${p.titleKey}"></h3>
|
||||
<p class="project-card__desc" data-i18n="${p.descKey}"></p>
|
||||
</article>`
|
||||
)
|
||||
.join("") +
|
||||
(rest.length === 0
|
||||
? `<div class="project-empty" data-i18n="projects.emptyNote"></div>`
|
||||
: "");
|
||||
|
||||
document.querySelectorAll<HTMLElement>("[data-project]").forEach((card) => {
|
||||
card.addEventListener("click", () => {
|
||||
const project = projects.find((p) => p.id === card.dataset.project);
|
||||
if (!project) return;
|
||||
openModal(`
|
||||
<span class="modal__tag" data-i18n="${project.id === "authrouter" ? "projects.authrouter.tag" : ""}"></span>
|
||||
<h3 class="modal__title" data-i18n="${project.titleKey}"></h3>
|
||||
${
|
||||
project.image
|
||||
? `<img class="modal__media" src="${project.image}" alt="" loading="lazy" />`
|
||||
: `<div class="modal__media" data-i18n="projects.media.placeholder"></div>`
|
||||
}
|
||||
<p class="modal__desc" data-i18n="${project.detailKey ?? project.descKey}"></p>
|
||||
<div class="modal__stack">
|
||||
${project.stack.map((s) => `<span class="stack-chip">${s}</span>`).join("")}
|
||||
</div>
|
||||
<a class="btn btn--primary" href="${project.repoUrl}" target="_blank" rel="noopener" data-i18n="projects.viewOnGithub"></a>
|
||||
`);
|
||||
applyTranslations();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupThemeToggle(): void {
|
||||
const btn = document.getElementById("theme-toggle")!;
|
||||
const paint = () => {
|
||||
btn.innerHTML = getTheme() === "dark" ? `<i class="ph ph-sun"></i>` : `<i class="ph ph-moon"></i>`;
|
||||
};
|
||||
paint();
|
||||
onThemeChange(paint);
|
||||
btn.addEventListener("click", toggleTheme);
|
||||
}
|
||||
|
||||
function setupLangToggle(): void {
|
||||
const btn = document.getElementById("lang-toggle")!;
|
||||
const paint = () => {
|
||||
btn.textContent = getLang() === "zh" ? "EN" : "中文";
|
||||
};
|
||||
paint();
|
||||
onLangChange(() => {
|
||||
paint();
|
||||
paintRegionChips();
|
||||
});
|
||||
btn.addEventListener("click", () => setLang(getLang() === "zh" ? "en" : "zh"));
|
||||
}
|
||||
|
||||
function setupModal(): void {
|
||||
document.getElementById("modal-close")!.addEventListener("click", closeModal);
|
||||
document.getElementById("modal-overlay")!.addEventListener("click", (e) => {
|
||||
if (e.target === document.getElementById("modal-overlay")) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
function setupScrollReveal(): void {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const targets = document.querySelectorAll<HTMLElement>(".reveal");
|
||||
if (reduceMotion) {
|
||||
targets.forEach((el) => el.classList.add("is-visible"));
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("is-visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.15 }
|
||||
);
|
||||
targets.forEach((el) => observer.observe(el));
|
||||
}
|
||||
|
||||
initTheme();
|
||||
initI18n();
|
||||
renderSkills();
|
||||
renderInfraServices();
|
||||
renderProjects();
|
||||
renderContacts();
|
||||
setupThemeToggle();
|
||||
setupLangToggle();
|
||||
setupModal();
|
||||
setupScrollReveal();
|
||||
setupInfraGlobe();
|
||||
applyTranslations();
|
||||
renderInfraStats();
|
||||
|
||||
// Ensure the empty i18n keys used as placeholders (e.g. tag on non-featured items) don't render "undefined"
|
||||
document.querySelectorAll<HTMLElement>('[data-i18n=""]').forEach((el) => el.remove());
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
@import "@fontsource/outfit/400.css";
|
||||
@import "@fontsource/outfit/500.css";
|
||||
@import "@fontsource/outfit/600.css";
|
||||
@import "@fontsource/outfit/700.css";
|
||||
@import "@fontsource/ibm-plex-mono/400.css";
|
||||
@import "@fontsource/ibm-plex-mono/500.css";
|
||||
@import "@phosphor-icons/web/regular";
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: background-color 0.3s var(--ease-standard), color 0.3s var(--ease-standard);
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Woven texture: fixed, pointer-events none, never on a scrolling container (perf) */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--texture-line) 0px,
|
||||
var(--texture-line) 1px,
|
||||
transparent 1px,
|
||||
transparent 6px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--texture-line) 0px,
|
||||
var(--texture-line) 1px,
|
||||
transparent 1px,
|
||||
transparent 6px
|
||||
);
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Scroll reveal base state, activated via IntersectionObserver in main.ts */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.6s var(--ease-standard), transform 0.6s var(--ease-standard);
|
||||
}
|
||||
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
/* ---------- Nav ---------- */
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.nav__inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav__mark {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.nav__link {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
.nav__link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-faint);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s var(--ease-standard), transform 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
padding: 0 12px;
|
||||
width: auto;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav__links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
padding: calc(var(--nav-height) + 64px) 0 80px;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero__eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero__name {
|
||||
font-size: clamp(2.6rem, 5.6vw, 4.4rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hero__tagline {
|
||||
font-size: 1.15rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 50ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
padding: 12px 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn--text:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hero__support {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero__support i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
transition: transform 0.15s var(--ease-standard), background-color 0.2s var(--ease-standard);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
color: #f5f4f1;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn--primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.hero__stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hero__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hero__stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero__stat-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hero__focus {
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px dashed var(--border-faint);
|
||||
}
|
||||
|
||||
.hero__focus-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hero__focus-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero__focus-list span {
|
||||
font-size: 0.85rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hero__mark {
|
||||
justify-self: center;
|
||||
width: min(320px, 100%);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.hero__orbit {
|
||||
transform-origin: 100px 100px;
|
||||
animation: hero-orbit-spin 12s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-orbit-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__orbit {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section__index {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding-top: calc(var(--nav-height) + 40px);
|
||||
}
|
||||
.hero__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
text-align: left;
|
||||
}
|
||||
.hero__mark {
|
||||
width: min(220px, 60vw);
|
||||
}
|
||||
.hero__stats {
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Section shared ---------- */
|
||||
.section {
|
||||
padding: 96px 0;
|
||||
}
|
||||
|
||||
.section__head {
|
||||
margin-bottom: 48px;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: clamp(2rem, 3.4vw, 2.7rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section__lead {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section {
|
||||
padding: 64px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- About ---------- */
|
||||
.about__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.about__bio p {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.about__identity {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skills {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.skill-tag i {
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-left: 1px dashed var(--border);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.timeline__item {
|
||||
position: relative;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline__item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline__item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -29px;
|
||||
top: 4px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.timeline__year {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline__label {
|
||||
font-size: 0.94rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
.skills {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skills {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Infrastructure (map-dominant, asymmetric) ---------- */
|
||||
.infra {
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border-faint);
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.infra__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
gap: 56px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.infra__stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(2.2rem, 4vw, 3rem);
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat__label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.infra__globe {
|
||||
position: relative;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.infra__globe-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.infra__globe.has-live .infra__globe-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.infra__globe-fallback {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.infra__globe-hint {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-faint);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 85%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.infra__globe-hint i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.region-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.infra__services {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.service-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px dashed var(--border-faint);
|
||||
}
|
||||
|
||||
.service-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.service-row i {
|
||||
font-size: 1.3rem;
|
||||
color: var(--accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.service-row__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.96rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.service-row__desc {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.infra__grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 36px;
|
||||
}
|
||||
.infra__stats {
|
||||
gap: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Projects (featured + grid) ---------- */
|
||||
.project-featured {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
margin-bottom: 40px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
.project-featured:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.project-featured__media {
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16/10;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border-faint);
|
||||
}
|
||||
|
||||
img.project-featured__media {
|
||||
padding: 0;
|
||||
border: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-featured__tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project-featured__title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-featured__desc {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
font-size: 0.96rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 24px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s var(--ease-standard), transform 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.project-card__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.project-empty {
|
||||
padding: 32px;
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-radius: var(--radius);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.project-featured {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: color-mix(in srgb, var(--bg) 55%, rgba(0, 0, 0, 0.4));
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s var(--ease-standard);
|
||||
}
|
||||
|
||||
.modal-overlay.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.25s var(--ease-standard);
|
||||
}
|
||||
|
||||
.modal-overlay.is-open .modal {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.modal__tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal__media {
|
||||
aspect-ratio: 16/10;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed var(--border-faint);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img.modal__media {
|
||||
padding: 0;
|
||||
border: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal__desc {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal__stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stack-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-overlay,
|
||||
.modal {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Contact (centered minimal) ---------- */
|
||||
.contact {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contact__links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.contact-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
transition: border-color 0.2s var(--ease-standard), transform 0.15s var(--ease-standard);
|
||||
}
|
||||
|
||||
.contact-link:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.contact-link i {
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.footer {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Woven Canvas design tokens.
|
||||
Theme resolution order: [data-theme] attribute (user override, persisted)
|
||||
> prefers-color-scheme (system default). Never pure #000/#fff.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--font-display: "Outfit", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--accent: #3b4fa3;
|
||||
--accent-strong: #2e3d82;
|
||||
--accent-soft: rgba(59, 79, 163, 0.12);
|
||||
|
||||
--nav-height: 64px;
|
||||
--content-max: 1160px;
|
||||
|
||||
--ease-standard: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* Light (default) */
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
--bg: #edece8;
|
||||
--bg-elevated: #f5f4f1;
|
||||
--text: #2a2b2e;
|
||||
--text-muted: #5a5b60;
|
||||
--border: rgba(59, 79, 163, 0.35);
|
||||
--border-faint: rgba(42, 43, 46, 0.12);
|
||||
--texture-line: rgba(42, 43, 46, 0.045);
|
||||
--shadow: 0 10px 30px rgba(42, 43, 46, 0.08);
|
||||
}
|
||||
|
||||
/* Dark override via explicit attribute */
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #14161c;
|
||||
--bg-elevated: #1b1e26;
|
||||
--text: #e8e8ea;
|
||||
--text-muted: #a3a5ac;
|
||||
--border: rgba(122, 145, 232, 0.4);
|
||||
--border-faint: rgba(232, 232, 234, 0.12);
|
||||
--texture-line: rgba(232, 232, 234, 0.05);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* System dark preference, only applies when user has not overridden */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]):not([data-theme="dark"]) {
|
||||
--bg: #14161c;
|
||||
--bg-elevated: #1b1e26;
|
||||
--text: #e8e8ea;
|
||||
--text-muted: #a3a5ac;
|
||||
--border: rgba(122, 145, 232, 0.4);
|
||||
--border-faint: rgba(232, 232, 234, 0.12);
|
||||
--texture-line: rgba(232, 232, 234, 0.05);
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "pw-theme";
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function detectTheme(): Theme {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return systemPrefersDark() ? "dark" : "light";
|
||||
}
|
||||
|
||||
let currentTheme: Theme = detectTheme();
|
||||
const listeners = new Set<(theme: Theme) => void>();
|
||||
|
||||
export function getTheme(): Theme {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
export function setTheme(theme: Theme): void {
|
||||
currentTheme = theme;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
listeners.forEach((fn) => fn(theme));
|
||||
}
|
||||
|
||||
export function toggleTheme(): void {
|
||||
setTheme(currentTheme === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
export function onThemeChange(fn: (theme: Theme) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function initTheme(): void {
|
||||
document.documentElement.setAttribute("data-theme", currentTheme);
|
||||
|
||||
// Only follow live system changes if the user has never made an explicit choice.
|
||||
if (!localStorage.getItem(STORAGE_KEY)) {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
if (localStorage.getItem(STORAGE_KEY)) return;
|
||||
currentTheme = e.matches ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-theme", currentTheme);
|
||||
listeners.forEach((fn) => fn(currentTheme));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user