322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const closeBtn = document.getElementById('close-sidebar');
|
|
const trigger = document.getElementById('read-more-trigger');
|
|
const body = document.body;
|
|
let manualClose = false;
|
|
|
|
// --- Shy Logo Logic ---
|
|
const logo = document.querySelector('.header-logo img');
|
|
const header = document.querySelector('.main-header');
|
|
|
|
if (logo && header) {
|
|
let offsetX = 0, offsetY = 0;
|
|
let velX = 0, velY = 0;
|
|
let mouseX = 0, mouseY = 0;
|
|
let chaseStarted = false;
|
|
let approaching = false;
|
|
let stillTimer = null;
|
|
let animating = false;
|
|
|
|
const FLEE_RADIUS = 130;
|
|
const FLEE_FORCE = 18;
|
|
const FRICTION = 0.82;
|
|
const APPROACH_SPEED = 0.035;
|
|
const STILL_DELAY = 1000;
|
|
|
|
function logoCenter() {
|
|
const r = logo.getBoundingClientRect();
|
|
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
}
|
|
|
|
function headerBounds() {
|
|
const hr = header.getBoundingClientRect();
|
|
const lr = logo.getBoundingClientRect();
|
|
return {
|
|
minX: -(lr.left - hr.left) + 8,
|
|
maxX: (hr.right - lr.right) - 8,
|
|
minY: -(lr.top - hr.top) + 8,
|
|
maxY: (hr.bottom - lr.bottom) - 8
|
|
};
|
|
}
|
|
|
|
function clamp(x, y) {
|
|
const b = headerBounds();
|
|
return {
|
|
x: Math.max(b.minX, Math.min(b.maxX, x)),
|
|
y: Math.max(b.minY, Math.min(b.maxY, y))
|
|
};
|
|
}
|
|
|
|
function applyTransform() {
|
|
logo.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
|
|
}
|
|
|
|
function tick() {
|
|
if (approaching) {
|
|
// Gently approach where the mouse is
|
|
const c = logoCenter();
|
|
const targetX = mouseX - (c.x - offsetX);
|
|
const targetY = mouseY - (c.y - offsetY);
|
|
velX += (targetX - offsetX) * APPROACH_SPEED;
|
|
velY += (targetY - offsetY) * APPROACH_SPEED;
|
|
velX *= 0.85;
|
|
velY *= 0.85;
|
|
}
|
|
|
|
velX *= FRICTION;
|
|
velY *= FRICTION;
|
|
offsetX += velX;
|
|
offsetY += velY;
|
|
|
|
const c = clamp(offsetX, offsetY);
|
|
// Bounce off header edges
|
|
if (c.x !== offsetX) velX *= -0.3;
|
|
if (c.y !== offsetY) velY *= -0.3;
|
|
offsetX = c.x;
|
|
offsetY = c.y;
|
|
|
|
applyTransform();
|
|
|
|
const moving = Math.abs(velX) > 0.1 || Math.abs(velY) > 0.1;
|
|
const farFromHome = Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5;
|
|
if (moving || approaching || farFromHome) {
|
|
requestAnimationFrame(tick);
|
|
} else {
|
|
animating = false;
|
|
}
|
|
}
|
|
|
|
function startAnim() {
|
|
if (!animating) {
|
|
animating = true;
|
|
requestAnimationFrame(tick);
|
|
}
|
|
}
|
|
|
|
header.addEventListener('mousemove', (e) => {
|
|
mouseX = e.clientX;
|
|
mouseY = e.clientY;
|
|
|
|
const c = logoCenter();
|
|
const dx = c.x - mouseX;
|
|
const dy = c.y - mouseY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist < FLEE_RADIUS) {
|
|
chaseStarted = true;
|
|
approaching = false;
|
|
|
|
// Flee away from cursor
|
|
const angle = Math.atan2(dy, dx);
|
|
const force = ((FLEE_RADIUS - dist) / FLEE_RADIUS) * FLEE_FORCE;
|
|
velX += Math.cos(angle) * force;
|
|
velY += Math.sin(angle) * force;
|
|
startAnim();
|
|
}
|
|
|
|
// Reset the "stopped chasing" timer
|
|
clearTimeout(stillTimer);
|
|
stillTimer = setTimeout(() => {
|
|
if (chaseStarted) {
|
|
approaching = true;
|
|
startAnim();
|
|
}
|
|
}, STILL_DELAY);
|
|
});
|
|
|
|
header.addEventListener('mouseleave', () => {
|
|
clearTimeout(stillTimer);
|
|
approaching = false;
|
|
chaseStarted = false;
|
|
// Drift home
|
|
velX += -offsetX * 0.08;
|
|
velY += -offsetY * 0.08;
|
|
startAnim();
|
|
});
|
|
}
|
|
|
|
// --- Sidebar Logic ---
|
|
|
|
function checkSidebarVisibility() {
|
|
if (manualClose) return;
|
|
|
|
const scrollTop = window.scrollY;
|
|
const windowHeight = window.innerHeight;
|
|
const docHeight = document.documentElement.scrollHeight;
|
|
|
|
// "Sidebar fix": Check if page is short (content fits in window or close to it)
|
|
// If content is short, sidebar should be visible immediately (if desktop)
|
|
if (docHeight <= windowHeight * 1.2 && window.innerWidth > 900) {
|
|
sidebar.classList.add('visible');
|
|
sidebar.style.opacity = 1;
|
|
sidebar.style.pointerEvents = 'auto';
|
|
return;
|
|
}
|
|
|
|
// Scroll Logic for longer pages
|
|
if (scrollTop + windowHeight > docHeight * 0.9) {
|
|
sidebar.classList.add('visible');
|
|
trigger.classList.add('visible');
|
|
|
|
// Dynamic Opacity near bottom
|
|
const remaining = docHeight - (scrollTop + windowHeight);
|
|
const threshold = docHeight * 0.1;
|
|
|
|
if (remaining < threshold) {
|
|
const opacity = 1 - (remaining / threshold);
|
|
sidebar.style.opacity = Math.min(Math.max(opacity, 0), 1);
|
|
|
|
if (opacity > 0) {
|
|
sidebar.style.pointerEvents = 'auto';
|
|
} else {
|
|
sidebar.style.pointerEvents = 'none';
|
|
}
|
|
} else {
|
|
// Should be redundant with visibility check but safe
|
|
}
|
|
} else {
|
|
// Hide if scrolled up?
|
|
// "It just is gone then... manual close... refresh if want it back"
|
|
// But scroll behavior implies transient visibility.
|
|
// We'll hide it if we scroll back up, unless it's short content.
|
|
sidebar.style.opacity = 0;
|
|
sidebar.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
|
|
window.addEventListener('scroll', checkSidebarVisibility);
|
|
window.addEventListener('resize', checkSidebarVisibility);
|
|
|
|
// Initial check
|
|
setTimeout(checkSidebarVisibility, 100); // Slight delay for rendering
|
|
|
|
// Mobile Trigger
|
|
trigger.addEventListener('click', () => {
|
|
sidebar.classList.add('force-visible');
|
|
sidebar.style.opacity = 1;
|
|
sidebar.style.pointerEvents = 'auto';
|
|
});
|
|
|
|
// Close Button
|
|
closeBtn.addEventListener('click', () => {
|
|
sidebar.classList.remove('visible');
|
|
sidebar.classList.remove('force-visible');
|
|
sidebar.style.opacity = 0;
|
|
sidebar.style.pointerEvents = 'none';
|
|
manualClose = true;
|
|
trigger.style.display = 'none';
|
|
});
|
|
|
|
// --- Font Tribute: Carter & Connare ---
|
|
const TRIBUTE_FONTS = ['Georgia', 'Tahoma', 'Comic Sans MS', 'Trebuchet MS'];
|
|
|
|
function tributeTextNodes() {
|
|
const content = document.querySelector('.main-content');
|
|
if (!content) return [];
|
|
const nodes = [];
|
|
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, {
|
|
acceptNode: (n) => {
|
|
const el = n.parentElement;
|
|
if (n.textContent.trim().length < 2) return NodeFilter.FILTER_REJECT;
|
|
if (['SCRIPT','STYLE','CODE','PRE'].includes(el.tagName)) return NodeFilter.FILTER_REJECT;
|
|
if (el.classList.contains('font-tribute')) return NodeFilter.FILTER_REJECT;
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
});
|
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
return nodes;
|
|
}
|
|
|
|
function sprinkleFont() {
|
|
const nodes = tributeTextNodes();
|
|
if (!nodes.length) return;
|
|
|
|
const node = nodes[Math.floor(Math.random() * nodes.length)];
|
|
const text = node.textContent;
|
|
|
|
// Find a non-whitespace character
|
|
let pos, tries = 0;
|
|
do {
|
|
pos = Math.floor(Math.random() * text.length);
|
|
} while (text[pos].trim() === '' && ++tries < 20);
|
|
if (text[pos].trim() === '') return;
|
|
|
|
const font = TRIBUTE_FONTS[Math.floor(Math.random() * TRIBUTE_FONTS.length)];
|
|
const span = document.createElement('span');
|
|
span.textContent = text[pos];
|
|
span.style.fontFamily = '"' + font + '"';
|
|
span.className = 'font-tribute';
|
|
|
|
const before = document.createTextNode(text.slice(0, pos));
|
|
const after = document.createTextNode(text.slice(pos + 1));
|
|
node.parentNode.insertBefore(before, node);
|
|
node.parentNode.insertBefore(span, node);
|
|
node.parentNode.insertBefore(after, node);
|
|
node.parentNode.removeChild(node);
|
|
}
|
|
|
|
// Initial sprinkle: 3 letters
|
|
for (let i = 0; i < 3; i++) sprinkleFont();
|
|
|
|
// Occasionally, mid-read, one more letter quietly changes
|
|
(function scheduleNext() {
|
|
setTimeout(() => {
|
|
sprinkleFont();
|
|
scheduleNext();
|
|
}, 12000 + Math.random() * 30000); // every 12-42 seconds
|
|
})();
|
|
|
|
// --- Dark Mode Logic ---
|
|
|
|
// Create Toggle Button
|
|
const sidebarContent = document.querySelector('.sidebar-content');
|
|
const themeBtn = document.createElement('button');
|
|
themeBtn.id = 'theme-toggle';
|
|
themeBtn.textContent = 'Toggle Theme';
|
|
sidebarContent.appendChild(themeBtn);
|
|
|
|
function setDarkMode(enable) {
|
|
if (enable) {
|
|
body.classList.add('dark-mode');
|
|
themeBtn.textContent = 'Switch to Light Mode';
|
|
} else {
|
|
body.classList.remove('dark-mode');
|
|
themeBtn.textContent = 'Switch to Dark Mode';
|
|
}
|
|
}
|
|
|
|
function getCookie(name) {
|
|
const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
|
|
return v ? v[2] : null;
|
|
}
|
|
|
|
function setCookie(name, value, hours) {
|
|
const d = new Date();
|
|
d.setTime(d.getTime() + (hours * 60 * 60 * 1000));
|
|
document.cookie = name + "=" + value + ";path=/;expires=" + d.toUTCString();
|
|
}
|
|
|
|
// Init Dark Mode
|
|
const cookieTheme = getCookie('theme');
|
|
if (cookieTheme === 'dark') {
|
|
setDarkMode(true);
|
|
} else if (cookieTheme === 'light') {
|
|
setDarkMode(false);
|
|
} else {
|
|
// Time based auto-detect
|
|
const hour = new Date().getHours();
|
|
if (hour < 6 || hour >= 18) {
|
|
setDarkMode(true);
|
|
} else {
|
|
setDarkMode(false);
|
|
}
|
|
}
|
|
|
|
themeBtn.addEventListener('click', () => {
|
|
const isDark = body.classList.contains('dark-mode');
|
|
setDarkMode(!isDark);
|
|
setCookie('theme', !isDark ? 'dark' : 'light', 24);
|
|
});
|
|
});
|