diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index b8fb449..008dd79 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -125,19 +125,35 @@ footer { z-index: 2; } -@keyframes slide-in { from { transform: translateY(100dvh); } to { transform: translateY(0); } } -@keyframes slide-out { from { transform: translateY(0); } to { transform: translateY(-100dvh); } } +@keyframes slide-in { + from { + transform: translateY(100dvh); + } + + to { + transform: translateY(0); + } +} +@keyframes slide-out { + from { + transform: translateY(0); + } + + to { + transform: translateY(-100dvh); + } +} /* Forward nav (normal clicks) */ html[data-turbo-visit-direction="forward"] { - &::view-transition-new(main) { animation: slide-in .4s ease-out both; } - &::view-transition-old(main) { animation: slide-out .4s ease-in both; } + &::view-transition-new(main) { animation: slide-in .3s ease-out both; } + &::view-transition-old(main) { animation: slide-out .3s ease-in both; } } /* Back button reverses the sense */ html[data-turbo-visit-direction="back"] { - &::view-transition-new(main), - &::view-transition-old(main) { animation: slide-in .4s ease-out both reverse; } + &::view-transition-new(main) { animation: none } + &::view-transition-old(main) { animation: none } } body { @@ -151,8 +167,14 @@ body { -webkit-font-smoothing: antialiased; touch-action: manipulation; - &:has(main .background-container) { - background-color: var(--clr-black); + background-image: var(--bg-landscape); + background-position: center center; + background-attachment: fixed; + background-repeat: no-repeat; + background-size: cover; + + @media (orientation: portrait) { + background-image: var(--bg-portrait); } } @@ -176,15 +198,16 @@ header { } path.logo-text { - fill: var(--clr-black); + fill: var(--clr-white); } -:has(main .background-container) { + +:has(main .with-backdrop) { & header path.logo-text { - fill: var(--clr-white); + fill: var(--clr-black); } & footer { - color: var(--clr-white); + color: var(--clr-black); } } @@ -197,7 +220,8 @@ footer { font-family: var(--ff-ikea); writing-mode: vertical-rl; text-orientation: mixed; - z-index: 10 + z-index: 10; + color: var(--clr-white); } @@ -265,10 +289,13 @@ footer { justify-content: center; padding: 0 1em; box-sizing: border-box; - transition: scale 0.08s ease-in-out; + transition: transform 0.08s ease-in-out; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; - &:not(.wiggle):active { - scale: 0.96; + &:not(.wiggle):active { + transform: scale(0.96); } } @@ -293,13 +320,16 @@ footer { main { - margin: 0 1.2rem 1.6rem 1.2rem; + padding: 50px 1.2rem 1.6rem 1.2rem; flex-grow: 1; display: flex; flex-direction: column; justify-content: end; - + &:has(.with-backdrop) { + background-color: var(--clr-dark-yellow); + } + } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f1e2f5..7557ca3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,4 +11,16 @@ module ApplicationHelper end end + + def quiz_image_urls + + urls = [] + + assets = Node.viewable.map {|node| node.assets.includes(file_attachment: :blob).select{ |asset| asset.file.image? } }.flatten + assets.each do |asset| + urls << rails_storage_proxy_path(asset.file.variant(resize_to_limit: [((image_orientation(asset.file) == :landscape) ? 2400 : 1600), nil])) + end + + urls.compact.uniq + end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 94fe2f7..cd6e9eb 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -4,6 +4,8 @@ import { Application } from "@hotwired/stimulus" import LocaleController from "locale_controller" import ImageController from "image_controller" +import QuizImagePreloader from "quiz_preloader" + const application = Application.start() application.register("locale", LocaleController) @@ -15,10 +17,35 @@ 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') diff --git a/app/javascript/quiz_preloader.js b/app/javascript/quiz_preloader.js new file mode 100644 index 0000000..e7c267c --- /dev/null +++ b/app/javascript/quiz_preloader.js @@ -0,0 +1,117 @@ +export default class QuizImagePreloader { + constructor() { + this.isPreloading = false + this.preloadedImages = new Map() + } + + init() { + // Start preloading on initial page load + document.addEventListener('DOMContentLoaded', () => { + sessionStorage.removeItem('quiz_images_preloaded') + // this.startPreloadingFromHeader() + // console.info('startPreloadingFromHeader'); + }) + + // Continue preloading on Turbo navigation + document.addEventListener('turbo:load', () => { + this.startPreloadingFromHeader() + console.info('startPreloadingFromHeader'); + }) + + + } + + startPreloadingFromHeader() { + + if (sessionStorage.getItem('quiz_images_preloaded') === 'true') { + return + } + + // Skip if already preloading + if (this.isPreloading) { + return + } + + const imageUrls = this.getImageUrlsFromHeader() + + if (imageUrls.length > 0) { + this.preloadImages(imageUrls) + } + } + + getImageUrlsFromHeader() { + const headerElement = document.querySelector('header[data-quiz-images]') || + document.querySelector('[data-quiz-images]') + + if (!headerElement) return [] + + + try { + const urls = JSON.parse(headerElement.dataset.quizImages || '[]') + return urls.filter(url => url && url.trim()) + } catch (e) { + console.warn('Failed to parse quiz image URLs from header:', e) + return [] + } + } + + preloadImages(imageUrls) { + this.isPreloading = true + let loadedCount = 0 + const totalImages = imageUrls.length + + imageUrls.forEach((url, index) => { + // Skip if already preloaded + if (this.preloadedImages.has(url)) { + loadedCount++ + if (loadedCount === totalImages) { + this.onPreloadComplete() + } + return + } + + const img = new Image() + + img.onload = () => { + this.preloadedImages.set(url, img) + loadedCount++ + + if (loadedCount === totalImages) { + this.onPreloadComplete() + } + } + + img.onerror = () => { + console.warn(`Failed to preload image: ${url}`) + loadedCount++ + + if (loadedCount === totalImages) { + this.onPreloadComplete() + } + } + + // Stagger requests to avoid overwhelming server + setTimeout(() => { + img.src = url + }, index * 30) + }) + } + + onPreloadComplete() { + this.isPreloading = false + sessionStorage.setItem('quiz_images_preloaded', 'true') + + console.info('PreloadComplete'); + // Dispatch completion event for components that need to know + document.dispatchEvent(new CustomEvent('quiz:preload:complete', { + detail: { totalImages: this.preloadedImages.size } + })) + } + + // Check if preloading is complete + isComplete() { + return sessionStorage.getItem('quiz_images_preloaded') === 'true' + } + + +} \ No newline at end of file diff --git a/app/views/languages/index.html.erb b/app/views/languages/index.html.erb index 32d7f54..8605cf4 100644 --- a/app/views/languages/index.html.erb +++ b/app/views/languages/index.html.erb @@ -1,10 +1,21 @@ -<%- content_for :title, t('project_name') %> +<%- + content_for :title, t('project_name') + assets = @node.assets.includes(file_attachment: :blob).select{ |asset| asset.file.image? }.to_a -