skopec.dev/script.js
pepaskopec-blip a1db8ae3e4 Fix copy, thread count, and the light-theme hardware card.
FAQ data now matches the page, filters announce the active chip, and the mobile menu closes when you click outside it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 23:16:54 +02:00

359 lines
12 KiB
JavaScript

(() => {
const yearNodes = document.querySelectorAll("[data-year]");
const year = String(new Date().getFullYear());
yearNodes.forEach((node) => {
node.textContent = year;
});
const root = document.documentElement;
const themeToggle = document.querySelector(".theme-toggle");
const themeMeta = document.querySelector('meta[name="theme-color"]');
const themeColors = { dark: "#07090d", light: "#f6f7f9" };
const applyTheme = (theme) => {
root.dataset.theme = theme;
if (themeMeta) themeMeta.setAttribute("content", themeColors[theme]);
if (themeToggle) {
themeToggle.setAttribute(
"aria-label",
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
);
themeToggle.setAttribute("aria-pressed", theme === "light" ? "true" : "false");
}
};
applyTheme(root.dataset.theme === "light" ? "light" : "dark");
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const next = root.dataset.theme === "light" ? "dark" : "light";
applyTheme(next);
try {
localStorage.setItem("theme", next);
} catch (error) {
/* storage unavailable — theme resets to dark on reload */
}
});
}
const toggle = document.querySelector(".nav-toggle");
const nav = document.querySelector("#site-nav");
if (toggle && nav) {
const setOpen = (open) => {
nav.classList.toggle("is-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
};
toggle.addEventListener("click", () => {
setOpen(!nav.classList.contains("is-open"));
});
nav.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", () => setOpen(false));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") setOpen(false);
});
document.addEventListener("click", (event) => {
if (!nav.classList.contains("is-open")) return;
if (nav.contains(event.target) || toggle.contains(event.target)) return;
setOpen(false);
});
}
const radar = document.querySelector("[data-radar]");
if (radar) {
const hudName = document.querySelector("[data-radar-name]");
const hudRole = document.querySelector("[data-radar-role]");
const nodes = Array.from(radar.querySelectorAll(".radar-node"));
const defaultName = hudName ? hudName.textContent : "Homelab";
const defaultRole = hudRole ? hudRole.textContent : "";
const nodeData = nodes.map((el) => {
const x = parseFloat(el.style.getPropertyValue("--x")) || 50;
const y = parseFloat(el.style.getPropertyValue("--y")) || 50;
const dx = x - 50;
const dy = y - 50;
const dist = Math.hypot(dx, dy);
const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
return { el, dist, angle };
});
let currentBeam = 102;
let targetBeam = null;
let hoveredNode = null;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const shortestDelta = (from, to) => ((((to - from) % 360) + 540) % 360) - 180;
const updateSweep = (beamDeg) => {
const startDeg = (beamDeg - 252 + 360) % 360;
radar.style.setProperty("--radar-angle", `${startDeg.toFixed(1)}deg`);
nodeData.forEach(({ el, dist, angle }) => {
if (dist < 4) return;
const diff = Math.abs(shortestDelta(angle, beamDeg));
el.classList.toggle("is-swept", diff < 28);
});
};
const setHud = (name, role) => {
if (hudName) hudName.textContent = name;
if (hudRole) hudRole.textContent = role;
};
nodes.forEach((el, idx) => {
const activate = () => {
hoveredNode = nodeData[idx];
if (hoveredNode.dist >= 4) {
targetBeam = hoveredNode.angle;
}
setHud(el.dataset.name || defaultName, el.dataset.role || defaultRole);
};
const deactivate = () => {
hoveredNode = null;
setHud(defaultName, defaultRole);
};
el.addEventListener("mouseenter", activate);
el.addEventListener("focus", activate);
el.addEventListener("mouseleave", deactivate);
el.addEventListener("blur", deactivate);
});
radar.addEventListener("pointermove", (event) => {
if (hoveredNode && hoveredNode.dist >= 4) return;
const rect = radar.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
if (Math.hypot(dx, dy) > 8) {
targetBeam = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
}
});
radar.addEventListener("pointerleave", () => {
if (!hoveredNode) targetBeam = null;
});
if (!reduceMotion) {
let lastTime = performance.now();
const tick = (now) => {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
if (targetBeam !== null) {
const delta = shortestDelta(currentBeam, targetBeam);
currentBeam = (currentBeam + delta * Math.min(1, dt * 8) + 360) % 360;
} else {
currentBeam = (currentBeam + dt * 18) % 360;
}
updateSweep(currentBeam);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
} else {
updateSweep(currentBeam);
}
}
const filterChips = document.querySelectorAll("[data-filter]");
const projectCards = document.querySelectorAll(".project-card[data-category]");
if (filterChips.length && projectCards.length) {
filterChips.forEach((chip) => {
chip.setAttribute("aria-pressed", chip.classList.contains("is-active") ? "true" : "false");
chip.addEventListener("click", () => {
const filter = chip.dataset.filter;
filterChips.forEach((c) => {
const on = c === chip;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", on ? "true" : "false");
});
projectCards.forEach((card) => {
const match = filter === "all" || card.dataset.category === filter;
card.hidden = !match;
});
});
});
}
const hwChips = document.querySelectorAll("[data-hw-filter]");
const hwCards = document.querySelectorAll(".hw-card[data-hw-category]");
if (hwChips.length && hwCards.length) {
hwChips.forEach((chip) => {
chip.setAttribute("aria-pressed", chip.classList.contains("is-active") ? "true" : "false");
chip.addEventListener("click", () => {
const filter = chip.dataset.hwFilter;
hwChips.forEach((c) => {
const on = c === chip;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", on ? "true" : "false");
});
hwCards.forEach((card) => {
const match = filter === "all" || card.dataset.hwCategory === filter;
card.hidden = !match;
});
});
});
}
const snakeCanvas = document.querySelector("[data-snake-canvas]");
if (snakeCanvas instanceof HTMLCanvasElement) {
const ctx = snakeCanvas.getContext("2d");
const scoreNode = document.querySelector("[data-snake-score]");
const cols = 20;
const rows = 9;
const cell = 14;
let snake = [
{ x: 6, y: 4 },
{ x: 5, y: 4 },
{ x: 4, y: 4 },
{ x: 3, y: 4 },
{ x: 2, y: 4 },
];
let food = { x: 14, y: 4 };
let score = 140;
const placeFood = (tx, ty) => {
if (typeof tx === "number" && typeof ty === "number") {
food = {
x: Math.max(1, Math.min(cols - 2, tx)),
y: Math.max(1, Math.min(rows - 2, ty)),
};
return;
}
for (let i = 0; i < 30; i++) {
const fx = 1 + Math.floor(Math.random() * (cols - 2));
const fy = 1 + Math.floor(Math.random() * (rows - 2));
if (!snake.some((s) => s.x === fx && s.y === fy)) {
food = { x: fx, y: fy };
return;
}
}
};
const snakePalette = () => {
const light = document.documentElement.dataset.theme === "light";
return light
? {
grid: "rgba(17, 21, 28, 0.08)",
food: "#dc3c11",
head: "#11151c",
body: "179, 47, 8",
}
: {
grid: "rgba(238, 242, 246, 0.04)",
food: "#ff4a1c",
head: "#eef2f6",
body: "255, 122, 82",
};
};
const drawSnake = () => {
if (!ctx) return;
const palette = snakePalette();
ctx.clearRect(0, 0, snakeCanvas.width, snakeCanvas.height);
ctx.strokeStyle = palette.grid;
ctx.lineWidth = 1;
for (let x = 0; x <= cols; x++) {
ctx.beginPath();
ctx.moveTo(x * cell + 0.5, 0);
ctx.lineTo(x * cell + 0.5, rows * cell);
ctx.stroke();
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath();
ctx.moveTo(0, y * cell + 0.5);
ctx.lineTo(cols * cell, y * cell + 0.5);
ctx.stroke();
}
ctx.fillStyle = palette.food;
ctx.beginPath();
ctx.arc(food.x * cell + cell / 2, food.y * cell + cell / 2, 4.5, 0, Math.PI * 2);
ctx.fill();
snake.forEach((seg, idx) => {
const alpha = Math.max(0.28, 1 - idx * 0.09);
ctx.fillStyle = idx === 0 ? palette.head : `rgba(${palette.body}, ${alpha})`;
ctx.fillRect(seg.x * cell + 2, seg.y * cell + 2, cell - 4, cell - 4);
});
};
const stepSnake = () => {
const head = snake[0];
const dirs = [
{ x: 1, y: 0 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 },
];
const valid = dirs
.map((d) => ({
x: (head.x + d.x + cols) % cols,
y: (head.y + d.y + rows) % rows,
}))
.filter((p) => !snake.slice(0, -1).some((s) => s.x === p.x && s.y === p.y));
const next = valid.length
? valid.sort(
(a, b) =>
Math.hypot(a.x - food.x, a.y - food.y) -
Math.hypot(b.x - food.x, b.y - food.y)
)[0]
: { x: (head.x + 1) % cols, y: head.y };
snake.unshift(next);
if (next.x === food.x && next.y === food.y) {
score += 10;
if (scoreNode) scoreNode.textContent = `SCORE ${String(score).padStart(4, "0")}`;
if (snake.length > 9) snake.pop();
placeFood();
} else {
snake.pop();
}
drawSnake();
};
snakeCanvas.addEventListener("pointerdown", (e) => {
const rect = snakeCanvas.getBoundingClientRect();
const gx = Math.floor(((e.clientX - rect.left) / rect.width) * cols);
const gy = Math.floor(((e.clientY - rect.top) / rect.height) * rows);
placeFood(gx, gy);
drawSnake();
});
drawSnake();
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setInterval(stepSnake, 180);
}
new MutationObserver(drawSnake).observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
}
const revealItems = document.querySelectorAll(".reveal");
if (!revealItems.length) return;
if (!("IntersectionObserver" in window)) {
revealItems.forEach((el) => el.classList.add("is-visible"));
return;
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
obs.unobserve(entry.target);
});
},
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
revealItems.forEach((el) => observer.observe(el));
})();