Candlebright
-
+
diff --git a/js/matrix.js b/js/matrix.js
index 5d6159a..7c0de6f 100644
--- a/js/matrix.js
+++ b/js/matrix.js
@@ -4,6 +4,11 @@ class MatrixBackground {
this.ctx = this.canvas.getContext('2d');
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.config = {
dotCount: this.getMobileDotCount(),
dotSpeed: 0.2,
@@ -11,15 +16,18 @@ class MatrixBackground {
connectionChance: 0.0002,
maxConnections: this.getMobileMaxConnections(),
dotSize: 3,
+ mouseRadius: 110,
+ mouseForce: 0.6,
+ // Palette synced to the UI accent tokens
colors: {
- dotDefault: 'rgba(255, 255, 255, 0.8)',
- dotDefaultGlow: 'rgba(255, 255, 255, 0.4)',
- dotConnected: 'rgba(100, 149, 237, 0.8)',
- dotConnectedGlow: 'rgba(100, 149, 237, 0.6)',
+ dotDefault: 'rgba(230, 240, 255, 0.78)',
+ dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
+ dotConnected: 'rgba(120, 190, 245, 0.85)',
+ dotConnectedGlow: 'rgba(79, 195, 247, 0.55)',
connection: {
- start: 'rgba(100, 149, 237, 0.2)',
- middle: 'rgba(138, 43, 226, 0.6)',
- end: 'rgba(100, 149, 237, 0.2)'
+ start: 'rgba(102, 126, 234, 0.22)',
+ middle: 'rgba(118, 75, 162, 0.62)',
+ end: 'rgba(79, 195, 247, 0.22)'
}
}
};
@@ -28,14 +36,13 @@ class MatrixBackground {
}
getMobileDotCount() {
- // Reduce dot count on mobile devices for better performance
- if (window.innerWidth <= 480) return 25;
- if (window.innerWidth <= 768) return 35;
+ if (window.innerWidth <= 480) return 35;
+ if (window.innerWidth <= 768) return 45;
+ if (window.innerWidth >= 2100) return 100;
return 50;
}
getMobileMaxConnections() {
- // Reduce max connections on mobile devices for better performance
if (window.innerWidth <= 480) return 4;
if (window.innerWidth <= 768) return 6;
return 8;
@@ -43,20 +50,61 @@ class MatrixBackground {
init() {
this.handleResize();
+ this.bindEvents();
this.animate();
+ }
- // Prevent resizing for mobile performance reasons
- // window.addEventListener('resize', () => this.handleResize());
+ 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;
+ this.mouse.active = true;
+ }, { passive: true });
+
+ window.addEventListener('pointerout', () => {
+ this.mouse.active = false;
+ this.mouse.x = -9999;
+ 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 {
+ this.start();
+ }
+ });
+
+ // Resize is debounced via rAF; disabled on mobile for perf parity with original.
+ if (window.innerWidth > 768) {
+ let resizeRaf = null;
+ window.addEventListener('resize', () => {
+ if (resizeRaf) cancelAnimationFrame(resizeRaf);
+ resizeRaf = requestAnimationFrame(() => this.handleResize());
+ });
+ }
+ }
+
+ start() {
+ if (this.rafId || !this.isRunning) return;
+ this.animate();
+ }
+
+ stop() {
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
}
handleResize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
-
- // Update mobile-specific configurations
this.config.dotCount = this.getMobileDotCount();
this.config.maxConnections = this.getMobileMaxConnections();
-
this.createDots();
}
@@ -71,51 +119,54 @@ class MatrixBackground {
const startX = Math.random() * (this.canvas.width - 20) + 10;
const startY = Math.random() * (this.canvas.height - 20) + 10;
- let velocityX = 0, velocityY = 0;
- let attempts = 0;
- while ((Math.abs(velocityX) < 0.15 || Math.abs(velocityY) < 0.15) && attempts < 10) {
- const angle = Math.random() * Math.PI * 2;
- const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
- velocityX = Math.cos(angle) * speed;
- velocityY = Math.sin(angle) * speed;
- attempts++;
- }
- if (Math.abs(velocityX) < 0.15) velocityX = velocityX < 0 ? -0.15 : 0.15;
- if (Math.abs(velocityY) < 0.15) velocityY = velocityY < 0 ? -0.15 : 0.15;
+ const angle = Math.random() * Math.PI * 2;
+ const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
+ const baseVelocityX = Math.cos(angle) * speed;
+ const baseVelocityY = Math.sin(angle) * speed;
this.dots.push({
x: startX,
y: startY,
- vx: velocityX,
- vy: velocityY,
+ vx: baseVelocityX,
+ vy: baseVelocityY,
+ baseVx: baseVelocityX,
+ baseVy: baseVelocityY,
+ wanderPhase: Math.random() * Math.PI * 2,
+ wanderSpeed: 0.01 + Math.random() * 0.01,
opacity: Math.random() * 0.3 + 0.7,
size: this.config.dotSize + Math.random() * 2,
- connectionCount: 0,
- stuckFrames: 0
+ connectionCount: 0
});
}
updateDots() {
+ const radius = this.config.mouseRadius;
+ const radiusSq = radius * radius;
+ const force = this.config.mouseForce;
+
this.dots.forEach(dot => {
+ // Mouse repulsion
+ 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;
+ }
+ }
+
dot.x += dot.vx;
dot.y += dot.vy;
- if (Math.abs(dot.vx) < 0.03 && Math.abs(dot.vy) < 0.03) {
- dot.stuckFrames++;
- } else {
- dot.stuckFrames = 0;
- }
-
- if (dot.stuckFrames > 30) {
- const angle = Math.random() * Math.PI * 2;
- const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
- dot.vx = Math.cos(angle) * speed;
- dot.vy = Math.sin(angle) * speed;
- dot.stuckFrames = 0;
- } else {
- if (Math.abs(dot.vx) < 0.05) dot.vx += (Math.random() - 0.5) * 0.1;
- if (Math.abs(dot.vy) < 0.05) dot.vy += (Math.random() - 0.5) * 0.1;
- }
+ // 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;
const maxX = this.canvas.width - dot.size;
const maxY = this.canvas.height - dot.size;
@@ -175,7 +226,7 @@ class MatrixBackground {
this.connections.forEach(conn => {
const { dot1, dot2, startTime, duration } = conn;
const elapsed = Date.now() - startTime;
- const opacity = Math.min(1, elapsed / 500); // Fade in
+ const opacity = Math.min(1, elapsed / 500);
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y);
gradient.addColorStop(0, this.config.colors.connection.start);
@@ -185,15 +236,14 @@ class MatrixBackground {
this.ctx.beginPath();
this.ctx.moveTo(dot1.x, dot1.y);
this.ctx.lineTo(dot2.x, dot2.y);
-
+
this.ctx.strokeStyle = gradient;
this.ctx.lineWidth = 2;
this.ctx.globalAlpha = opacity;
- // Pulsing effect
- const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2; // 2-second pulse cycle
+ const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
this.ctx.shadowBlur = 4 + pulse * 6;
- this.ctx.shadowColor = 'rgba(138, 43, 226, 0.4)';
+ this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
this.ctx.stroke();
});
@@ -229,17 +279,19 @@ class MatrixBackground {
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
- this.updateDots();
- this.updateConnections();
- this.tryCreateConnections();
+ if (!this.prefersReducedMotion) {
+ this.updateDots();
+ this.updateConnections();
+ this.tryCreateConnections();
+ }
this.drawConnections();
this.drawDots();
- requestAnimationFrame(() => this.animate());
+ this.rafId = requestAnimationFrame(() => this.animate());
}
}
document.addEventListener('DOMContentLoaded', () => {
new MatrixBackground();
-});
\ No newline at end of file
+});
diff --git a/js/social.js b/js/social.js
index 4650c41..0a956cb 100644
--- a/js/social.js
+++ b/js/social.js
@@ -1,7 +1,26 @@
document.addEventListener('DOMContentLoaded', () => {
- new MatrixBackground();
+ // ----------------------------------------------------------------
+ // Avatar glitch layers — clone the avatar image twice (R + B
+ // channel shift clones) so the CSS hover animation has elements
+ // to slice/displace. Skipped when the user prefers reduced motion.
+ // ----------------------------------------------------------------
+ const avatar = document.querySelector('.avatar');
+ const avatarImg = avatar && avatar.querySelector('img');
+ 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);
+ });
+ }
+
+ // ----------------------------------------------------------------
// Email copy-to-clipboard and tooltip logic
+ // ----------------------------------------------------------------
const emailLink = document.getElementById('email-link');
const emailTooltip = document.getElementById('email-tooltip');
const obfuscatedEmail = 'Bg4aC0IJDAAeCBgbCU8AGA0PKQkBAAACQgIGAw=='; // generated through utils/xor-enc.py
@@ -20,39 +39,61 @@ document.addEventListener('DOMContentLoaded', () => {
const emailAddress = decodeEmail(obfuscatedEmail, xorKey);
if (emailLink && emailTooltip) {
- emailLink.addEventListener('click', function(e) {
- e.preventDefault();
- // Copy email to clipboard
- if (navigator.clipboard) {
- navigator.clipboard.writeText(emailAddress);
- } else {
- // Fallback for older browsers
- const textarea = document.createElement('textarea');
- textarea.value = emailAddress;
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand('copy');
- document.body.removeChild(textarea);
+ let tooltipTimeout;
+
+ async function copyEmail() {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ try {
+ await navigator.clipboard.writeText(emailAddress);
+ return true;
+ } catch (error) {
+ // Fall through to the legacy copy path when clipboard permissions fail.
+ }
}
-
+
+ const textarea = document.createElement('textarea');
+ textarea.value = emailAddress;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.select();
+
+ let copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch (error) {
+ copied = false;
+ }
+ document.body.removeChild(textarea);
+ return copied;
+ }
+
+ emailLink.addEventListener('click', async function(e) {
+ e.preventDefault();
+
+ const copied = await copyEmail();
+ if (!copied) return;
+
// Position tooltip above email link
const emailRect = emailLink.getBoundingClientRect();
const socialLinksRect = emailLink.parentElement.getBoundingClientRect();
-
+
const left = emailRect.left - socialLinksRect.left + (emailRect.width / 2);
const top = emailRect.top - socialLinksRect.top - 10;
-
+
emailTooltip.style.left = left + 'px';
emailTooltip.style.top = top + 'px';
emailTooltip.style.transform = 'translateX(-50%) translateY(-75%)';
-
- // Show tooltip
- emailTooltip.style.opacity = '1';
-
+
+ // Show tooltip only after the copy operation succeeds.
+ emailTooltip.classList.add('is-visible');
+
// Hide after 1.5s
- setTimeout(() => {
- emailTooltip.style.opacity = '0';
+ clearTimeout(tooltipTimeout);
+ tooltipTimeout = setTimeout(() => {
+ emailTooltip.classList.remove('is-visible');
}, 1500);
});
}
-});
\ No newline at end of file
+});
diff --git a/nginx.conf b/nginx.conf
index 8887993..4fe72e2 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -23,9 +23,24 @@ http {
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
+ keepalive_requests 100;
types_hash_max_size 2048;
client_max_body_size 20M;
+ # Buffers & Timeouts tuning
+ client_body_buffer_size 16k;
+ client_header_buffer_size 1k;
+ large_client_header_buffers 4 8k;
+ client_body_timeout 12s;
+ 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_min_uses 2;
+ open_file_cache_errors on;
+
# Gzip compression
gzip on;
gzip_vary on;
@@ -40,19 +55,29 @@ http {
application/javascript
application/x-javascript
application/xml+rss
- image/svg+xml;
+ image/svg+xml
+ image/x-icon
+ application/wasm
+ font/woff
+ font/woff2
+ application/vnd.ms-fontobject
+ application/x-font-ttf;
gzip_min_length 256;
gzip_disable "msie6";
- # Security headers
+ # 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;
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;
+ # HTTP (port 80) Server Block
server {
listen 80;
- server_name localhost;
+ listen [::]:80;
+ server_name localhost ivanch.me www.ivanch.me;
root /usr/share/nginx/html;
index index.html;
@@ -60,30 +85,48 @@ http {
charset utf-8;
# Custom error pages
- error_page 404 /index.html;
+ error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# Main location
location / {
- try_files $uri $uri/ /index.html;
+ try_files $uri $uri/ =404;
}
- # Cache static assets
+ # 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";
+ 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;
}
@@ -91,6 +134,12 @@ http {
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
+ 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;
}
# Deny access to hidden files
@@ -108,3 +157,4 @@ http {
}
}
}
+