mirror of
https://github.com/perstarkse/minne.git
synced 2026-06-25 11:26:17 +02:00
223 lines
5.4 KiB
JavaScript
223 lines
5.4 KiB
JavaScript
/**
|
|
* Design Polishing Pass - Interactive Effects
|
|
*
|
|
* Includes:
|
|
* - Scroll-Linked Navbar Shadow
|
|
* - HTMX Swap Animation
|
|
* - Typewriter AI Response
|
|
* - Rubberbanding Scroll
|
|
*/
|
|
|
|
(() => {
|
|
// === SCROLL-LINKED NAVBAR SHADOW ===
|
|
function initScrollShadow() {
|
|
const mainContent = document.querySelector("main");
|
|
const navbar = document.querySelector("nav");
|
|
if (!mainContent || !navbar) return;
|
|
|
|
mainContent.addEventListener(
|
|
"scroll",
|
|
() => {
|
|
const scrollTop = mainContent.scrollTop;
|
|
const scrollHeight =
|
|
mainContent.scrollHeight - mainContent.clientHeight;
|
|
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
|
navbar.style.setProperty("--scroll-depth", scrollDepth.toFixed(2));
|
|
},
|
|
{ passive: true },
|
|
);
|
|
}
|
|
|
|
// === HTMX SWAP ANIMATION ===
|
|
function initHtmxSwapAnimation() {
|
|
document.body.addEventListener("htmx:afterSwap", (event) => {
|
|
let target = event.detail.target;
|
|
if (!target) return;
|
|
|
|
// If full body swap (hx-boost), animate only the main content
|
|
if (target.tagName === "BODY") {
|
|
const main = document.querySelector("main");
|
|
if (main) target = main;
|
|
}
|
|
|
|
// Only animate if target is valid and inside/is main content or a card/panel
|
|
// Avoid animating sidebar or navbar updates
|
|
if (target && (target.tagName === "MAIN" || target.closest("main"))) {
|
|
if (!target.classList.contains("animate-fade-up")) {
|
|
target.classList.add("animate-fade-up");
|
|
// Remove class after animation completes to allow re-animation
|
|
setTimeout(() => {
|
|
target.classList.remove("animate-fade-up");
|
|
}, 250);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// === TYPEWRITER AI RESPONSE ===
|
|
// Works with SSE streaming - buffers text and reveals character by character
|
|
window.initTypewriter = (element, options = {}) => {
|
|
const { minDelay = 5, maxDelay = 15, showCursor = true } = options;
|
|
|
|
let buffer = "";
|
|
let isTyping = false;
|
|
let cursorElement = null;
|
|
|
|
if (showCursor) {
|
|
cursorElement = document.createElement("span");
|
|
cursorElement.className = "typewriter-cursor";
|
|
cursorElement.textContent = "▌";
|
|
cursorElement.style.animation = "blink 1s step-end infinite";
|
|
element.appendChild(cursorElement);
|
|
}
|
|
|
|
function typeNextChar() {
|
|
if (buffer.length === 0) {
|
|
isTyping = false;
|
|
return;
|
|
}
|
|
|
|
isTyping = true;
|
|
const char = buffer.charAt(0);
|
|
buffer = buffer.slice(1);
|
|
|
|
// Insert before cursor
|
|
if (cursorElement && cursorElement.parentNode) {
|
|
const textNode = document.createTextNode(char);
|
|
element.insertBefore(textNode, cursorElement);
|
|
} else {
|
|
element.textContent += char;
|
|
}
|
|
|
|
const delay = minDelay + Math.random() * (maxDelay - minDelay);
|
|
setTimeout(typeNextChar, delay);
|
|
}
|
|
|
|
return {
|
|
append: (text) => {
|
|
buffer += text;
|
|
if (!isTyping) {
|
|
typeNextChar();
|
|
}
|
|
},
|
|
complete: () => {
|
|
// Flush remaining buffer immediately
|
|
if (cursorElement && cursorElement.parentNode) {
|
|
const textNode = document.createTextNode(buffer);
|
|
element.insertBefore(textNode, cursorElement);
|
|
cursorElement.remove();
|
|
} else {
|
|
element.textContent += buffer;
|
|
}
|
|
buffer = "";
|
|
isTyping = false;
|
|
},
|
|
};
|
|
};
|
|
|
|
// === RUBBERBANDING SCROLL ===
|
|
function attachRubberbanding(
|
|
container,
|
|
{ maxPull = 60, resistance = 0.4 } = {},
|
|
) {
|
|
let startY = 0;
|
|
let pulling = false;
|
|
|
|
function applyPull(distance) {
|
|
container.style.transform = `translateY(${distance}px)`;
|
|
}
|
|
|
|
function release() {
|
|
container.style.transition =
|
|
"transform 300ms cubic-bezier(0.25, 1, 0.5, 1)";
|
|
container.style.transform = "translateY(0)";
|
|
setTimeout(() => {
|
|
container.style.transition = "";
|
|
}, 300);
|
|
pulling = false;
|
|
}
|
|
|
|
function isAtTop() {
|
|
return container.scrollTop <= 0;
|
|
}
|
|
function isAtBottom() {
|
|
return (
|
|
container.scrollTop + container.clientHeight >= container.scrollHeight
|
|
);
|
|
}
|
|
|
|
container.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
startY = e.touches[0].clientY;
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
container.addEventListener(
|
|
"touchmove",
|
|
(e) => {
|
|
const diff = e.touches[0].clientY - startY;
|
|
const isPullingDown = diff > 0 && isAtTop();
|
|
const isPullingUp = diff < 0 && isAtBottom();
|
|
|
|
if (isPullingDown) {
|
|
pulling = true;
|
|
applyPull(Math.min(diff * resistance, maxPull));
|
|
} else if (isPullingUp) {
|
|
pulling = true;
|
|
applyPull(Math.max(diff * resistance, -maxPull));
|
|
}
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
container.addEventListener(
|
|
"touchend",
|
|
() => {
|
|
if (pulling) release();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
}
|
|
|
|
function initRubberbanding() {
|
|
document
|
|
.querySelectorAll("#chat-scroll-container, .content-scroll-container")
|
|
.forEach((container) => attachRubberbanding(container));
|
|
}
|
|
|
|
// === INITIALIZATION ===
|
|
function init() {
|
|
initScrollShadow();
|
|
initHtmxSwapAnimation();
|
|
initRubberbanding();
|
|
}
|
|
|
|
// Run on DOMContentLoaded
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
// Re-init rubberbanding after HTMX navigations
|
|
document.body.addEventListener("htmx:afterSettle", () => {
|
|
initRubberbanding();
|
|
});
|
|
|
|
// Add typewriter cursor blink animation
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0; }
|
|
}
|
|
.typewriter-cursor {
|
|
color: var(--color-accent);
|
|
font-weight: bold;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
})();
|