mirror of
https://github.com/perstarkse/minne.git
synced 2026-04-19 15:31:23 +02:00
release: 1.0.0
This commit is contained in:
199
html-router/assets/design-polish.js
Normal file
199
html-router/assets/design-polish.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Design Polishing Pass - Interactive Effects
|
||||
*
|
||||
* Includes:
|
||||
* - Scroll-Linked Navbar Shadow
|
||||
* - HTMX Swap Animation
|
||||
* - Typewriter AI Response
|
||||
* - Rubberbanding Scroll
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// === 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 = function(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: function(text) {
|
||||
buffer += text;
|
||||
if (!isTyping) {
|
||||
typeNextChar();
|
||||
}
|
||||
},
|
||||
complete: function() {
|
||||
// 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 initRubberbanding() {
|
||||
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
|
||||
|
||||
containers.forEach(container => {
|
||||
let startY = 0;
|
||||
let pulling = false;
|
||||
let pullDistance = 0;
|
||||
const maxPull = 60;
|
||||
const resistance = 0.4;
|
||||
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (e) => {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diff = currentY - startY;
|
||||
|
||||
// At top boundary, pulling down
|
||||
if (container.scrollTop <= 0 && diff > 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.min(diff * resistance, maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
// At bottom boundary, pulling up
|
||||
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
|
||||
pulling = true;
|
||||
pullDistance = Math.max(diff * resistance, -maxPull);
|
||||
container.style.transform = `translateY(${pullDistance}px)`;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', () => {
|
||||
if (pulling) {
|
||||
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;
|
||||
pullDistance = 0;
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// === 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);
|
||||
|
||||
})();
|
||||
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
@@ -1,144 +0,0 @@
|
||||
//==========================================================
|
||||
// head-support.js
|
||||
//
|
||||
// An extension to add head tag merging.
|
||||
//==========================================================
|
||||
(function(){
|
||||
|
||||
var api = null;
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function mergeHead(newContent, defaultMergeStrategy) {
|
||||
|
||||
if (newContent && newContent.indexOf('<head') > -1) {
|
||||
const htmlDoc = document.createElement("html");
|
||||
// remove svgs to avoid conflicts
|
||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
// extract head tag
|
||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
||||
|
||||
// if the head tag exists...
|
||||
if (headTag) {
|
||||
|
||||
var added = []
|
||||
var removed = []
|
||||
var preserved = []
|
||||
var nodesToAppend = []
|
||||
|
||||
htmlDoc.innerHTML = headTag;
|
||||
var newHeadTag = htmlDoc.querySelector("head");
|
||||
var currentHead = document.head;
|
||||
|
||||
if (newHeadTag == null) {
|
||||
return;
|
||||
} else {
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
var srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// determine merge strategy
|
||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
||||
|
||||
// get the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (mergeStrategy === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the tremaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
||||
log(newElt);
|
||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
||||
currentHead.appendChild(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("head-support", {
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
htmx.on('htmx:afterSwap', function(evt){
|
||||
let xhr = evt.detail.xhr;
|
||||
if (xhr) {
|
||||
var serverResponse = xhr.response;
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyRestore', function(evt){
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
if (evt.detail.cacheMiss) {
|
||||
mergeHead(evt.detail.serverResponse, "merge");
|
||||
} else {
|
||||
mergeHead(evt.detail.item.head, "merge");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyItemCreated', function(evt){
|
||||
var historyItem = evt.detail.item;
|
||||
historyItem.head = document.head.outerHTML;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user