diff --git a/app/assets/images/ico-arrow-down.svg b/app/assets/images/ico-arrow-down.svg deleted file mode 100644 index f76e305..0000000 --- a/app/assets/images/ico-arrow-down.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/ico-arrow-right.svg b/app/assets/images/ico-arrow-right.svg index 6005d25..574314e 100644 --- a/app/assets/images/ico-arrow-right.svg +++ b/app/assets/images/ico-arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/assets/images/ico-arrow-updown.svg b/app/assets/images/ico-arrow-updown.svg deleted file mode 100644 index 5d13fe4..0000000 --- a/app/assets/images/ico-arrow-updown.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/ico-clock.svg b/app/assets/images/ico-clock.svg new file mode 100644 index 0000000..c7e13f4 --- /dev/null +++ b/app/assets/images/ico-clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/ico-close.svg b/app/assets/images/ico-close.svg index fc15840..f986246 100644 --- a/app/assets/images/ico-close.svg +++ b/app/assets/images/ico-close.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/images/ico-globe.svg b/app/assets/images/ico-globe.svg new file mode 100644 index 0000000..5899852 --- /dev/null +++ b/app/assets/images/ico-globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/ico-h1.svg b/app/assets/images/ico-h1.svg deleted file mode 100644 index 923f83b..0000000 --- a/app/assets/images/ico-h1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/ico-h2.svg b/app/assets/images/ico-h2.svg deleted file mode 100644 index ae5e530..0000000 --- a/app/assets/images/ico-h2.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/ico-h3.svg b/app/assets/images/ico-h3.svg deleted file mode 100644 index 4f46f42..0000000 --- a/app/assets/images/ico-h3.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/ikea-foundation-203x22.svg b/app/assets/images/ikea-foundation-203x22.svg index 563d008..0a53a0d 100644 --- a/app/assets/images/ikea-foundation-203x22.svg +++ b/app/assets/images/ikea-foundation-203x22.svg @@ -1,6 +1,6 @@ - + - \ No newline at end of file + diff --git a/app/assets/images/wase.png b/app/assets/images/wase.png deleted file mode 100644 index 9030e6c..0000000 Binary files a/app/assets/images/wase.png and /dev/null differ diff --git a/app/assets/images/windmill.png b/app/assets/images/windmill.png deleted file mode 100644 index b0c2f5f..0000000 Binary files a/app/assets/images/windmill.png and /dev/null differ diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index aa24e01..d618b40 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -111,9 +111,179 @@ body { flex-direction: column; gap: 0; min-height: 100vh; - min-height: 100dvh; + min-height: 100svh; -webkit-font-smoothing: antialiased; - touch-action: manipulation; - + touch-action: manipulation; + + background-color: var(--clr-white); +} + +button { + cursor: pointer; +} + +ul[class] { + margin: 0; + padding: 0; + list-style: none; +} + +header { + background-color: var(--clr-white); + position: fixed; + inset: 16px 16px auto 16px; + padding: 14px 16px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0px 2px 2px 0px #0000000A; + z-index: 100; + + > a { + flex-grow: 1; + max-width: 203px; + } +} + +.language-button { + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1; + background-color: var(--clr-sand); + border: none; + border-radius: 50%; + width: 36px; + cursor: pointer; + + svg { + display: none; + + &:nth-child(1) { + display: block; + } + } + + &[aria-expanded="true"] { + svg:nth-child(1) { + display: none; + } + svg:nth-child(2) { + display: block; + } + } +} + +#language-menu { + position: absolute; + inset: 0 0 auto 0; + z-index: 90; + background-color: var(--clr-sand); + padding: calc(16px + 64px + 32px) 16px 16px 16px; + + & h2 { + font: var(--td-xl); + font-weight: 400; + margin: 0 0 16px 0; + } + + & ul { + font: var(--td-base); + border-top: 1px solid var(--clr-black); + } + + & li { + border-bottom: 1px solid var(--clr-black); + padding: 12px 0; + position: relative; + min-height: 56px; + + } + + & a { + text-decoration: none; + color: var(--clr-black); + display: flex; + align-items: center; + min-height: 32px; + + &.current { + font-weight: 700; + } + + &::after { + content: ""; + position: absolute; + right: 0; + width: 32px; + aspect-ratio: 1; + border-radius: 50%; + background-color: var(--clr-green); + background-image: url("ico-arrow-right.svg"); + background-repeat: no-repeat; + background-position: 50% 50%; + } + + } +} + +.intro-container { + display: flex; + flex-direction: column; + gap: 0; + min-height: 100vh; + min-height: 100svh; + + > img { + flex: 1 1 0; + min-height: 0; + width: 100%; + object-fit: cover; + } + + > article { + background-color: var(--clr-white); + flex-shrink: 0; + text-align: center; + padding: 24px 24px 16px 24px; + } + + & h1 { + font: var(--td-xl); + font-weight: 400; + margin: 0 auto 8px auto; + } + + & p { + max-width: 32ch; + margin: 0 auto 24px auto; + } + + & .cta + * { + margin-top: 12px; + } +} + +.cta { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--clr-green); + background-image: url("ico-arrow-right.svg"); + background-repeat: no-repeat; + background-position: 16px 50%; + max-width: 280px; + border-radius: 100vw; + height: 3em; + margin: 0 auto; + font-weight: 700; + color: var(--clr-black); + text-decoration: none; } +.play-time { + font: var(--td-s); + display: inline-flex; + gap: 0.4em; + align-items: center; +} \ No newline at end of file diff --git a/app/controllers/languages_controller.rb b/app/controllers/languages_controller.rb index c8d05e8..96d6caa 100644 --- a/app/controllers/languages_controller.rb +++ b/app/controllers/languages_controller.rb @@ -1,44 +1,10 @@ class LanguagesController < ApplicationController - before_action :set_locale_to_default, only: :index - before_action :set_locale, only: :show - before_action :set_node - - helper_method :accept_language - def index - not_found unless @node + redirect_to locale_root_path(locale: first_matching_language) end - - def show - @accept_language = params[:locale] - - not_found unless @node - - render :index - end - - - def update - set_locale - - respond_to do |format| - format.turbo_stream - end - end - - private - def set_node - @node = Node.roots.viewable.first - end - - def accept_language - @accept_language ||= first_matching_language - end - - def first_matching_language accept_languages = request.env["HTTP_ACCEPT_LANGUAGE"].to_s.split(",").map { |l| lang, q_factor = l.split(";q=") @@ -60,9 +26,4 @@ private end "en" end - - - def set_locale_to_default - I18n.locale = I18n.default_locale - end end diff --git a/app/helpers/site_helper.rb b/app/helpers/site_helper.rb index 00609b5..ca900d0 100644 --- a/app/helpers/site_helper.rb +++ b/app/helpers/site_helper.rb @@ -19,33 +19,27 @@ module SiteHelper end - def random_value(from, to) - range = rand(2) == 0 ? (-to..-from) : (from..to) - rand(range) + def render_responsive_picture(asset = nil, alt: "", lazy: false, fetchpriority: nil) + return unless asset + + widths = [ 800, 1600, 2400 ] + metadata = asset.file.metadata + + image_tag(rails_storage_proxy_path(asset.file.variant(resize_to_limit: [ 1600, nil ], format: "webp")), + alt: alt, + srcset: responsive_srcset(asset, widths), + sizes: "100vw", + width: metadata[:width], + height: metadata[:height], + loading: ("lazy" if lazy), + fetchpriority: fetchpriority, + decoding: "async") end - def parse_article_html(html) - result = {} - return result if html.blank? - - doc = Nokogiri::HTML.fragment(html, "utf-8") - - first_link = doc.at_css("a") - first_link.remove if first_link - - result[:title] = doc.at_css("h1")&.text - result[:description] = doc.at_css("div")&.text - result[:link] = first_link&.to_html - - doc.search("*").each do |node| - while node.children.last and node.children.last.name == "br" - node.children.last.remove - end - end - - result[:html] = doc.to_html - - result + def responsive_srcset(asset, widths) + widths.map { |w| + "#{rails_storage_proxy_path(asset.file.variant(resize_to_limit: [ w, nil ], format: "webp"))} #{w}w" + }.join(", ") end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 62dcd84..49a7bea 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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; -} diff --git a/app/javascript/language_menu_controller.js b/app/javascript/language_menu_controller.js new file mode 100644 index 0000000..6654b8c --- /dev/null +++ b/app/javascript/language_menu_controller.js @@ -0,0 +1,53 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button", "menu"] + + connect() { + this.boundClickOutside = this.clickOutside.bind(this) + this.boundKeydown = this.keydown.bind(this) + } + + disconnect() { + this.stopListening() + } + + toggle(event) { + event.preventDefault() + this.isOpen ? this.close() : this.open() + } + + open() { + this.buttonTarget.setAttribute("aria-expanded", "true") + this.menuTarget.hidden = false + document.addEventListener("mousedown", this.boundClickOutside) + document.addEventListener("keydown", this.boundKeydown) + } + + close() { + this.buttonTarget.setAttribute("aria-expanded", "false") + this.menuTarget.hidden = true + this.stopListening() + } + + stopListening() { + document.removeEventListener("mousedown", this.boundClickOutside) + document.removeEventListener("keydown", this.boundKeydown) + } + + get isOpen() { + return this.buttonTarget.getAttribute("aria-expanded") === "true" + } + + clickOutside(event) { + if (this.element.contains(event.target)) return + this.close() + } + + keydown(event) { + if (event.key === "Escape") { + this.close() + this.buttonTarget.focus() + } + } +} diff --git a/app/javascript/locale_controller.js b/app/javascript/locale_controller.js deleted file mode 100644 index 3ce9319..0000000 --- a/app/javascript/locale_controller.js +++ /dev/null @@ -1,56 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["select", "current"] - static values = { url: String } - - connect() { - } - - disconnect() { - } - - - changeLocale() { - - this.currentTarget.textContent = this.selectTarget.options[this.selectTarget.selectedIndex].textContent - - // Create form data to send - const formData = new FormData() - formData.append("locale", this.selectTarget.value) - - // Make the PUT request using fetch - fetch(this.urlValue, { - method: "PUT", - headers: { - 'Accept': "text/vnd.turbo-stream.html", - "X-CSRF-Token": this.getMetaValue("csrf-token") - }, - body: formData - }) - .then (response => response.text()) - .then(html => { - - Turbo.renderStreamMessage(html) - document.documentElement.setAttribute('lang', this.selectTarget.value) - - requestAnimationFrame(() => { - document.querySelectorAll('.animation-element').forEach((animationElement) => { - animationElement.style.opacity = 1 - }) - - }) - - }) - .catch((err) => { - console.info('rejected', err) - }) - - } - - // Helper method to get CSRF token - getMetaValue(name) { - const element = document.head.querySelector(`meta[name="${name}"]`) - return element.getAttribute("content") - } -} \ No newline at end of file diff --git a/app/javascript/plausible_controller.js b/app/javascript/plausible_controller.js deleted file mode 100644 index bd638dd..0000000 --- a/app/javascript/plausible_controller.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -let lastTrackedUrl = null; - -export default class extends Controller { - connect() { - document.addEventListener("turbo:load", () => { - const currentUrl = window.location.pathname + window.location.search; - if (window.plausible && currentUrl !== lastTrackedUrl) { - window.plausible("pageview"); - lastTrackedUrl = currentUrl; - } - }); - } -} diff --git a/app/models/concerns/has_attachments.rb b/app/models/concerns/has_attachments.rb index 3d7ebeb..e2d719f 100644 --- a/app/models/concerns/has_attachments.rb +++ b/app/models/concerns/has_attachments.rb @@ -26,9 +26,9 @@ module HasAttachments end + def icon - self.assets.map{ |asset| return asset if asset.file and asset.file.content_type.starts_with?('image/') } - nil + assets.find { |a| a.file.attached? && a.file.content_type&.start_with?("image/") } end diff --git a/app/models/node.rb b/app/models/node.rb index de600ae..6420ed8 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -26,16 +26,7 @@ class Node < ApplicationRecord enum :status, { status_published: 0, status_draft: 1, status_archived: 2 } enum :template, { - start: 0, - stage: 1, - choice: 2, - chance: 3, - bonus: 4, - best: 5, - bad: 6, - good: 7, - game_over: 8, - score: 9 + start: 0 } def available_templates diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 949fdee..6c45b7a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,11 +1,12 @@ - + <%= content_for?(:title) ? yield(:title) : t(:project_name) %> - + + <%= tag.meta name: "description", content: content_for?(:meta_description) ? yield(:meta_description) : (@node.present?? @node.page_description : "") %> <%= csrf_meta_tags %> @@ -16,16 +17,47 @@ <%= stylesheet_link_tag "reset", "application" %> + <%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller] %> -
- <%= link_to svg("ikea-foundation-203x22"), url_for(controller: "languages", action: (I18n.default_locale == I18n.locale ? "index" : "show")) %> -
+
+
+ <%= link_to svg("ikea-foundation-203x22"), url_for(controller: "site", action: "index") %> + + +
+ + +
+
<%= yield %>
- + diff --git a/app/views/site/start.html.erb b/app/views/site/start.html.erb new file mode 100644 index 0000000..c9afa17 --- /dev/null +++ b/app/views/site/start.html.erb @@ -0,0 +1,17 @@ +<%- content_for :title, node_title(@node) %> +
+ + <%= render_responsive_picture(@node.icon, alt: "Hero", fetchpriority: "high") %> + +
+ <%= tag.h1 t("game.intro_title").html_safe %> + <%= tag.p t("game.intro_description") %> + + <%= link_to tag.span(t("game.let_me_try")), "#", class: "cta" %> + <%= tag.div class: "play-time" do %> + <%= svg "ico-clock" %> + <%= tag.span t("game.play_time") %> + <% end %> +
+ +
\ No newline at end of file diff --git a/app/views/site/tmpl_chance.html.erb b/app/views/site/tmpl_chance.html.erb deleted file mode 100644 index a3a6ae9..0000000 --- a/app/views/site/tmpl_chance.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%- - content_for :title, node_title(@node) - cards = @node.children.viewable.ordered -%> - diff --git a/app/views/site/tmpl_choise.html.erb b/app/views/site/tmpl_choise.html.erb deleted file mode 100644 index e2d5483..0000000 --- a/app/views/site/tmpl_choise.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%- content_for :title, node_title(@node) %> - diff --git a/config/importmap.rb b/config/importmap.rb index e06586d..32fc0b0 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -14,5 +14,4 @@ pin "tom-select", to: "tom-select--dist--js--tom-select.base.min.js.js" # @2.4.3 # site_helper pin "application", preload: false -pin "locale_controller", preload: false -pin "plausible_controller", preload: false +pin "language_menu_controller", preload: false diff --git a/config/locales/da.yml b/config/locales/da.yml index 9a989f7..1954977 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -1,16 +1,14 @@ # Danish (da_DK) da: - get_started: Lad os komme i gang! - what_is_your_name: Hvad hedder du? - write_your_name: Skriv dit navn. - submit: Send - next_question: Næste spørgsmål - of_people_worldwide_think_just_lik_you: af mennesker verden over, der tænker som dig. - see_results: Se resultater - results: Resultater - read_more: Læs mere - share_on_story: Del - read_more_link: https://ikeafoundation.org/25 - please_type_your_name_to_continue: Skriv dit navn for at fortsætte. - share_title: IKEA Foundation Week quiz result - share_text: Quiz result description \ No newline at end of file + game: + please_choose_a_language: | + Vælg venligst
+ et sprog + intro_title: | + Kan du redde
+ tomaten? + intro_description: Det lyder nemt, men det er mere udfordrende, end du tror! + let_me_try: Lad mig prøve + play_time: Spilletid 2-5 minutter + + \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 6b4fa9d..7d73d1c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3,16 +3,18 @@ en: project_name: IKEA Foundation Week 2026 client_name: IKEA Foundation - let_me_try: Let me try! - flip_the_card: Flip the card - next_stage: Proceed to the next stage - bonus: Bonus - go_again: Go again - use_bonus_point: Use bonus point - get_bonus_point: Get bonus point - let_my_try: Let me try - - countries: + game: + please_choose_a_language: | + Please
+ choose a language + intro_title: | + Can you save
+ the tomato? + intro_description: Sounds easy, but it’s more challenging than you might think! + let_me_try: Let me try + play_time: Play time 2-5 minutes + + countries: au: Australia at: Austria bh: Bahrain diff --git a/config/routes.rb b/config/routes.rb index 9649977..b79cc0f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,10 +49,6 @@ Rails.application.routes.draw do get "login/verification", to: "sessions#verification" end - scope ":locale", constraints: { locale: /en|zh|hr|cs|da|nl|fi|fr|fr-CA|de|hu|it|ja|ko|nb|pl|pt|ro|sr|sk|sl|es|sv|uk/ } do - get "", to: "languages#show" - # get "*url", to: "site#page", constraints: lambda { |req| req.path.exclude?("storage") } - end namespace :api do namespace :v1 do @@ -60,7 +56,13 @@ Rails.application.routes.draw do end end - put "update_locale", to: "languages#update" + + scope ":locale", constraints: { locale: /en|zh|hr|cs|da|nl|fi|fr|fr-CA|de|hu|it|ja|ko|nb|pl|pt|ro|sr|sk|sl|es|sv|uk/ } do + + # get "*url", to: "site#page", constraints: lambda { |req| req.path.exclude?("storage") } + + get "", to: "site#index", as: :locale_root + end # Defines the root path route ("/") root "languages#index"