Compare commits
6
Commits
870afc8b88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c232eee1e8 | ||
|
|
d5d4ab251b | ||
|
|
11559d64c2 | ||
|
|
409e0b881f | ||
|
|
476651b789 | ||
|
|
be486a9580 |
No files matched your search
@@ -20,8 +20,8 @@
|
||||
<link rel="stylesheet" href="css/button.css">
|
||||
<link rel="stylesheet" href="css/sections/error.css">
|
||||
|
||||
<!-- Remix Icon CDN for the navigation icon -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<!-- Remix Icon subset (only the icon used on this page) -->
|
||||
<link rel="stylesheet" href="css/icons.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
| | \ V / (_| | | | | (__| | | |
|
||||
|_| \_/ \__,_|_| |_|\___|_| |_|
|
||||
|
||||
software, servers, and a little sorcery.
|
||||
software, servers, and a little sorcery
|
||||
```
|
||||
|
||||
**[Enter the site](https://ivanch.me)** · [GitHub](https://github.com/ivanch) · [Gitea](https://git.ivanch.me/ivanch) · [Blog](https://blog.ivanch.me)
|
||||
<p align="center">
|
||||
<a href="https://ivanch.me"><img alt="Website" src="https://img.shields.io/badge/Website-ivanch.me-4FC3F7?style=for-the-badge&logo=googlechrome&logoColor=white"></a>
|
||||
<a href="https://github.com/ivanch"><img alt="GitHub" src="https://img.shields.io/badge/GitHub-ivanch-181717?style=for-the-badge&logo=github&logoColor=white"></a>
|
||||
<a href="https://git.ivanch.me/ivanch"><img alt="Gitea" src="https://img.shields.io/badge/-Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white"></a>
|
||||
<a href="https://blog.ivanch.me"><img alt="Blog" src="https://img.shields.io/badge/-Blog-667EEA?style=for-the-badge"></a>
|
||||
<a href="https://www.linkedin.com/in/joseivanch/"><img alt="LinkedIn" src="https://img.shields.io/badge/-LinkedIn-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## `$ whoami`
|
||||
|
||||
@@ -20,7 +28,7 @@ My corner of the internet is part portfolio, part doorway into the homelab.
|
||||
|
||||
```text
|
||||
interests
|
||||
|-- building robust software
|
||||
|-- building robust and maintainable software
|
||||
|-- making CI/CD do the repetitive work
|
||||
|-- orchestrating containers with Kubernetes
|
||||
`-- self-hosting, tinkering, and open source
|
||||
@@ -30,10 +38,10 @@ interests
|
||||
|
||||
| Shelf | Tools |
|
||||
| :--- | :--- |
|
||||
| Code | .NET Core, React, Python, HTML, CSS |
|
||||
| Data | PostgreSQL, MySQL, Redis |
|
||||
| Infrastructure | Linux, Bash, Docker, Kubernetes, Terraform |
|
||||
| Delivery | GitHub Actions, Gitea Actions |
|
||||
| **Code** |      |
|
||||
| **Data** |    |
|
||||
| **Infrastructure** |      |
|
||||
| **Delivery** |   |
|
||||
|
||||
## `$ ls projects/`
|
||||
|
||||
@@ -41,51 +49,8 @@ A few things I've sent out into the world:
|
||||
|
||||
| Project | What it does |
|
||||
| :--- | :--- |
|
||||
| [OpenCand](https://github.com/ivanch/opencand) | Search and explore Brazilian candidate data with .NET Core and React. [Visit the app](https://opencand.ivanch.me). |
|
||||
| [Vision Start](https://github.com/ivanch/vision-start) | A customizable Chrome startpage with a glassmorphism look. |
|
||||
| [Kasbot](https://github.com/ivanch/kasbot) | A Discord bot inspired by a Brazilian DJ. |
|
||||
| [Candlebright](https://github.com/ivanch/candlebright) | A game project written in C++. |
|
||||
|
||||
## `$ ssh homelab`
|
||||
|
||||
Meet **[Haven](https://github.com/ivanch/haven)**: my personal homelab,
|
||||
where bare-metal Kubernetes meets Docker containers and the occasional experiment.
|
||||
|
||||
```text
|
||||
.-------------------.
|
||||
| Haven |
|
||||
| bare metal / k3s |
|
||||
'---------+---------'
|
||||
|
|
||||
.---------+---------.
|
||||
| |
|
||||
deployments server scripts
|
||||
```
|
||||
|
||||
[Explore the deployments](https://github.com/ivanch/haven) · [Browse the scripts](https://git.ivanch.me/ivanch/server-scripts)
|
||||
|
||||
## `$ cat site.txt`
|
||||
|
||||
This repository is the source for **[ivanch.me](https://ivanch.me)**:
|
||||
a static homepage with glass panels, a canvas flow field, and links to the things I build.
|
||||
HTML, CSS, and vanilla JavaScript keep it small; Nginx serves it in production.
|
||||
|
||||
```text
|
||||
.
|
||||
|-- index.html # the front door
|
||||
|-- 404.html # for the scenic detours
|
||||
|-- assets/ # avatar and favicon
|
||||
|-- css/ # layout, glass, and color tokens
|
||||
|-- js/ # canvas backgrounds and social interactions
|
||||
|-- Dockerfile # minify JavaScript, then serve with Nginx
|
||||
|-- nginx.conf # caching, compression, and routing
|
||||
`-- .gitea/workflows/ # build and deploy
|
||||
```
|
||||
|
||||
## `$ cat contact.txt`
|
||||
|
||||
[LinkedIn](https://www.linkedin.com/in/joseivanch/) · [GitHub](https://github.com/ivanch) · [Gitea](https://git.ivanch.me/ivanch) · [Blog](https://blog.ivanch.me)
|
||||
|
||||
```text
|
||||
ivanch@home:~$ _
|
||||
```
|
||||
| **[OpenCand](https://github.com/ivanch/opencand)** | Search and explore Brazilian candidate data with .NET Core and React |
|
||||
| **[Vision Start](https://github.com/ivanch/vision-start)** | A customizable Chrome startpage with a glassmorphism look |
|
||||
| **[Kasbot](https://github.com/ivanch/kasbot)** | A Discord bot inspired by a Brazilian DJ |
|
||||
| **[Candlebright](https://github.com/ivanch/candlebright)** | A game project written in C++ |
|
||||
| **[Haven](https://github.com/ivanch/haven)** | My personal homelab (now with GitOps!) |
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Remix Icon v3.5.0 (subset)
|
||||
* https://remixicon.com
|
||||
* Released under the Apache License Version 2.0
|
||||
*
|
||||
* Only the icons used by the site are included. The font file is a subset of
|
||||
* remixicon.woff2 that contains just these glyphs. To add an icon, copy its
|
||||
* rule from the full remixicon.css and re-subset the font with its codepoint.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: "remixicon";
|
||||
src: url("../assets/fonts/remixicon-subset.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
[class^="ri-"], [class*=" ri-"] {
|
||||
font-family: 'remixicon' !important;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.ri-article-line:before { content: "\ea7e"; }
|
||||
.ri-briefcase-line:before { content: "\eaf5"; }
|
||||
.ri-building-2-fill:before { content: "\eb08"; }
|
||||
.ri-git-repository-fill:before { content: "\edc6"; }
|
||||
.ri-github-fill:before { content: "\edca"; }
|
||||
.ri-global-line:before { content: "\edcf"; }
|
||||
.ri-graduation-cap-fill:before { content: "\f332"; }
|
||||
.ri-home-5-line:before { content: "\ee1f"; }
|
||||
.ri-linkedin-fill:before { content: "\eeb5"; }
|
||||
.ri-mail-fill:before { content: "\eef3"; }
|
||||
.ri-terminal-box-line:before { content: "\f1f6"; }
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
|
||||
<link rel="stylesheet" href="css/button.css">
|
||||
|
||||
<!-- Remix Icon CDN for social icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<!-- Remix Icon subset (only the icons used on this page) -->
|
||||
<link rel="stylesheet" href="css/icons.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas class="matrix-canvas" id="matrixCanvas"></canvas>
|
||||
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
class MatrixBackground {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('matrixCanvas');
|
||||
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,
|
||||
connectionDuration: { min: 2000, max: 6000 },
|
||||
connectionChance: 0.0002,
|
||||
maxConnections: this.getMobileMaxConnections(),
|
||||
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)',
|
||||
dotConnected: 'rgba(120, 190, 245, 0.85)',
|
||||
dotConnectedGlow: 'rgba(79, 195, 247, 0.55)',
|
||||
connection: {
|
||||
start: 'rgba(102, 126, 234, 0.22)',
|
||||
middle: 'rgba(118, 75, 162, 0.62)',
|
||||
end: 'rgba(79, 195, 247, 0.22)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
getMobileDotCount() {
|
||||
if (window.innerWidth <= 480) return 35;
|
||||
if (window.innerWidth <= 768) return 45;
|
||||
if (window.innerWidth >= 2100) return 100;
|
||||
return 50;
|
||||
}
|
||||
|
||||
getMobileMaxConnections() {
|
||||
if (window.innerWidth <= 480) return 4;
|
||||
if (window.innerWidth <= 768) return 6;
|
||||
return 8;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.handleResize();
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
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;
|
||||
this.config.dotCount = this.getMobileDotCount();
|
||||
this.config.maxConnections = this.getMobileMaxConnections();
|
||||
this.createDots();
|
||||
}
|
||||
|
||||
createDots() {
|
||||
this.dots = [];
|
||||
for (let i = 0; i < this.config.dotCount; i++) {
|
||||
this.createDot();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const baseVelocityY = Math.sin(angle) * speed;
|
||||
|
||||
this.dots.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
const maxX = Math.max(minX, this.canvas.width - dotRadius);
|
||||
const maxY = Math.max(minY, this.canvas.height - dotRadius);
|
||||
|
||||
if (dot.x <= minX && dot.vx < 0) {
|
||||
dot.x = minX + (minX - dot.x);
|
||||
dot.vx = Math.abs(dot.vx);
|
||||
dot.baseVx = Math.abs(dot.baseVx);
|
||||
} else if (dot.x >= maxX && dot.vx > 0) {
|
||||
dot.x = maxX - (dot.x - maxX);
|
||||
dot.vx = -Math.abs(dot.vx);
|
||||
dot.baseVx = -Math.abs(dot.baseVx);
|
||||
}
|
||||
|
||||
if (dot.y <= minY && dot.vy < 0) {
|
||||
dot.y = minY + (minY - dot.y);
|
||||
dot.vy = Math.abs(dot.vy);
|
||||
dot.baseVy = Math.abs(dot.baseVy);
|
||||
} else if (dot.y >= maxY && dot.vy > 0) {
|
||||
dot.y = maxY - (dot.y - maxY);
|
||||
dot.vy = -Math.abs(dot.vy);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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.globalAlpha = 1;
|
||||
this.ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
createConnection(dot1, dot2) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
drawConnections() {
|
||||
this.connections.forEach(conn => {
|
||||
const { dot1, dot2, startTime, duration } = conn;
|
||||
const elapsed = Date.now() - startTime;
|
||||
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);
|
||||
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.strokeStyle = gradient;
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.globalAlpha = opacity;
|
||||
|
||||
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() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (!this.prefersReducedMotion) {
|
||||
this.updateDots();
|
||||
this.updateConnections();
|
||||
this.tryCreateConnections();
|
||||
}
|
||||
|
||||
this.drawConnections();
|
||||
this.drawDots();
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new MatrixBackground();
|
||||
});
|
||||
Reference in new issue
Block a user