|
|
|
@ -1,17 +1,11 @@ |
|
|
|
import "@hotwired/turbo-rails" |
|
|
|
import { Application } from "@hotwired/stimulus" |
|
|
|
|
|
|
|
import LocaleController from "locale_controller" |
|
|
|
import ImageController from "image_controller" |
|
|
|
import PlausibleController from "plausible_controller" |
|
|
|
|
|
|
|
import QuizImagePreloader from "quiz_preloader" |
|
|
|
import LanguageMenuController from "language_menu_controller" |
|
|
|
|
|
|
|
const application = Application.start() |
|
|
|
|
|
|
|
application.register("locale", LocaleController) |
|
|
|
application.register("image", ImageController) |
|
|
|
application.register("plausible", PlausibleController) |
|
|
|
application.register("language-menu", LanguageMenuController) |
|
|
|
|
|
|
|
// Configure Stimulus development experience
|
|
|
|
application.debug = false |
|
|
|
@ -19,562 +13,3 @@ window.Stimulus = application |
|
|
|
|
|
|
|
export { application } |
|
|
|
|
|
|
|
const quizPreloader = new QuizImagePreloader() |
|
|
|
quizPreloader.init() |
|
|
|
|
|
|
|
var animationElements |
|
|
|
var originalHeights |
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("turbo:before-visit", (event) => { |
|
|
|
const bodyStyle = document.body.getAttribute('style') |
|
|
|
if (bodyStyle) { |
|
|
|
sessionStorage.setItem('previousBodyStyle', bodyStyle) |
|
|
|
} else { |
|
|
|
sessionStorage.removeItem('previousBodyStyle') |
|
|
|
} |
|
|
|
}) |
|
|
|
|
|
|
|
// Apply stored style on backdrop pages
|
|
|
|
document.addEventListener("turbo:render", (event) => { |
|
|
|
const hasBackdrop = document.querySelector('.with-backdrop') |
|
|
|
|
|
|
|
if (hasBackdrop) { |
|
|
|
const previousStyle = sessionStorage.getItem('previousBodyStyle') |
|
|
|
if (previousStyle) { |
|
|
|
document.body.setAttribute('style', previousStyle) |
|
|
|
sessionStorage.removeItem('previousBodyStyle') |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
|
|
|
|
document.addEventListener('turbo:render', (event) => { |
|
|
|
console.info('turbo:render') |
|
|
|
|
|
|
|
let errorElements = document.querySelectorAll('.field_with_errors') |
|
|
|
|
|
|
|
if (errorElements.length > 0) { |
|
|
|
animationElements = document.querySelectorAll('.animation-element') |
|
|
|
gsap.set(animationElements, { |
|
|
|
opacity: 1 |
|
|
|
}) |
|
|
|
} |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// document.addEventListener("turbo:before-stream-render", animateElements)
|
|
|
|
document.addEventListener("turbo:load", animateElements) |
|
|
|
document.addEventListener("turbo:load", validateFormInput) |
|
|
|
|
|
|
|
|
|
|
|
function validateFormInput(event) { |
|
|
|
const form = document.getElementById('questionForm'); |
|
|
|
const submitBtn = document.getElementById('questionSubmitButton'); |
|
|
|
|
|
|
|
if (form) { |
|
|
|
form.addEventListener('submit', e => { |
|
|
|
// If no radio in the group is checked…
|
|
|
|
if (!form.querySelector('input[type=radio]:checked')) { |
|
|
|
e.preventDefault(); // stop the form from submitting
|
|
|
|
submitBtn.classList.remove('wiggle'); // reset so the animation can retrigger
|
|
|
|
void submitBtn.offsetWidth; // force reflow (quick trick)
|
|
|
|
submitBtn.classList.add('wiggle'); // start the wiggle
|
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
// Tidy up the class once the animation ends
|
|
|
|
submitBtn.addEventListener('animationend', () => { |
|
|
|
submitBtn.classList.remove('wiggle'); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function animateElements(event) { |
|
|
|
|
|
|
|
// console.info(event.type)
|
|
|
|
|
|
|
|
// Copy link to clipboard
|
|
|
|
document.querySelectorAll('[data-share-title]').forEach((share_btn) => { |
|
|
|
share_btn.addEventListener('click', (e) => { |
|
|
|
|
|
|
|
if (navigator.share) { |
|
|
|
navigator.share({ |
|
|
|
title: e.currentTarget.getAttribute('data-share-title'), |
|
|
|
text: e.currentTarget.getAttribute('data-share-text'), |
|
|
|
url: e.currentTarget.getAttribute('data-share-url'), |
|
|
|
}).then(() => { |
|
|
|
console.log('Thanks for sharing!') |
|
|
|
}) |
|
|
|
.catch(console.error) |
|
|
|
} else { |
|
|
|
navigator.clipboard.writeText(window.location.href) |
|
|
|
alert('URL copied to clipboard!') |
|
|
|
} |
|
|
|
}) |
|
|
|
}) |
|
|
|
|
|
|
|
originalHeights = [] |
|
|
|
animationElements = document.querySelectorAll('.animation-element') |
|
|
|
|
|
|
|
animationElements.forEach((animationElement, index) => { |
|
|
|
const height = animationElement.offsetHeight |
|
|
|
originalHeights.push(height) |
|
|
|
// console.log(`Element ${index + 1} height: ${height}px`)
|
|
|
|
}) |
|
|
|
|
|
|
|
gsap.set(animationElements, { |
|
|
|
height: 0, |
|
|
|
opacity: 0 |
|
|
|
}) |
|
|
|
|
|
|
|
let typewriterElements = document.querySelectorAll('.typewriter-text') |
|
|
|
if (typewriterElements.length > 0) { |
|
|
|
gsap.set(typewriterElements, { |
|
|
|
opacity: 0 |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// Start the sequential animation
|
|
|
|
animateElementsSequentially() |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function animateElementsSequentially(index = 0) { |
|
|
|
|
|
|
|
if (index >= animationElements.length) { |
|
|
|
console.log('All animations complete') |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
// 'margin-top': (index == (animationElements.length-1) ? "12px" : "8px"),
|
|
|
|
|
|
|
|
gsap.to(animationElements[index], { |
|
|
|
height: originalHeights[index], |
|
|
|
opacity: 1, |
|
|
|
duration: (0.004 * originalHeights[index]), |
|
|
|
delay: (index > 0 ? 0.4 : 0), |
|
|
|
ease: "power2.out", |
|
|
|
onComplete: () => { |
|
|
|
|
|
|
|
// Focus input field
|
|
|
|
animationElements[index].querySelector('input[type=text]')?.focus() |
|
|
|
|
|
|
|
|
|
|
|
// Typewriter
|
|
|
|
const typewriterTexts = animationElements[index].querySelectorAll('.typewriter-text') |
|
|
|
if (typewriterTexts.length > 0) { |
|
|
|
const typewriter = typeWriterConsoleSequential(typewriterTexts[0].parentElement, { |
|
|
|
baseSpeed: 10, |
|
|
|
onAllComplete: () => { |
|
|
|
|
|
|
|
if (index + 1 < animationElements.length) { |
|
|
|
let nextAnimationElementInputField = animationElements[index+1].querySelector('input[type=text]') |
|
|
|
if (nextAnimationElementInputField) { |
|
|
|
animationElements[index].querySelectorAll('.typewriter-cursor').forEach((node) => { |
|
|
|
node.classList.remove('typewriter-cursor') |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Start next element after typewriter finishes
|
|
|
|
animateElementsSequentially(index + 1) |
|
|
|
|
|
|
|
} |
|
|
|
}) |
|
|
|
typewriter.start() |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
// % Answer Distributions
|
|
|
|
const answerDistributions = animationElements[index].querySelectorAll('.answer-distribution') |
|
|
|
if (answerDistributions.length > 0 ) { |
|
|
|
|
|
|
|
const containerHeight = animationElements[index].offsetHeight - 8 // - padding
|
|
|
|
|
|
|
|
const percentages = Array.from(answerDistributions).map(bar => parseInt(bar.dataset.value)) |
|
|
|
const maxPercentage = Math.max(...percentages) |
|
|
|
|
|
|
|
const targetHeights = [] |
|
|
|
|
|
|
|
answerDistributions.forEach((bar, i) => { |
|
|
|
const percentage = parseInt(bar.dataset.value) |
|
|
|
const relativePercentage = (percentage / maxPercentage) * 100 |
|
|
|
const calculatedHeight = (relativePercentage / 100) * containerHeight |
|
|
|
|
|
|
|
const minHeight = getNaturalHeight(bar) |
|
|
|
|
|
|
|
let finalMinHeight = minHeight |
|
|
|
|
|
|
|
// if (bar.querySelector('.explanation')) {
|
|
|
|
// finalMinHeight = Math.max(minHeight, 160)
|
|
|
|
// }
|
|
|
|
|
|
|
|
targetHeights[i] = Math.max(calculatedHeight, finalMinHeight) |
|
|
|
}) |
|
|
|
|
|
|
|
const percentageValues = percentages.map((val, idx) => ({ value: val, index: idx })) |
|
|
|
const sortedPercentages = [...percentageValues].sort((a, b) => b.value - a.value) |
|
|
|
const highestBar = sortedPercentages[0] |
|
|
|
const lowestBar = sortedPercentages[sortedPercentages.length - 1] |
|
|
|
|
|
|
|
let equalHeight = answerDistributions[0].offsetHeight |
|
|
|
|
|
|
|
answerDistributions.forEach((bar, i) => { |
|
|
|
let fakeHeight, finalHeight |
|
|
|
|
|
|
|
if (i === highestBar.index) { |
|
|
|
fakeHeight = containerHeight * (0.3 + ((Math.random() - 0.5) * 0.2)) |
|
|
|
} else if (i === lowestBar.index) { |
|
|
|
fakeHeight = containerHeight * (0.8 + ((Math.random() - 0.5) * 0.2)) |
|
|
|
} else { |
|
|
|
fakeHeight = containerHeight * (0.5 + ((Math.random() - 0.5) * 0.2)) |
|
|
|
} |
|
|
|
|
|
|
|
finalHeight = targetHeights[i] |
|
|
|
|
|
|
|
gsap.to(bar, { |
|
|
|
keyframes: [ |
|
|
|
{ height: fakeHeight, duration: 0.3, ease: "power2.inOut" }, |
|
|
|
{ height: finalHeight, duration: 0.4, ease: "power2.in" } |
|
|
|
], |
|
|
|
onComplete: () => { |
|
|
|
if (bar.dataset.answered != undefined) { |
|
|
|
bar.classList.add('answered') |
|
|
|
} |
|
|
|
|
|
|
|
gsap.to(bar.querySelectorAll('.percentage'), { |
|
|
|
opacity: 1, |
|
|
|
duration: 0.4, |
|
|
|
onComplete: function () { |
|
|
|
const explanationElement = this.targets()[0].nextElementSibling |
|
|
|
if (explanationElement) { |
|
|
|
explanationElement.style.display = 'block' |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
|
|
|
|
if (i == (answerDistributions.length - 1)) { |
|
|
|
setTimeout(() => animateElementsSequentially(index + 1), 1000) |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
}) |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
animateElementsSequentially(index + 1) |
|
|
|
} |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function typeWriterConsoleSequential(container, options = {}) { |
|
|
|
const { |
|
|
|
selector = '.typewriter-text', |
|
|
|
baseSpeed = 40, |
|
|
|
divDelay = 200, |
|
|
|
showCursor = true, |
|
|
|
onDivComplete = null, |
|
|
|
onAllComplete = null |
|
|
|
} = options |
|
|
|
|
|
|
|
const textDivs = container.querySelectorAll(selector) |
|
|
|
if (textDivs.length === 0) return null |
|
|
|
|
|
|
|
let currentDivIndex = 0 |
|
|
|
let isActive = false |
|
|
|
let animationId = null |
|
|
|
let timeoutIds = new Set() // Track all timeouts for cleanup
|
|
|
|
|
|
|
|
// Cleanup function for Turbo navigation
|
|
|
|
function cleanup() { |
|
|
|
isActive = false |
|
|
|
if (animationId) { |
|
|
|
cancelAnimationFrame(animationId) |
|
|
|
animationId = null |
|
|
|
} |
|
|
|
// Clear all timeouts
|
|
|
|
timeoutIds.forEach(id => clearTimeout(id)) |
|
|
|
timeoutIds.clear() |
|
|
|
} |
|
|
|
|
|
|
|
// Set up Turbo event listeners for cleanup
|
|
|
|
function setupTurboListeners() { |
|
|
|
const turboEvents = ['turbo:before-visit', 'turbo:before-cache', 'beforeunload'] |
|
|
|
|
|
|
|
turboEvents.forEach(eventName => { |
|
|
|
document.addEventListener(eventName, cleanup, { once: false }) |
|
|
|
}) |
|
|
|
|
|
|
|
// Store cleanup function on container for manual cleanup
|
|
|
|
container._typewriterCleanup = cleanup |
|
|
|
} |
|
|
|
|
|
|
|
// Enhanced setTimeout that tracks IDs
|
|
|
|
function safeSetTimeout(callback, delay) { |
|
|
|
const id = setTimeout(() => { |
|
|
|
timeoutIds.delete(id) |
|
|
|
if (isActive) callback() |
|
|
|
}, delay) |
|
|
|
timeoutIds.add(id) |
|
|
|
return id |
|
|
|
} |
|
|
|
|
|
|
|
// Store original text and prepare divs
|
|
|
|
function initializeDivs() { |
|
|
|
// Set container height to prevent layout shifts
|
|
|
|
const containerHeight = container.offsetHeight |
|
|
|
container.style.minHeight = `${containerHeight}px` |
|
|
|
|
|
|
|
textDivs.forEach(div => { |
|
|
|
if (!div.dataset.originalText) { |
|
|
|
div.dataset.originalText = div.textContent.trim() |
|
|
|
} |
|
|
|
// Use visibility instead of blanking content to maintain layout
|
|
|
|
div.style.visibility = 'hidden' |
|
|
|
div.innerHTML = '' |
|
|
|
div.style.opacity = '1' |
|
|
|
}) |
|
|
|
|
|
|
|
// Force reflow to ensure styles are applied
|
|
|
|
container.offsetHeight |
|
|
|
} |
|
|
|
|
|
|
|
function createCursorSpan() { |
|
|
|
// No longer needed - cursor is now handled via CSS
|
|
|
|
return null |
|
|
|
} |
|
|
|
|
|
|
|
function updateDivContent(div, text, withCursor = false) { |
|
|
|
// Use textContent for better performance
|
|
|
|
div.textContent = text |
|
|
|
|
|
|
|
// Toggle cursor class instead of adding DOM element
|
|
|
|
if (withCursor && showCursor) { |
|
|
|
div.classList.add('typewriter-cursor') |
|
|
|
} else { |
|
|
|
div.classList.remove('typewriter-cursor') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function animateNextDiv() { |
|
|
|
if (currentDivIndex >= textDivs.length || !isActive) { |
|
|
|
isActive = false |
|
|
|
if (onAllComplete) { |
|
|
|
// Use setTimeout to avoid potential timing issues
|
|
|
|
requestAnimationFrame(() => { |
|
|
|
safeSetTimeout(() => onAllComplete(container), 200) |
|
|
|
}) |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const currentDiv = textDivs[currentDivIndex] |
|
|
|
const originalText = currentDiv.dataset.originalText |
|
|
|
const isLastDiv = currentDivIndex === textDivs.length - 1 |
|
|
|
const isFirstDiv = currentDivIndex === 0 |
|
|
|
|
|
|
|
// Make div visible and prepare for typing
|
|
|
|
currentDiv.style.visibility = 'visible' |
|
|
|
updateDivContent(currentDiv, '', showCursor) |
|
|
|
currentDiv.classList.add('typing-active') |
|
|
|
|
|
|
|
let charIndex = 0 |
|
|
|
// Add 1 second delay for first div to let cursor blink twice
|
|
|
|
let nextCharTime = performance.now() + (isFirstDiv ? 20 : 20) |
|
|
|
|
|
|
|
function typeFrame(currentTime) { |
|
|
|
if (!isActive) { |
|
|
|
if (animationId) { |
|
|
|
cancelAnimationFrame(animationId) |
|
|
|
animationId = null |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
if (currentTime >= nextCharTime && charIndex < originalText.length) { |
|
|
|
charIndex++ |
|
|
|
const currentText = originalText.substring(0, charIndex) |
|
|
|
updateDivContent(currentDiv, currentText, true) |
|
|
|
|
|
|
|
// Calculate delay for next character
|
|
|
|
// let delay = baseSpeed + Math.random() * 15
|
|
|
|
let delay = baseSpeed |
|
|
|
const char = originalText.charAt(charIndex - 1) |
|
|
|
|
|
|
|
if (char === '.' || char === '!' || char === '?') { |
|
|
|
delay += 250 |
|
|
|
} else if (char === ',' || char === '') { |
|
|
|
delay += 120 |
|
|
|
} else if (char === ' ') { |
|
|
|
delay += 60 |
|
|
|
} |
|
|
|
|
|
|
|
nextCharTime = currentTime + delay |
|
|
|
} |
|
|
|
|
|
|
|
// Check if current div is complete
|
|
|
|
if (charIndex >= originalText.length) { |
|
|
|
currentDiv.classList.remove('typing-active') |
|
|
|
|
|
|
|
// Final content update
|
|
|
|
updateDivContent(currentDiv, originalText, showCursor) |
|
|
|
|
|
|
|
if (onDivComplete) { |
|
|
|
onDivComplete(currentDiv, currentDivIndex, container) |
|
|
|
} |
|
|
|
|
|
|
|
currentDivIndex++ |
|
|
|
|
|
|
|
if (isActive && currentDivIndex < textDivs.length) { |
|
|
|
// Move to next div after delay
|
|
|
|
safeSetTimeout(() => { |
|
|
|
if (isActive) { |
|
|
|
// Remove cursor from previous div (except last one)
|
|
|
|
if (!isLastDiv) { |
|
|
|
updateDivContent(currentDiv, originalText, false) |
|
|
|
} |
|
|
|
animateNextDiv() |
|
|
|
} |
|
|
|
}, divDelay) |
|
|
|
} else if (isActive) { |
|
|
|
// This was the last div
|
|
|
|
safeSetTimeout(() => { |
|
|
|
if (onAllComplete) { |
|
|
|
onAllComplete(container) |
|
|
|
} |
|
|
|
}, 200) |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
// Continue animation
|
|
|
|
if (isActive) { |
|
|
|
animationId = requestAnimationFrame(typeFrame) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Start the animation
|
|
|
|
animationId = requestAnimationFrame(typeFrame) |
|
|
|
} |
|
|
|
|
|
|
|
// Initialize Turbo listeners
|
|
|
|
setupTurboListeners() |
|
|
|
|
|
|
|
// Add required CSS for cursor pseudo-element
|
|
|
|
function addCursorStyles() { |
|
|
|
const styleId = 'typewriter-cursor-styles' |
|
|
|
if (!document.getElementById(styleId)) { |
|
|
|
const style = document.createElement('style') |
|
|
|
style.id = styleId |
|
|
|
style.textContent = `
|
|
|
|
.typewriter-cursor::after { |
|
|
|
content: '|' |
|
|
|
animation: typewriter-blink 1s infinite |
|
|
|
color: currentColor |
|
|
|
} |
|
|
|
|
|
|
|
@keyframes typewriter-blink { |
|
|
|
0%, 50% { opacity: 1 } |
|
|
|
51%, 100% { opacity: 0 } |
|
|
|
} |
|
|
|
`
|
|
|
|
document.head.appendChild(style) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Initialize styles
|
|
|
|
addCursorStyles() |
|
|
|
|
|
|
|
return { |
|
|
|
start: () => { |
|
|
|
if (isActive) return // Prevent multiple starts
|
|
|
|
|
|
|
|
initializeDivs() |
|
|
|
isActive = true |
|
|
|
currentDivIndex = 0 |
|
|
|
|
|
|
|
// Use requestAnimationFrame for smoother start
|
|
|
|
requestAnimationFrame(() => { |
|
|
|
if (isActive) { |
|
|
|
animateNextDiv() |
|
|
|
} |
|
|
|
}) |
|
|
|
}, |
|
|
|
|
|
|
|
stop: () => { |
|
|
|
cleanup() |
|
|
|
}, |
|
|
|
|
|
|
|
reset: () => { |
|
|
|
cleanup() |
|
|
|
|
|
|
|
textDivs.forEach(div => { |
|
|
|
if (div.dataset.originalText) { |
|
|
|
div.style.visibility = 'visible' |
|
|
|
updateDivContent(div, div.dataset.originalText, false) |
|
|
|
div.classList.remove('typing-active') |
|
|
|
div.classList.remove('typewriter-cursor') |
|
|
|
} |
|
|
|
}) |
|
|
|
currentDivIndex = 0 |
|
|
|
}, |
|
|
|
|
|
|
|
// Manual cleanup method for explicit cleanup
|
|
|
|
destroy: () => { |
|
|
|
cleanup() |
|
|
|
// Remove Turbo event listeners
|
|
|
|
const turboEvents = ['turbo:before-visit', 'turbo:before-cache', 'beforeunload'] |
|
|
|
turboEvents.forEach(eventName => { |
|
|
|
document.removeEventListener(eventName, cleanup) |
|
|
|
}) |
|
|
|
// Remove cleanup reference
|
|
|
|
delete container._typewriterCleanup |
|
|
|
}, |
|
|
|
|
|
|
|
get isActive() { return isActive } |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function getNaturalHeight(el) { |
|
|
|
// Save original inline styles
|
|
|
|
|
|
|
|
const prevParent = { |
|
|
|
display: el.style.display |
|
|
|
}; |
|
|
|
|
|
|
|
// Apply temporary styles to parent
|
|
|
|
el.style.display = "flex"; |
|
|
|
|
|
|
|
// Handle hidden children
|
|
|
|
const childPrev = []; |
|
|
|
for (let child of el.children) { |
|
|
|
childPrev.push({ |
|
|
|
display: child.style.display |
|
|
|
}); |
|
|
|
|
|
|
|
child.style.display = "block"; |
|
|
|
} |
|
|
|
|
|
|
|
// Measure
|
|
|
|
const height = el.scrollHeight; |
|
|
|
|
|
|
|
// Restore child styles
|
|
|
|
Array.from(el.children).forEach((child, i) => { |
|
|
|
Object.assign(child.style, childPrev[i]); |
|
|
|
}); |
|
|
|
|
|
|
|
// Restore parent styles
|
|
|
|
Object.assign(el.style, prevParent); |
|
|
|
|
|
|
|
return height; |
|
|
|
} |