Compare commits
3
Commits
5093e64c69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2ddcd3ec | ||
|
|
858f28323e | ||
|
|
df426c95e0 |
+3
-2
@@ -1,6 +1,7 @@
|
||||
# Deployment target: "com" or "cn". Builds default to "com" when omitted.
|
||||
SITE_VARIANT=com
|
||||
|
||||
# Build-only secret used to resolve node IPs into public coordinates.
|
||||
# Never prefix this variable with VITE_, or it may be exposed to the browser.
|
||||
# Build-only configuration used to resolve node IPs into public coordinates.
|
||||
# Never prefix these variables with VITE_, or they may be exposed to the browser.
|
||||
KOMARI_BASE_URL=https://ops.zhouhaoze.top
|
||||
KOMARI_API_KEY=
|
||||
|
||||
+34
-35
@@ -45,11 +45,10 @@ export interface NodeLocationsFile {
|
||||
}
|
||||
|
||||
export interface NodeLocationBuildOptions {
|
||||
komariBaseUrl: string;
|
||||
komariApiKey?: string;
|
||||
}
|
||||
|
||||
const KOMARI_BASE_URL = "https://ops.zhouhaoze.top";
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
if (ip.includes(":")) {
|
||||
const value = ip.toLowerCase();
|
||||
@@ -91,25 +90,38 @@ async function fetchJson(url: string, init?: RequestInit): Promise<Record<string
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function geoResult(
|
||||
latValue: unknown,
|
||||
lngValue: unknown,
|
||||
cityValue: unknown,
|
||||
countryValue: unknown,
|
||||
countryCodeValue: unknown,
|
||||
city: unknown,
|
||||
country: unknown,
|
||||
countryCode: unknown,
|
||||
): GeoResult | null {
|
||||
const lat = Number(latValue);
|
||||
const lng = Number(lngValue);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
||||
const lat = numberValue(latValue);
|
||||
const lng = numberValue(lngValue);
|
||||
if (lat === null || lng === null || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
|
||||
return {
|
||||
lat,
|
||||
lng,
|
||||
city: typeof cityValue === "string" ? cityValue : "",
|
||||
country: typeof countryValue === "string" ? countryValue : "",
|
||||
countryCode: typeof countryCodeValue === "string" ? countryCodeValue : "",
|
||||
city: typeof city === "string" ? city : "",
|
||||
country: typeof country === "string" ? country : "",
|
||||
countryCode: typeof countryCode === "string" ? countryCode : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
|
||||
const data = await fetchJson(
|
||||
`https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`,
|
||||
);
|
||||
if (data.success !== true) return null;
|
||||
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
|
||||
}
|
||||
|
||||
async function locateWithIpSb(ip: string): Promise<GeoResult | null> {
|
||||
const data = await fetchJson(`https://api.ip.sb/geoip/${encodeURIComponent(ip)}`);
|
||||
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
|
||||
@@ -122,28 +134,15 @@ async function locateWithIpinfo(ip: string): Promise<GeoResult | null> {
|
||||
return geoResult(lat, lng, data.city, data.country, data.country);
|
||||
}
|
||||
|
||||
async function locateWithIpWho(ip: string): Promise<GeoResult | null> {
|
||||
const data = await fetchJson(
|
||||
`https://ipwho.is/${encodeURIComponent(ip)}?fields=success,country,country_code,city,latitude,longitude`,
|
||||
);
|
||||
if (data.success !== true) return null;
|
||||
return geoResult(data.latitude, data.longitude, data.city, data.country, data.country_code);
|
||||
}
|
||||
|
||||
async function locateWithIpapi(ip: string): Promise<GeoResult | null> {
|
||||
const data = await fetchJson(`https://ipapi.co/${encodeURIComponent(ip)}/json/`);
|
||||
if (data.error === true) return null;
|
||||
return geoResult(data.latitude, data.longitude, data.city, data.country_name, data.country_code);
|
||||
}
|
||||
|
||||
async function locateIp(ip: string): Promise<GeoResult | null> {
|
||||
const providers = [locateWithIpSb, locateWithIpinfo, locateWithIpWho, locateWithIpapi];
|
||||
const providers = [() => locateWithIpWho(ip), () => locateWithIpSb(ip), () => locateWithIpinfo(ip)];
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const result = await provider(ip);
|
||||
if (result) return result;
|
||||
const location = await provider();
|
||||
if (location) return location;
|
||||
} catch {
|
||||
// Continue in provider order; the client keeps its country-level fallback if all fail.
|
||||
// Try the next provider; the country-level fallback remains available in the client.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -164,8 +163,8 @@ async function mapWithConcurrency<T, R>(items: T[], limit: number, worker: (item
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchKomariNodes(apiKey: string): Promise<KomariNode[]> {
|
||||
const payload = await fetchJson(`${KOMARI_BASE_URL}/api/rpc2`, {
|
||||
async function fetchKomariNodes(baseUrl: string, apiKey: string): Promise<KomariNode[]> {
|
||||
const payload = await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/rpc2`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
@@ -179,19 +178,19 @@ async function fetchKomariNodes(apiKey: string): Promise<KomariNode[]> {
|
||||
return Object.values(rpc.result).filter((node) => !node.hidden);
|
||||
}
|
||||
|
||||
async function fetchPublicNodes(): Promise<PublicNode[]> {
|
||||
const payload = (await fetchJson(`${KOMARI_BASE_URL}/api/nodes`)) as KomariNodesResponse;
|
||||
async function fetchPublicNodes(baseUrl: string): Promise<PublicNode[]> {
|
||||
const payload = (await fetchJson(`${baseUrl.replace(/\/$/, "")}/api/nodes`)) as KomariNodesResponse;
|
||||
if (!Array.isArray(payload.data)) throw new Error("Komari public API returned an invalid node list.");
|
||||
return payload.data.map((node) => ({ uuid: node.uuid, name: node.name, region: node.region ?? "" }));
|
||||
}
|
||||
|
||||
export async function generateNodeLocations(options: NodeLocationBuildOptions): Promise<NodeLocationsFile> {
|
||||
const publicNodes = await fetchPublicNodes();
|
||||
const publicNodes = await fetchPublicNodes(options.komariBaseUrl);
|
||||
if (!options.komariApiKey) {
|
||||
return { version: 1, generatedAt: new Date().toISOString(), nodes: publicNodes, locations: [] };
|
||||
}
|
||||
|
||||
const nodes = await fetchKomariNodes(options.komariApiKey);
|
||||
const nodes = await fetchKomariNodes(options.komariBaseUrl, options.komariApiKey);
|
||||
const nodesWithIp = nodes.flatMap((node) => {
|
||||
const ip = firstPublicIp(node);
|
||||
return ip ? [{ node, ip }] : [];
|
||||
|
||||
+41
-135
@@ -14,14 +14,6 @@ interface GlobePoint {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GlobeLight {
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude: number;
|
||||
size: number;
|
||||
kind: "core" | "spark";
|
||||
}
|
||||
|
||||
function buildPoints(snapshot: InfraSnapshot): GlobePoint[] {
|
||||
const locationsById = new Map(snapshot.nodeLocations.map((location) => [location.uuid, location]));
|
||||
const points = snapshot.nodes.flatMap((node) => {
|
||||
@@ -53,7 +45,7 @@ function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] {
|
||||
if (group.length === 1) return group;
|
||||
return group.map((point, index) => {
|
||||
const angle = (index / group.length) * Math.PI * 2;
|
||||
const radius = 0.65 + index * 0.72;
|
||||
const radius = 0.28 + Math.floor(index / 8) * 0.12;
|
||||
return {
|
||||
...point,
|
||||
lat: Math.max(-89.9, Math.min(89.9, point.lat + Math.sin(angle) * radius)),
|
||||
@@ -63,77 +55,11 @@ function spreadOverlappingPoints(points: GlobePoint[]): GlobePoint[] {
|
||||
});
|
||||
}
|
||||
|
||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
const MAX_LIGHTS = 420;
|
||||
|
||||
function seededNoise(seed: number): number {
|
||||
const value = Math.sin(seed * 12.9898) * 43758.5453;
|
||||
return value - Math.floor(value);
|
||||
}
|
||||
|
||||
function wrapLongitude(lng: number): number {
|
||||
return ((lng + 540) % 360) - 180;
|
||||
}
|
||||
|
||||
function buildLightField(points: GlobePoint[]): GlobeLight[] {
|
||||
const sparkCount = points.length <= 12 ? 10 : points.length <= 50 ? 8 : points.length <= 90 ? 5 : 2;
|
||||
const lights: GlobeLight[] = [];
|
||||
|
||||
for (const [pointIndex, point] of points.entries()) {
|
||||
if (lights.length >= MAX_LIGHTS) break;
|
||||
lights.push({ lat: point.lat, lng: point.lng, altitude: 0.006, size: 3.1, kind: "core" });
|
||||
|
||||
const densityBoost = Math.min(4, Math.max(0, Math.round(point.count) - 1));
|
||||
const clusterSize = sparkCount + densityBoost;
|
||||
const seed = Math.abs(Math.round((point.lat + 90) * 997 + (point.lng + 180) * 619 + pointIndex * 7919));
|
||||
const longitudeScale = Math.max(0.35, Math.cos(THREE.MathUtils.degToRad(point.lat)));
|
||||
|
||||
for (let index = 0; index < clusterSize && lights.length < MAX_LIGHTS; index += 1) {
|
||||
const noise = seededNoise(seed + index * 17);
|
||||
const angle = index * GOLDEN_ANGLE + noise * 0.8;
|
||||
const radius = 0.9 + Math.sqrt((index + 0.5) / clusterSize) * (4.2 + noise * 0.85);
|
||||
lights.push({
|
||||
lat: THREE.MathUtils.clamp(point.lat + Math.cos(angle) * radius, -89.8, 89.8),
|
||||
lng: wrapLongitude(point.lng + (Math.sin(angle) * radius) / longitudeScale),
|
||||
altitude: 0.0035 + noise * 0.002,
|
||||
size: 1.75 + noise * 0.75,
|
||||
kind: "spark",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lights;
|
||||
}
|
||||
|
||||
function createGlowTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return new THREE.CanvasTexture(canvas);
|
||||
|
||||
const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||||
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
gradient.addColorStop(0.18, "rgba(255, 255, 255, 0.96)");
|
||||
gradient.addColorStop(0.44, "rgba(255, 255, 255, 0.42)");
|
||||
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function lightColor(accent: string): THREE.Color {
|
||||
return new THREE.Color(accent).lerp(new THREE.Color(cssVar("--text", "#f5f4f1")), 0.38);
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.substring(0, 2), 16);
|
||||
@@ -149,9 +75,9 @@ function sizeOf(container: HTMLElement): { width: number; height: number } {
|
||||
|
||||
export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): () => void {
|
||||
const points = buildPoints(snapshot);
|
||||
const lights = buildLightField(points);
|
||||
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);
|
||||
@@ -167,13 +93,12 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
container.replaceChildren(renderer.domElement);
|
||||
renderer.domElement.style.cursor = "grab";
|
||||
renderer.domElement.setAttribute("aria-label", "Interactive globe visualizing global infrastructure distribution");
|
||||
renderer.domElement.setAttribute("aria-label", "Interactive globe showing server regions");
|
||||
renderer.domElement.dataset.nodeCount = String(points.length);
|
||||
renderer.domElement.dataset.visualLightCount = String(lights.length);
|
||||
renderer.domElement.dataset.preciseLocationCount = String(snapshot.nodeLocations.length);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.82));
|
||||
const frontLight = new THREE.DirectionalLight(0xffffff, 0.58);
|
||||
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);
|
||||
@@ -183,74 +108,58 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
||||
rimLight.position.set(0, -120, -260);
|
||||
scene.add(rimLight);
|
||||
|
||||
const globeMaterial = new THREE.MeshPhongMaterial({
|
||||
color: new THREE.Color(globeBase),
|
||||
emissive: new THREE.Color(accent),
|
||||
emissiveIntensity: 0.045,
|
||||
shininess: 9,
|
||||
transparent: true,
|
||||
opacity: 0.98,
|
||||
});
|
||||
const glowTexture = createGlowTexture();
|
||||
const coreLightMaterial = new THREE.SpriteMaterial({
|
||||
map: glowTexture,
|
||||
color: lightColor(accent),
|
||||
transparent: true,
|
||||
opacity: 0.98,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sparkLightMaterial = new THREE.SpriteMaterial({
|
||||
map: glowTexture,
|
||||
color: lightColor(accent),
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
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.075))
|
||||
.polygonSideColor(() => hexToRgba(accent, 0.018))
|
||||
.polygonStrokeColor(() => hexToRgba(accent, 0.24))
|
||||
.polygonAltitude(0.0025)
|
||||
.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.14);
|
||||
.atmosphereAltitude(0.18)
|
||||
.pointsData(points)
|
||||
.pointLat("lat")
|
||||
.pointLng("lng")
|
||||
.pointColor(() => accent)
|
||||
.pointAltitude(0.018)
|
||||
.pointRadius(0.42);
|
||||
|
||||
globe
|
||||
.customLayerData(lights)
|
||||
.customThreeObject((datum) => {
|
||||
const light = datum as GlobeLight;
|
||||
const sprite = new THREE.Sprite(light.kind === "core" ? coreLightMaterial : sparkLightMaterial);
|
||||
sprite.scale.setScalar(light.size);
|
||||
return sprite;
|
||||
})
|
||||
.customThreeObjectUpdate((object, datum) => {
|
||||
const light = datum as GlobeLight;
|
||||
const { x, y, z } = globe.getCoords(light.lat, light.lng, light.altitude);
|
||||
object.position.set(x, y, z);
|
||||
});
|
||||
.labelsData(points)
|
||||
.labelLat("lat")
|
||||
.labelLng("lng")
|
||||
.labelText(() => "")
|
||||
.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);
|
||||
const initialView = globe.getCoords(24, 110, 1.8);
|
||||
camera.position.set(initialView.x, initialView.y, initialView.z);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
function repaint(): void {
|
||||
accent = cssVar("--accent", "#3b4fa3");
|
||||
textColor = cssVar("--text", "#f5f4f1");
|
||||
globeBase = cssVar("--bg-elevated", "#1b1e26");
|
||||
globeMaterial.color.set(globeBase);
|
||||
globeMaterial.emissive.set(accent);
|
||||
coreLightMaterial.color.copy(lightColor(accent));
|
||||
sparkLightMaterial.color.copy(lightColor(accent));
|
||||
globe
|
||||
.polygonCapColor(() => hexToRgba(accent, 0.075))
|
||||
.polygonSideColor(() => hexToRgba(accent, 0.018))
|
||||
.polygonStrokeColor(() => hexToRgba(accent, 0.24))
|
||||
.atmosphereColor(accent);
|
||||
.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);
|
||||
|
||||
@@ -299,9 +208,6 @@ export function mountGlobe(container: HTMLElement, snapshot: InfraSnapshot): ()
|
||||
controls.dispose();
|
||||
globe._destructor();
|
||||
globeMaterial.dispose();
|
||||
coreLightMaterial.dispose();
|
||||
sparkLightMaterial.dispose();
|
||||
glowTexture.dispose();
|
||||
renderer.dispose();
|
||||
scene.clear();
|
||||
if (renderer.domElement.parentElement === container) container.removeChild(renderer.domElement);
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
"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, explore the global network",
|
||||
"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.",
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
"infra.uptime.desc": "监控各项服务的在线状态和响应情况。",
|
||||
"infra.alist.name": "Alist",
|
||||
"infra.alist.desc": "聚合多个存储来源的文件管理与分享服务。",
|
||||
"infra.globeHint": "拖拽旋转,查看全球分布",
|
||||
"infra.globeHint": "拖拽旋转,查看节点分布",
|
||||
|
||||
"projects.title": "项目",
|
||||
"projects.lead": "一些自己写的和维护的项目,点击卡片可以看更多细节。",
|
||||
|
||||
+5
-1
@@ -7,6 +7,7 @@ import { projects } from "./data/projects";
|
||||
import { infraServices } from "./data/infra";
|
||||
import { contacts } from "./data/contacts";
|
||||
import { fetchInfraSnapshot, type InfraSnapshot } from "./komari";
|
||||
import { IS_CN_SITE } from "./site-config";
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app")!;
|
||||
|
||||
@@ -156,7 +157,10 @@ app.innerHTML = `
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container" data-i18n="footer.text"></div>
|
||||
<div class="container footer__inner">
|
||||
<span data-i18n="footer.text"></span>
|
||||
${IS_CN_SITE ? `<a class="footer__icp" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2026004383号-1</a>` : ""}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
|
||||
@@ -465,15 +465,6 @@
|
||||
.infra__globe-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 50% 48%, color-mix(in srgb, var(--accent) 8%, transparent) 0, transparent 48%),
|
||||
radial-gradient(circle at 50% 52%, color-mix(in srgb, var(--text) 4%, transparent) 0, transparent 66%);
|
||||
}
|
||||
|
||||
.infra__globe-canvas canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.infra__globe.has-live .infra__globe-fallback {
|
||||
@@ -832,6 +823,23 @@ img.modal__media {
|
||||
border-top: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer__icp {
|
||||
color: inherit;
|
||||
transition: color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
.footer__icp:hover,
|
||||
.footer__icp:focus-visible {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const variant = resolveVariant(process.env.SITE_VARIANT ?? env.SITE_VARIANT);
|
||||
const nodeLocationOptions = {
|
||||
komariBaseUrl: process.env.KOMARI_BASE_URL ?? env.KOMARI_BASE_URL ?? "https://ops.zhouhaoze.top",
|
||||
komariApiKey: process.env.KOMARI_API_KEY ?? env.KOMARI_API_KEY,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user