commit 6ea00cfe434dd5396f7f29ee96168f9c46a75327 Author: YiYi Date: Tue Aug 11 23:38:08 2026 +0800 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. diff --git a/.bind-domain.cjs b/.bind-domain.cjs new file mode 100644 index 0000000..e7b3e73 --- /dev/null +++ b/.bind-domain.cjs @@ -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); + }); diff --git a/.create-dns.cjs b/.create-dns.cjs new file mode 100644 index 0000000..63f56b8 --- /dev/null +++ b/.create-dns.cjs @@ -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); + }); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/index.html b/index.html new file mode 100644 index 0000000..ccd4e9c --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + + + Haoze Zhou + + + +
+ + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..0f35b53 Binary files /dev/null and b/logo.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..72ab474 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1369 @@ +{ + "name": "personalweb", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "personalweb", + "version": "0.0.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" + }, + "devDependencies": { + "@types/three": "^0.183.0", + "@types/topojson-client": "^3.1.5", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/outfit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/outfit/-/outfit-5.3.0.tgz", + "integrity": "sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phosphor-icons/web": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz", + "integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.4.0.tgz", + "integrity": "sha512-GQrHc6WSDHhf32GB/b9AGyOeC05AQ3nGN7ooWTrlCYefKn7X9/2wG9BNexF47AuyDq5jPdOvITVR26BLFWV+mA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.4.0.tgz", + "integrity": "sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.4.0.tgz", + "integrity": "sha512-OAsc3qdNx+tRqzWmMNFMnVlWWACIqnYqIejZKvb2oKkKPYJJrURPXZ6OdrGD58ljJMolLoqwIZGSx3VndaEjxg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.4.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.0.tgz", + "integrity": "sha512-AaGkvloQhxdrfMm/HbY8cpOz1K1jkEELn6zjFoY3yhAiC7zhhZE19+gDBybYwKk+GqXmWKxqDCB43NqyW3+QZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/three/node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", + "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-delaunay": "6", + "d3-geo": "3", + "d3-tricontour": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-tricontour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", + "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", + "license": "ISC", + "dependencies": { + "d3-delaunay": "6", + "d3-scale": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/frame-ticker": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/frame-ticker/-/frame-ticker-1.0.3.tgz", + "integrity": "sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==", + "license": "MIT", + "dependencies": { + "simplesignal": "^2.1.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/h3-js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.5.0.tgz", + "integrity": "sha512-uKmdPc+DuarnR+XqZxEuWOMw7KzzKROrx3MLeJpFnMOs78S9M5eZ+X5RieS9UcSFQqbeXzbfWuX/W9Pyajinqw==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/point-in-polygon-hao": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", + "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/simplesignal": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", + "integrity": "sha512-PEo2qWpUke7IMhlqiBxrulIFvhJRLkl1ih52Rwa+bPjzhJepcd4GIjn2RiQmFSx3dQvsEAgF0/lXMwMN7vODaA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.183.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.0.tgz", + "integrity": "sha512-G6SH2jfefIVa2YI4JL2VbgQhrrbp1A8dRc7lr3PW827kdVyaX2RgH6M5FmjmdVFLgSHppyg3OYOZdTfWElle+g==", + "license": "MIT", + "peer": true + }, + "node_modules/three-conic-polygon-geometry": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/three-conic-polygon-geometry/-/three-conic-polygon-geometry-2.1.3.tgz", + "integrity": "sha512-7KYcgd7rYg91L8AnCEddeyhw4qlv9+8NAI0luHq7QGt26icWUGNCsvLyD+u6FPKjP+un3zGBi4JGzqgGpmBSWA==", + "license": "MIT", + "dependencies": { + "@turf/boolean-point-in-polygon": "^7.2", + "d3-array": "1 - 3", + "d3-geo": "1 - 3", + "d3-geo-voronoi": "2", + "d3-scale": "1 - 4", + "delaunator": "5", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-geojson-geometry": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/three-geojson-geometry/-/three-geojson-geometry-2.1.1.tgz", + "integrity": "sha512-dC7bF3ri1goDcihYhzACHOBQqu7YNNazYLa2bSydVIiJUb3jDFojKSy+gNj2pMkqZNSVjssSmdY9zlmnhEpr1w==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-interpolate": "1 - 3", + "earcut": "3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.72.0" + } + }, + "node_modules/three-globe": { + "version": "2.45.2", + "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.2.tgz", + "integrity": "sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "d3-array": "3", + "d3-color": "3", + "d3-geo": "3", + "d3-interpolate": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "data-bind-mapper": "1", + "frame-ticker": "1", + "h3-js": "4", + "index-array-by": "1", + "kapsule": "^1.16", + "three-conic-polygon-geometry": "2", + "three-geojson-geometry": "2", + "three-slippy-map-globe": "1", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/three-slippy-map-globe": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/three-slippy-map-globe/-/three-slippy-map-globe-1.0.6.tgz", + "integrity": "sha512-PCUR+X+1kYFYtQBf8+b/ct8xBHtnkeu7FItRYBeFxyIe3ksnGuLi0H9RAxAfVSSUsZVbKIKNz9q1atEjynrrkg==", + "license": "MIT", + "dependencies": { + "d3-geo": "1 - 3", + "d3-octree": "^1.1", + "d3-scale": "1 - 4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.154" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6057542 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/authrouter.png b/public/images/authrouter.png new file mode 100644 index 0000000..a0fa5b0 Binary files /dev/null and b/public/images/authrouter.png differ diff --git a/src/data/contacts.ts b/src/data/contacts.ts new file mode 100644 index 0000000..bfc2f6b --- /dev/null +++ b/src/data/contacts.ts @@ -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 }, +]; diff --git a/src/data/infra.ts b/src/data/infra.ts new file mode 100644 index 0000000..0a052a9 --- /dev/null +++ b/src/data/infra.ts @@ -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, +}; diff --git a/src/data/projects.ts b/src/data/projects.ts new file mode 100644 index 0000000..e99c461 --- /dev/null +++ b/src/data/projects.ts @@ -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", + }, +]; diff --git a/src/data/regions.ts b/src/data/regions.ts new file mode 100644 index 0000000..3746d93 --- /dev/null +++ b/src/data/regions.ts @@ -0,0 +1,44 @@ +export interface RegionInfo { + code: string; + lat: number; + lng: number; + name: string; +} + +const REGION_TABLE: Record = { + 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; +} diff --git a/src/globe.ts b/src/globe.ts new file mode 100644 index 0000000..c91669d --- /dev/null +++ b/src/globe.ts @@ -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); + }; +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..aaedac3 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,58 @@ +import en from "./locales/en.json"; +import zh from "./locales/zh.json"; + +export type Lang = "en" | "zh"; + +const dictionaries: Record> = { 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("[data-i18n]").forEach((el) => { + const key = el.dataset.i18n; + if (!key) return; + el.textContent = t(key); + }); + document.querySelectorAll("[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(); +} diff --git a/src/komari.ts b/src/komari.ts new file mode 100644 index 0000000..7bcbf94 --- /dev/null +++ b/src/komari.ts @@ -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 { + 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(); + 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, + }; + } +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..9c9a6f2 --- /dev/null +++ b/src/locales/en.json @@ -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" +} diff --git a/src/locales/zh.json b/src/locales/zh.json new file mode 100644 index 0000000..b53c719 --- /dev/null +++ b/src/locales/zh.json @@ -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 周昊泽" +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9621668 --- /dev/null +++ b/src/main.ts @@ -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("#app")!; + +app.innerHTML = ` + + +
+
+
+
+ +

+

+
+ + +
+

+
+
+ -- + +
+
+ -- + +
+
+ ${projects.length} + +
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ 01 +

+ +

+

+
+
+

+
+

+
+
+ 2025 + +
+
+ 2026 + +
+
+
+
+
+ +
+
+
+ 02 +

+

+
+
+
+
+
+
--
+
+
+
+
--
+
+
+
+
+
+ + + + +
+
+
+
+
+
+ +
+
+
+ 03 +

+

+
+ +
+
+
+ +
+
+
+ 04 +

+

+
+ +
+
+
+ +
+
+
+ + + +`; + +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) => `
${s.label}
` + ).join(""); +} + +function renderInfraServices(): void { + const el = document.getElementById("infra-services")!; + el.innerHTML = infraServices + .map( + (s) => ` +
+ +
+
+
+
+
` + ) + .join(""); +} + +function renderContacts(): void { + const el = document.getElementById("contact-links")!; + el.innerHTML = contacts + .map( + (c) => ` + + + + ` + ) + .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) => `${r}`) + .join(""); + } else { + const label = t("infra.regionsFallback").replace("{count}", String(infraSnapshotCache.regionCount)); + chips.innerHTML = `${label}`; + } +} + +async function renderInfraStats(): Promise { + 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 { + 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) => ` + ` + ) + .join(""); + + grid.innerHTML = + rest + .map( + (p) => ` +
+

+

+
` + ) + .join("") + + (rest.length === 0 + ? `
` + : ""); + + document.querySelectorAll("[data-project]").forEach((card) => { + card.addEventListener("click", () => { + const project = projects.find((p) => p.id === card.dataset.project); + if (!project) return; + openModal(` + + + ${ + project.image + ? `` + : `` + } + + + + `); + applyTranslations(); + }); + }); +} + +function setupThemeToggle(): void { + const btn = document.getElementById("theme-toggle")!; + const paint = () => { + btn.innerHTML = getTheme() === "dark" ? `` : ``; + }; + 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(".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('[data-i18n=""]').forEach((el) => el.remove()); + diff --git a/src/styles/base.css b/src/styles/base.css new file mode 100644 index 0000000..575e8b3 --- /dev/null +++ b/src/styles/base.css @@ -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; + } +} diff --git a/src/styles/components.css b/src/styles/components.css new file mode 100644 index 0000000..3626bc3 --- /dev/null +++ b/src/styles/components.css @@ -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); +} + + + + + diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..ab34741 --- /dev/null +++ b/src/styles/tokens.css @@ -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); + } +} diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..0b91f6c --- /dev/null +++ b/src/theme.ts @@ -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)); + }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..206b5e0 --- /dev/null +++ b/tsconfig.json @@ -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"] +}