new pipeline and build improvements
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
.git
|
||||
.gitea
|
||||
utils/xor-enc.py
|
||||
dist
|
||||
node_modules
|
||||
utils/xor-enc.py
|
||||
|
||||
22
.gitea/workflows/deploy.yaml
Normal file
22
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Homepage Deploy (Production)
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Homepage
|
||||
runs-on: ubuntu-amd64
|
||||
needs: build
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Recreate Container
|
||||
uses: https://git.ivanch.me/ivanch/pipeline-actions/ssh-deploy@main
|
||||
with:
|
||||
ssh_host: ${{ secrets.HOST }}
|
||||
ssh_username: ${{ secrets.USERNAME }}
|
||||
ssh_key: ${{ secrets.KEY }}
|
||||
ssh_port: ${{ secrets.PORT }}
|
||||
remote_dir: ${{ secrets.DIR }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Homepage Build and Deploy
|
||||
name: Homepage Build and Deploy (Test)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -41,4 +41,4 @@ jobs:
|
||||
ssh_username: ${{ secrets.USERNAME }}
|
||||
ssh_key: ${{ secrets.KEY }}
|
||||
ssh_port: ${{ secrets.PORT }}
|
||||
remote_dir: ${{ secrets.DIR }}
|
||||
remote_dir: ${{ secrets.TEST_DIR }}
|
||||
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
7
404.html
7
404.html
@@ -15,6 +15,11 @@
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||
|
||||
<title>404 | ivanch</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="stylesheet" href="css/matrix.css">
|
||||
<link rel="stylesheet" href="css/button.css">
|
||||
@@ -48,4 +53,4 @@
|
||||
<script src="js/matrix.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,19 +1,18 @@
|
||||
FROM node:alpine AS build
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Install terser to minify JS files
|
||||
RUN npm install -g terser
|
||||
RUN if [ -d "js" ]; then find js -type f -name "*.js" -exec sh -c 'for f; do terser "$f" -c -m -o "$f"; done' sh {} +; fi
|
||||
COPY . .
|
||||
RUN OUTPUT_DIR=/dist npm run build:production
|
||||
|
||||
FROM nginx:alpine-slim AS final
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY . /usr/share/nginx/html
|
||||
COPY --from=build /src/js /usr/share/nginx/html/js
|
||||
COPY --from=build /dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
@import url('variables.css');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400..700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -60,7 +56,6 @@ img {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-lg), var(--inset-shadow);
|
||||
will-change: box-shadow;
|
||||
animation: glass-shadow 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
|
||||
|
||||
<title>ivanch</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
|
||||
<link rel="stylesheet" href="css/sections/profile.css">
|
||||
@@ -34,7 +38,7 @@
|
||||
<div class="glass hero-card">
|
||||
<div class="profile-container">
|
||||
<div class="avatar">
|
||||
<img src="/assets/lain.png" alt="Avatar" />
|
||||
<img src="/assets/lain.png" alt="Avatar" width="160" height="160" decoding="async" />
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<div class="profile-info-item">
|
||||
@@ -152,4 +156,4 @@
|
||||
<script src="js/matrix.js"></script>
|
||||
<script src="js/social.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
310
js/matrix.js
310
js/matrix.js
@@ -1,13 +1,21 @@
|
||||
class MatrixBackground {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('matrixCanvas');
|
||||
if (!this.canvas) return;
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
if (!this.ctx) return;
|
||||
|
||||
this.dots = [];
|
||||
this.connections = [];
|
||||
this.mouse = { x: -9999, y: -9999, active: false };
|
||||
this.rafId = null;
|
||||
this.isRunning = true;
|
||||
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
this.isRunning = false;
|
||||
this.lastTimestamp = 0;
|
||||
this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
this.prefersReducedMotion = this.motionQuery.matches;
|
||||
this.dotSpriteCache = new Map();
|
||||
this.onAnimationFrame = (timestamp) => this.animate(timestamp);
|
||||
|
||||
this.config = {
|
||||
dotCount: this.getMobileDotCount(),
|
||||
@@ -18,7 +26,6 @@ class MatrixBackground {
|
||||
dotSize: 3,
|
||||
mouseRadius: 110,
|
||||
mouseForce: 0.6,
|
||||
// Palette synced to the UI accent tokens
|
||||
colors: {
|
||||
dotDefault: 'rgba(230, 240, 255, 0.78)',
|
||||
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
|
||||
@@ -51,15 +58,18 @@ class MatrixBackground {
|
||||
init() {
|
||||
this.handleResize();
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.renderFrame(0);
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Pointer reactivity — push dots away from cursor for a "matrix repel" feel.
|
||||
// Touch devices are included; the dot pushes happen only while touched.
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
this.mouse.x = e.clientX;
|
||||
this.mouse.y = e.clientY;
|
||||
window.addEventListener('pointermove', (event) => {
|
||||
this.mouse.x = event.clientX;
|
||||
this.mouse.y = event.clientY;
|
||||
this.mouse.active = true;
|
||||
}, { passive: true });
|
||||
|
||||
@@ -69,35 +79,59 @@ class MatrixBackground {
|
||||
this.mouse.y = -9999;
|
||||
});
|
||||
|
||||
// Pause the rAF loop when the tab is hidden to save CPU / battery.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.stop();
|
||||
} else if (this.prefersReducedMotion) {
|
||||
this.renderFrame(this.lastTimestamp);
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Resize is debounced via rAF; disabled on mobile for perf parity with original.
|
||||
const updateMotionPreference = (event) => {
|
||||
this.prefersReducedMotion = event.matches;
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.stop();
|
||||
this.renderFrame(this.lastTimestamp);
|
||||
} else if (!document.hidden) {
|
||||
this.start();
|
||||
}
|
||||
};
|
||||
|
||||
if (this.motionQuery.addEventListener) {
|
||||
this.motionQuery.addEventListener('change', updateMotionPreference);
|
||||
} else {
|
||||
this.motionQuery.addListener(updateMotionPreference);
|
||||
}
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
let resizeRaf = null;
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeRaf) cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(() => this.handleResize());
|
||||
if (resizeRaf !== null) cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(() => {
|
||||
resizeRaf = null;
|
||||
this.handleResize();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.rafId || !this.isRunning) return;
|
||||
this.animate();
|
||||
if (this.rafId !== null || this.prefersReducedMotion || document.hidden) return;
|
||||
|
||||
this.isRunning = true;
|
||||
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId) {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
|
||||
this.rafId = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
@@ -105,12 +139,17 @@ class MatrixBackground {
|
||||
this.canvas.height = window.innerHeight;
|
||||
this.config.dotCount = this.getMobileDotCount();
|
||||
this.config.maxConnections = this.getMobileMaxConnections();
|
||||
this.connections = [];
|
||||
this.createDots();
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.renderFrame(this.lastTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
createDots() {
|
||||
this.dots = [];
|
||||
for (let i = 0; i < this.config.dotCount; i++) {
|
||||
for (let index = 0; index < this.config.dotCount; index++) {
|
||||
this.createDot();
|
||||
}
|
||||
}
|
||||
@@ -118,7 +157,6 @@ class MatrixBackground {
|
||||
createDot() {
|
||||
const startX = Math.random() * (this.canvas.width - 20) + 10;
|
||||
const startY = Math.random() * (this.canvas.height - 20) + 10;
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
|
||||
const baseVelocityX = Math.cos(angle) * speed;
|
||||
@@ -141,36 +179,32 @@ class MatrixBackground {
|
||||
|
||||
updateDots() {
|
||||
const radius = this.config.mouseRadius;
|
||||
const radiusSq = radius * radius;
|
||||
const radiusSquared = radius * radius;
|
||||
const force = this.config.mouseForce;
|
||||
|
||||
this.dots.forEach(dot => {
|
||||
// Mouse repulsion
|
||||
for (const dot of this.dots) {
|
||||
if (this.mouse.active) {
|
||||
const dx = dot.x - this.mouse.x;
|
||||
const dy = dot.y - this.mouse.y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq < radiusSq && distSq > 0.5) {
|
||||
const dist = Math.sqrt(distSq);
|
||||
const strength = (1 - dist / radius) * force;
|
||||
dot.vx += (dx / dist) * strength;
|
||||
dot.vy += (dy / dist) * strength;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distanceSquared < radiusSquared && distanceSquared > 0.5) {
|
||||
const distance = Math.sqrt(distanceSquared);
|
||||
const strength = (1 - distance / radius) * force;
|
||||
dot.vx += (dx / distance) * strength;
|
||||
dot.vy += (dy / distance) * strength;
|
||||
}
|
||||
}
|
||||
|
||||
dot.x += dot.vx;
|
||||
dot.y += dot.vy;
|
||||
|
||||
// Ease back to a gentle autonomous drift after pointer interaction.
|
||||
dot.wanderPhase += dot.wanderSpeed;
|
||||
|
||||
const wanderX = Math.cos(dot.wanderPhase) * 0.03;
|
||||
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
|
||||
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
|
||||
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
|
||||
|
||||
// Reflect at the canvas edge. Keep the base velocity in sync with
|
||||
// the bounce so the drift easing does not push the dot back into
|
||||
// the same wall on the next frames.
|
||||
const dotRadius = dot.size / 2;
|
||||
const minX = dotRadius;
|
||||
const minY = dotRadius;
|
||||
@@ -197,69 +231,160 @@ class MatrixBackground {
|
||||
dot.baseVy = -Math.abs(dot.baseVy);
|
||||
}
|
||||
|
||||
// A large pointer impulse can overshoot both edges by more than
|
||||
// one frame, so clamp after reflecting the position.
|
||||
dot.x = Math.max(minX, Math.min(maxX, dot.x));
|
||||
dot.y = Math.max(minY, Math.min(maxY, dot.y));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getDotSprite(size, isConnected) {
|
||||
const roundedSize = Math.round(size * 2) / 2;
|
||||
const state = isConnected ? 'connected' : 'default';
|
||||
const cacheKey = state + ':' + roundedSize;
|
||||
const cachedSprite = this.dotSpriteCache.get(cacheKey);
|
||||
|
||||
if (cachedSprite) {
|
||||
return cachedSprite;
|
||||
}
|
||||
|
||||
const padding = 10;
|
||||
const dimension = Math.ceil(roundedSize + padding * 2);
|
||||
const sprite = document.createElement('canvas');
|
||||
sprite.width = dimension;
|
||||
sprite.height = dimension;
|
||||
|
||||
const spriteContext = sprite.getContext('2d');
|
||||
spriteContext.shadowBlur = 6;
|
||||
spriteContext.shadowColor = isConnected
|
||||
? this.config.colors.dotConnectedGlow
|
||||
: this.config.colors.dotDefaultGlow;
|
||||
spriteContext.fillStyle = isConnected
|
||||
? this.config.colors.dotConnected
|
||||
: this.config.colors.dotDefault;
|
||||
spriteContext.beginPath();
|
||||
spriteContext.arc(dimension / 2, dimension / 2, roundedSize / 2, 0, Math.PI * 2);
|
||||
spriteContext.fill();
|
||||
|
||||
this.dotSpriteCache.set(cacheKey, sprite);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
drawDots() {
|
||||
this.dots.forEach(dot => {
|
||||
const isConnected = dot.connectionCount > 0;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault;
|
||||
for (const dot of this.dots) {
|
||||
const sprite = this.getDotSprite(dot.size, dot.connectionCount > 0);
|
||||
const halfDimension = sprite.width / 2;
|
||||
|
||||
this.ctx.globalAlpha = dot.opacity;
|
||||
this.ctx.shadowBlur = 6;
|
||||
this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow;
|
||||
this.ctx.fill();
|
||||
});
|
||||
this.ctx.drawImage(sprite, dot.x - halfDimension, dot.y - halfDimension);
|
||||
}
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
this.ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
createConnection(dot1, dot2) {
|
||||
createConnection(dot1, dot2, timestamp) {
|
||||
if (this.connections.length >= this.config.maxConnections) return;
|
||||
|
||||
dot1.connectionCount++;
|
||||
dot2.connectionCount++;
|
||||
|
||||
this.connections.push({
|
||||
dot1: dot1,
|
||||
dot2: dot2,
|
||||
startTime: Date.now(),
|
||||
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min
|
||||
dot1,
|
||||
dot2,
|
||||
startTime: timestamp,
|
||||
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min)
|
||||
+ this.config.connectionDuration.min
|
||||
});
|
||||
}
|
||||
|
||||
updateConnections() {
|
||||
this.connections = this.connections.filter(conn => {
|
||||
const elapsed = Date.now() - conn.startTime;
|
||||
if (elapsed > conn.duration) {
|
||||
conn.dot1.connectionCount--;
|
||||
conn.dot2.connectionCount--;
|
||||
return false;
|
||||
updateConnections(timestamp) {
|
||||
let writeIndex = 0;
|
||||
|
||||
for (let readIndex = 0; readIndex < this.connections.length; readIndex++) {
|
||||
const connection = this.connections[readIndex];
|
||||
|
||||
if (timestamp - connection.startTime > connection.duration) {
|
||||
connection.dot1.connectionCount--;
|
||||
connection.dot2.connectionCount--;
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
this.connections[writeIndex] = connection;
|
||||
writeIndex++;
|
||||
}
|
||||
|
||||
this.connections.length = writeIndex;
|
||||
}
|
||||
|
||||
drawConnections() {
|
||||
this.connections.forEach(conn => {
|
||||
const { dot1, dot2, startTime, duration } = conn;
|
||||
const elapsed = Date.now() - startTime;
|
||||
const opacity = Math.min(1, elapsed / 500);
|
||||
samplePoisson(lambda) {
|
||||
if (lambda <= 0) return 0;
|
||||
|
||||
const threshold = Math.exp(-lambda);
|
||||
let product = 1;
|
||||
let count = 0;
|
||||
|
||||
do {
|
||||
count++;
|
||||
product *= Math.random();
|
||||
} while (product > threshold);
|
||||
|
||||
return count - 1;
|
||||
}
|
||||
|
||||
tryCreateConnections(timestamp) {
|
||||
const dotCount = this.dots.length;
|
||||
if (dotCount < 2 || this.connections.length >= this.config.maxConnections) return;
|
||||
|
||||
const pairCount = dotCount * (dotCount - 1) / 2;
|
||||
const attempts = this.samplePoisson(pairCount * this.config.connectionChance);
|
||||
|
||||
for (let attempt = 0;
|
||||
attempt < attempts && this.connections.length < this.config.maxConnections;
|
||||
attempt++) {
|
||||
const firstIndex = Math.floor(Math.random() * dotCount);
|
||||
let secondIndex = Math.floor(Math.random() * (dotCount - 1));
|
||||
|
||||
if (secondIndex >= firstIndex) {
|
||||
secondIndex++;
|
||||
}
|
||||
|
||||
const dot1 = this.dots[firstIndex];
|
||||
const dot2 = this.dots[secondIndex];
|
||||
const dx = dot2.x - dot1.x;
|
||||
const dy = dot2.y - dot1.y;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
|
||||
if (distanceSquared >= 22500 || distanceSquared <= 900) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alreadyConnected = this.connections.some((connection) =>
|
||||
(connection.dot1 === dot1 && connection.dot2 === dot2)
|
||||
|| (connection.dot1 === dot2 && connection.dot2 === dot1)
|
||||
);
|
||||
|
||||
if (!alreadyConnected) {
|
||||
this.createConnection(dot1, dot2, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawConnections(timestamp) {
|
||||
for (const connection of this.connections) {
|
||||
const elapsed = timestamp - connection.startTime;
|
||||
const opacity = Math.min(1, elapsed / 500);
|
||||
const gradient = this.ctx.createLinearGradient(
|
||||
connection.dot1.x,
|
||||
connection.dot1.y,
|
||||
connection.dot2.x,
|
||||
connection.dot2.y
|
||||
);
|
||||
|
||||
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y);
|
||||
gradient.addColorStop(0, this.config.colors.connection.start);
|
||||
gradient.addColorStop(0.5, this.config.colors.connection.middle);
|
||||
gradient.addColorStop(1, this.config.colors.connection.end);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(dot1.x, dot1.y);
|
||||
this.ctx.lineTo(dot2.x, dot2.y);
|
||||
|
||||
this.ctx.moveTo(connection.dot1.x, connection.dot1.y);
|
||||
this.ctx.lineTo(connection.dot2.x, connection.dot2.y);
|
||||
this.ctx.strokeStyle = gradient;
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.globalAlpha = opacity;
|
||||
@@ -267,51 +392,34 @@ class MatrixBackground {
|
||||
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
|
||||
this.ctx.shadowBlur = 4 + pulse * 6;
|
||||
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
|
||||
|
||||
this.ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
this.ctx.globalAlpha = 1;
|
||||
this.ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
tryCreateConnections() {
|
||||
for (let i = 0; i < this.dots.length; i++) {
|
||||
for (let j = i + 1; j < this.dots.length; j++) {
|
||||
if (Math.random() < this.config.connectionChance) {
|
||||
const dot1 = this.dots[i];
|
||||
const dot2 = this.dots[j];
|
||||
|
||||
const dx = dot2.x - dot1.x;
|
||||
const dy = dot2.y - dot1.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 150 && distance > 30) {
|
||||
const alreadyConnected = this.connections.some(conn =>
|
||||
(conn.dot1 === dot1 && conn.dot2 === dot2) ||
|
||||
(conn.dot1 === dot2 && conn.dot2 === dot1)
|
||||
);
|
||||
if (!alreadyConnected) {
|
||||
this.createConnection(dot1, dot2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animate() {
|
||||
renderFrame(timestamp) {
|
||||
this.lastTimestamp = timestamp;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (!this.prefersReducedMotion) {
|
||||
this.updateDots();
|
||||
this.updateConnections();
|
||||
this.tryCreateConnections();
|
||||
this.updateConnections(timestamp);
|
||||
this.tryCreateConnections(timestamp);
|
||||
}
|
||||
|
||||
this.drawConnections();
|
||||
this.drawConnections(timestamp);
|
||||
this.drawDots();
|
||||
}
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.animate());
|
||||
animate(timestamp) {
|
||||
this.rafId = null;
|
||||
this.renderFrame(timestamp);
|
||||
|
||||
if (this.isRunning && !this.prefersReducedMotion && !document.hidden) {
|
||||
this.rafId = requestAnimationFrame(this.onAnimationFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
js/social.js
26
js/social.js
@@ -9,13 +9,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (avatar && avatarImg && !prefersReducedMotion) {
|
||||
['r', 'b'].forEach((channel) => {
|
||||
const clone = avatarImg.cloneNode(false);
|
||||
clone.classList.add('glitch-layer', channel);
|
||||
clone.removeAttribute('alt');
|
||||
clone.setAttribute('aria-hidden', 'true');
|
||||
avatar.appendChild(clone);
|
||||
});
|
||||
const createGlitchLayers = () => {
|
||||
if (avatar.querySelector('.glitch-layer')) return;
|
||||
|
||||
['r', 'b'].forEach((channel) => {
|
||||
const clone = avatarImg.cloneNode(false);
|
||||
clone.classList.add('glitch-layer', channel);
|
||||
clone.removeAttribute('alt');
|
||||
clone.setAttribute('aria-hidden', 'true');
|
||||
avatar.appendChild(clone);
|
||||
});
|
||||
};
|
||||
|
||||
const removeGlitchLayers = () => {
|
||||
avatar.querySelectorAll('.glitch-layer').forEach((layer) => layer.remove());
|
||||
};
|
||||
|
||||
avatar.addEventListener('pointerenter', createGlitchLayers);
|
||||
avatar.addEventListener('pointerleave', removeGlitchLayers);
|
||||
avatar.addEventListener('pointercancel', removeGlitchLayers);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
79
nginx.conf
79
nginx.conf
@@ -1,10 +1,10 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
worker_processes 1;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
worker_connections 256;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
@@ -12,22 +12,16 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log off;
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance optimizations
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
keepalive_timeout 15;
|
||||
keepalive_requests 100;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 20M;
|
||||
client_max_body_size 1m;
|
||||
|
||||
# Buffers & Timeouts tuning
|
||||
client_body_buffer_size 16k;
|
||||
client_header_buffer_size 1k;
|
||||
large_client_header_buffers 4 8k;
|
||||
@@ -35,17 +29,18 @@ http {
|
||||
client_header_timeout 12s;
|
||||
send_timeout 10s;
|
||||
|
||||
# Open File Cache
|
||||
open_file_cache max=1000 inactive=20s;
|
||||
open_file_cache_valid 30s;
|
||||
open_file_cache max=100 inactive=60s;
|
||||
open_file_cache_valid 60s;
|
||||
open_file_cache_min_uses 2;
|
||||
open_file_cache_errors on;
|
||||
open_file_cache_errors off;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_static on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_comp_level 4;
|
||||
gzip_min_length 256;
|
||||
gzip_disable "msie6";
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
@@ -62,10 +57,7 @@ http {
|
||||
font/woff2
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf;
|
||||
gzip_min_length 256;
|
||||
gzip_disable "msie6";
|
||||
|
||||
# Security headers (Global default)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
@@ -73,67 +65,33 @@ http {
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
|
||||
# HTTP (port 80) Server Block
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost ivanch.me www.ivanch.me;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Charset
|
||||
charset utf-8;
|
||||
|
||||
# Custom error pages
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
# Main location
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Cache static assets & redeclare security headers to bypass Nginx header override gotcha
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|otf|eot)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.(css|js)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~* \.(woff|woff2|ttf|otf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# HTML files - shorter cache
|
||||
location ~* \.html$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
@@ -142,19 +100,14 @@ http {
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Deny access to backup files
|
||||
location ~ ~$ {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1415
package-lock.json
generated
Normal file
1415
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "ivanch-me",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:production": "node utils/build-production.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
250
utils/build-production.mjs
Normal file
250
utils/build-production.mjs
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { transform } from 'esbuild';
|
||||
import { minify } from 'html-minifier-terser';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const rootDirectory = path.resolve(process.cwd());
|
||||
const outputDirectory = path.resolve(process.env.OUTPUT_DIR || path.join(rootDirectory, 'dist'));
|
||||
|
||||
const cssFiles = [
|
||||
'css/variables.css',
|
||||
'css/main.css',
|
||||
'css/button.css',
|
||||
'css/matrix.css',
|
||||
'css/sections/error.css',
|
||||
'css/sections/hero.css',
|
||||
'css/sections/homelab.css',
|
||||
'css/sections/profile.css',
|
||||
'css/sections/projects.css'
|
||||
];
|
||||
|
||||
const jsFiles = [
|
||||
'js/matrix.js',
|
||||
'js/social.js'
|
||||
];
|
||||
|
||||
const htmlFiles = [
|
||||
'index.html',
|
||||
'404.html'
|
||||
];
|
||||
|
||||
const versionedFiles = new Map();
|
||||
|
||||
function absoluteSourcePath(relativePath) {
|
||||
return path.join(rootDirectory, relativePath);
|
||||
}
|
||||
|
||||
function absoluteOutputPath(relativePath) {
|
||||
return path.join(outputDirectory, relativePath);
|
||||
}
|
||||
|
||||
function versionFor(buffer) {
|
||||
return createHash('sha256').update(buffer).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
async function requireSourceFile(relativePath) {
|
||||
try {
|
||||
await fs.access(absoluteSourcePath(relativePath));
|
||||
} catch {
|
||||
throw new Error('Required source file is missing: ' + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOutput(relativePath, content) {
|
||||
const targetPath = absoluteOutputPath(relativePath);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, content);
|
||||
}
|
||||
|
||||
async function minifyCss(relativePath) {
|
||||
let source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||
const transformed = await transform(source, {
|
||||
loader: 'css',
|
||||
legalComments: 'none',
|
||||
minify: true
|
||||
});
|
||||
|
||||
await writeOutput(relativePath, transformed.code);
|
||||
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
|
||||
}
|
||||
|
||||
async function minifyJavaScript(relativePath) {
|
||||
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||
const transformed = await transform(source, {
|
||||
loader: 'js',
|
||||
legalComments: 'none',
|
||||
minify: true,
|
||||
target: 'es2020'
|
||||
});
|
||||
|
||||
await writeOutput(relativePath, transformed.code);
|
||||
versionedFiles.set(relativePath, versionFor(Buffer.from(transformed.code)));
|
||||
}
|
||||
|
||||
async function copyStaticFile(relativePath) {
|
||||
const content = await fs.readFile(absoluteSourcePath(relativePath));
|
||||
await writeOutput(relativePath, content);
|
||||
versionedFiles.set(relativePath, versionFor(content));
|
||||
}
|
||||
|
||||
function isExternalReference(reference) {
|
||||
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|data:)/i.test(reference);
|
||||
}
|
||||
|
||||
function versionedReference(reference) {
|
||||
if (isExternalReference(reference)) {
|
||||
return reference;
|
||||
}
|
||||
|
||||
const isAbsolute = reference.startsWith('/');
|
||||
const relativePath = (isAbsolute ? reference.slice(1) : reference).split(/[?#]/)[0];
|
||||
const version = versionedFiles.get(relativePath);
|
||||
|
||||
if (!version) {
|
||||
return reference;
|
||||
}
|
||||
|
||||
return (isAbsolute ? '/' : '') + relativePath + '?v=' + version;
|
||||
}
|
||||
|
||||
function replaceProductionReferences(html, avatarVersion) {
|
||||
return html.replace(/\b(?:href|src)=(["'])([^"']+)\1/g, (match, quote, reference) => {
|
||||
if (reference === '/assets/lain.png') {
|
||||
return match.replace(reference, '/assets/lain.webp?v=' + avatarVersion);
|
||||
}
|
||||
|
||||
const replacement = versionedReference(reference);
|
||||
return replacement === reference ? match : match.replace(reference, replacement);
|
||||
});
|
||||
}
|
||||
|
||||
async function minifyHtml(relativePath, avatarVersion) {
|
||||
const source = await fs.readFile(absoluteSourcePath(relativePath), 'utf8');
|
||||
const withProductionAssets = replaceProductionReferences(source, avatarVersion);
|
||||
const compactHtml = await minify(withProductionAssets, {
|
||||
collapseWhitespace: true,
|
||||
minifyCSS: false,
|
||||
minifyJS: false,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
sortAttributes: false,
|
||||
sortClassName: false,
|
||||
useShortDoctype: true
|
||||
});
|
||||
|
||||
await writeOutput(relativePath, compactHtml);
|
||||
}
|
||||
|
||||
async function assertHtmlReferencesResolve(relativePath) {
|
||||
const html = await fs.readFile(absoluteOutputPath(relativePath), 'utf8');
|
||||
const references = html.matchAll(/\b(?:href|src)=(["'])([^"']+)\1/g);
|
||||
|
||||
for (const referenceMatch of references) {
|
||||
const reference = referenceMatch[2];
|
||||
if (isExternalReference(reference)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = (reference.startsWith('/') ? reference.slice(1) : reference).split(/[?#]/)[0];
|
||||
const targetPath = path.resolve(outputDirectory, relativePath);
|
||||
const relativeTarget = path.relative(outputDirectory, targetPath);
|
||||
|
||||
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
|
||||
throw new Error('Production reference escapes the output directory: ' + reference);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
throw new Error('Production reference does not resolve: ' + reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function gzipFile(relativePath) {
|
||||
const source = await fs.readFile(absoluteOutputPath(relativePath));
|
||||
await fs.writeFile(absoluteOutputPath(relativePath) + '.gz', gzipSync(source, {
|
||||
level: 9,
|
||||
mtime: 0
|
||||
}));
|
||||
}
|
||||
|
||||
async function byteSize(relativePath) {
|
||||
return (await fs.stat(absoluteOutputPath(relativePath))).size;
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const requiredFiles = [
|
||||
...cssFiles,
|
||||
...jsFiles,
|
||||
...htmlFiles,
|
||||
'assets/favicon.ico',
|
||||
'assets/lain.png'
|
||||
];
|
||||
|
||||
await Promise.all(requiredFiles.map(requireSourceFile));
|
||||
await fs.rm(outputDirectory, { force: true, recursive: true });
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
for (const cssFile of cssFiles) {
|
||||
await minifyCss(cssFile);
|
||||
}
|
||||
|
||||
for (const jsFile of jsFiles) {
|
||||
await minifyJavaScript(jsFile);
|
||||
}
|
||||
|
||||
await copyStaticFile('assets/favicon.ico');
|
||||
|
||||
const optimizedAvatar = 'assets/lain.webp';
|
||||
await fs.mkdir(path.dirname(absoluteOutputPath(optimizedAvatar)), { recursive: true });
|
||||
await sharp(absoluteSourcePath('assets/lain.png'))
|
||||
.resize(320, 320, { fit: 'cover', position: 'centre' })
|
||||
.webp({ effort: 4, quality: 82 })
|
||||
.toFile(absoluteOutputPath(optimizedAvatar));
|
||||
|
||||
const avatarVersion = versionFor(await fs.readFile(absoluteOutputPath(optimizedAvatar)));
|
||||
|
||||
for (const htmlFile of htmlFiles) {
|
||||
await minifyHtml(htmlFile, avatarVersion);
|
||||
}
|
||||
|
||||
await Promise.all(htmlFiles.map(assertHtmlReferencesResolve));
|
||||
|
||||
const compressedFiles = [
|
||||
...cssFiles,
|
||||
...jsFiles,
|
||||
...htmlFiles
|
||||
];
|
||||
await Promise.all(compressedFiles.map(gzipFile));
|
||||
|
||||
const avatarBytes = await byteSize(optimizedAvatar);
|
||||
if (avatarBytes > 100 * 1024) {
|
||||
throw new Error('Optimized avatar exceeds the 100 KiB budget: ' + avatarBytes + ' bytes');
|
||||
}
|
||||
|
||||
const firstPartyFiles = [
|
||||
'index.html.gz',
|
||||
...cssFiles.map((file) => file + '.gz'),
|
||||
...jsFiles.map((file) => file + '.gz'),
|
||||
optimizedAvatar,
|
||||
'assets/favicon.ico'
|
||||
];
|
||||
const firstPartyBytes = (await Promise.all(firstPartyFiles.map(byteSize)))
|
||||
.reduce((total, size) => total + size, 0);
|
||||
|
||||
if (firstPartyBytes > 200 * 1024) {
|
||||
throw new Error('First-party transfer exceeds the 200 KiB budget: ' + firstPartyBytes + ' bytes');
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
avatarBytes,
|
||||
firstPartyBytes,
|
||||
outputDirectory
|
||||
}));
|
||||
}
|
||||
|
||||
await build();
|
||||
Reference in New Issue
Block a user