Browse Source

start facts

main
Mattias Bodlund 3 weeks ago
parent
commit
d8988c7c02
17 changed files with 289 additions and 142 deletions
  1. +4
    -4
      Gemfile.lock
  2. +1
    -0
      app/assets/images/ico-wave.svg
  3. +108
    -36
      app/assets/stylesheets/application.css
  4. +7
    -0
      app/assets/stylesheets/forms.css
  5. +7
    -13
      app/controllers/site_controller.rb
  6. +2
    -0
      app/javascript/application.js
  7. +55
    -0
      app/javascript/carousel_controller.js
  8. +5
    -12
      app/models/concerns/has_attachments.rb
  9. +4
    -13
      app/models/node.rb
  10. +0
    -3
      app/views/admin/attachments/_attachment.html.erb
  11. +8
    -38
      app/views/layouts/application.html.erb
  12. +32
    -0
      app/views/site/_language-menu.html.erb
  13. +33
    -0
      app/views/site/facts.html.erb
  14. +16
    -12
      app/views/site/start.html.erb
  15. +1
    -0
      config/importmap.rb
  16. +5
    -9
      config/locales/en.yml
  17. +1
    -2
      config/routes.rb

+ 4
- 4
Gemfile.lock View File

@ -151,7 +151,7 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
lexxy (0.9.15.alpha.1)
lexxy (0.9.15.alpha.2)
rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
@ -292,7 +292,7 @@ GEM
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
rubocop (1.86.2)
rubocop (1.87.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -460,7 +460,7 @@ CHECKSUMS
kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430
kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lexxy (0.9.15.alpha.1) sha256=5906f921e9ec8b0b7c47853e7fb9fb1b3c97921f4edd701d94a76ba25c45fa84
lexxy (0.9.15.alpha.2) sha256=4bcbc2d1e6d6462707fdf34ead5f4a916d226b089695c1446099a17ba7386432
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
@ -521,7 +521,7 @@ CHECKSUMS
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d
rubocop (1.87.0) sha256=b9d9ddf55116a513f8ef2c7ae660662d8b49301f118d3f0df61865b33a5c188d
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.35.3) sha256=6edd45410866912b9b2e90ae3aeafd31d576df2bb2a9c9408f1667a50c32c7de


+ 1
- 0
app/assets/images/ico-wave.svg View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="190" height="95" viewBox="0 0 190 95"><path d="M0,96h190c-8.68,0-18.02-1.28-27.76-3.75-27.52-7.02-57.98-23.68-84.83-48.34C62.22,29.96,49.83,14.97,40.53,0c3.7,7.29,6.63,14.51,8.75,21.52,3.83,12.63,5.01,24.56,3.37,35.06-1.64,10.49-6.11,19.49-13.55,26.32-7.44,6.83-17.24,10.93-28.67,12.44-3.35.44-6.83.66-10.44.66Z"/></svg>

+ 108
- 36
app/assets/stylesheets/application.css View File

@ -131,8 +131,8 @@ ul[class] {
header {
background-color: var(--clr-white);
position: fixed;
inset: 16px 16px auto 16px;
padding: 14px 16px;
inset: 1rem 1rem auto 1rem;
padding: 0.875rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
@ -141,7 +141,7 @@ header {
> a {
flex-grow: 1;
max-width: 203px;
max-width: 12.6875rem;
}
}
@ -153,7 +153,7 @@ header {
background-color: var(--clr-sand);
border: none;
border-radius: 50%;
width: 36px;
width: 2.25rem;
cursor: pointer;
svg {
@ -179,12 +179,12 @@ header {
inset: 0 0 auto 0;
z-index: 90;
background-color: var(--clr-sand);
padding: calc(16px + 64px + 32px) 16px 16px 16px;
padding: calc(1rem + 4rem + 2rem) 1rem 1rem 1rem;
& h2 {
font: var(--td-xl);
font-weight: 400;
margin: 0 0 16px 0;
margin: 0 0 1rem 0;
}
& ul {
@ -194,10 +194,9 @@ header {
& li {
border-bottom: 1px solid var(--clr-black);
padding: 12px 0;
padding: 0.75rem 0;
position: relative;
min-height: 56px;
min-height: 3.5rem;
}
& a {
@ -205,7 +204,7 @@ header {
color: var(--clr-black);
display: flex;
align-items: center;
min-height: 32px;
min-height: 2rem;
&.current {
font-weight: 700;
@ -215,7 +214,7 @@ header {
content: "";
position: absolute;
right: 0;
width: 32px;
width: 2rem;
aspect-ratio: 1;
border-radius: 50%;
background-color: var(--clr-green);
@ -223,44 +222,75 @@ header {
background-repeat: no-repeat;
background-position: 50% 50%;
}
}
}
.intro-container {
main {
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);
& article {
flex: 0 0 auto;
background-color: var(--clr-bg);
flex-shrink: 0;
text-align: center;
padding: 24px 24px 16px 24px;
padding: 1.5rem 1.5rem 1rem 1.5rem;
& p {
max-width: 32ch;
margin: 0 auto 1.5rem auto;
}
& .cta + * {
margin-top: 0.75rem;
}
}
& h1 {
& h1, h2 {
font: var(--td-xl);
font-weight: 400;
margin: 0 auto 8px auto;
margin: 0 auto 0.5rem auto;
}
& p {
max-width: 32ch;
margin: 0 auto 24px auto;
}
.start {
--clr-bg: var(--clr-white);
--clr-action: var(--clr-green);
}
.facts {
--clr-bg: var(--clr-green);
--clr-action: var(--clr-white);
}
.hero-container, .carousel {
flex: 1 1 0;
min-height: 0;
}
.hero-container {
display: grid;
grid-template: 1fr / 1fr;
& img {
grid-area: 1 / 1;
width: 100%;
height: 100%;
object-fit: cover;
min-width: 0;
min-height: 0;
}
& .cta + * {
margin-top: 12px;
& svg {
grid-area: 1 / 1;
place-self: end end;
fill: var(--clr-bg, var(--clr-green));
}
}
@ -268,13 +298,13 @@ header {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--clr-green);
background-color: var(--clr-action, var(--clr-sand-light));
background-image: url("ico-arrow-right.svg");
background-repeat: no-repeat;
background-position: 16px 50%;
max-width: 280px;
background-position: 1rem 50%;
max-width: 17.5rem;
border-radius: 100vw;
height: 3em;
height: 3rem;
margin: 0 auto;
font-weight: 700;
color: var(--clr-black);
@ -286,4 +316,46 @@ header {
display: inline-flex;
gap: 0.4em;
align-items: center;
}
.carousel {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none;
> li {
scroll-snap-align: center;
display: flex;
flex-direction: column;
--clr-bg: var(--clr-green);
}
}
.scroll-marker-group {
display: flex;
gap: 0.5rem;
justify-content: center;
background-color: var(--clr-bg);
padding: 0 0 1rem 0;
& button {
appearance: none;
border: none;
width: 0.75rem;
aspect-ratio: 1;
border-radius: 50%;
background-color: var(--clr-white);
transition: background-color 0.1s ease;
&.current {
background-color: var(--clr-black);
}
}
}

+ 7
- 0
app/assets/stylesheets/forms.css View File

@ -1153,4 +1153,11 @@ ul {
word-break: break-all; /* Ensures long strings don't push the width */
}
}
h2, h3, h4, h5 {
font-weight: 400;
strong {
font-weight: 700;
}
}
}

+ 7
- 13
app/controllers/site_controller.rb View File

@ -1,32 +1,26 @@
class SiteController < ApplicationController
before_action :set_locale
helper_method :root_node
def index
@node = Node.roots.viewable.first
@node = root_node
not_found and return unless @node
render(template: "site/#{@node.template}")
end
def page
@node = Node.find_by!("url->>'#{I18n.locale}' = ?", url_from_param)
not_found and return unless @node and @node.viewable?
# Redirect to first child if index?
redirect_to @node.children.viewable.ordered.first.url and return if @node.index? and @node.children.viewable.any?
# Link or render
redirect_to(@node.href, allow_other_host: true) and return if @node.href.present?
render(template: "site/#{@node.template}")
def facts
@node = root_node&.children&.facts&.first
end
private
def root_node
@root_node ||= Node.roots.viewable.first
end
def url_from_param
return "" unless params[:url]


+ 2
- 0
app/javascript/application.js View File

@ -2,10 +2,12 @@ import "@hotwired/turbo-rails"
import { Application } from "@hotwired/stimulus"
import LanguageMenuController from "language_menu_controller"
import CarouselController from "carousel_controller"
const application = Application.start()
application.register("language-menu", LanguageMenuController)
application.register("carousel", CarouselController)
// Configure Stimulus development experience
application.debug = false


+ 55
- 0
app/javascript/carousel_controller.js View File

@ -0,0 +1,55 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["carousel", "marker"]
connect() {
this.boundUpdate = this.update.bind(this)
this.boundEqualizeHeights = this.equalizeHeights.bind(this)
this.carouselTarget.addEventListener("scroll", this.boundUpdate, { passive: true })
window.addEventListener("resize", this.boundEqualizeHeights)
this.equalizeHeights()
this.update()
}
disconnect() {
this.carouselTarget.removeEventListener("scroll", this.boundUpdate)
window.removeEventListener("resize", this.boundEqualizeHeights)
}
equalizeHeights() {
const articles = this.carouselTarget.querySelectorAll(":scope > li > article")
articles.forEach((article) => (article.style.height = "auto"))
const tallest = Array.from(articles).reduce((max, article) => Math.max(max, article.offsetHeight), 0)
articles.forEach((article) => (article.style.height = `${tallest}px`))
}
update() {
const slides = Array.from(this.carouselTarget.children)
const center = this.carouselTarget.scrollLeft + this.carouselTarget.clientWidth / 2
let activeIndex = 0
let closest = Infinity
slides.forEach((slide, i) => {
const slideCenter = slide.offsetLeft + slide.offsetWidth / 2
const distance = Math.abs(slideCenter - center)
if (distance < closest) {
closest = distance
activeIndex = i
}
})
this.markerTargets.forEach((marker, i) => {
marker.classList.toggle("current", i === activeIndex)
})
}
goto(event) {
const index = parseInt(event.currentTarget.dataset.index, 10)
const slide = this.carouselTarget.children[index]
if (slide) {
this.carouselTarget.scrollTo({ left: slide.offsetLeft, behavior: "smooth" })
}
}
}

+ 5
- 12
app/models/concerns/has_attachments.rb View File

@ -2,36 +2,29 @@ module HasAttachments
extend ActiveSupport::Concern
included do
has_many :attachments, -> { order :position }, dependent: :destroy, as: :attachable_for, class_name: "Attachment"
has_many :assets, through: :attachments
has_many :assets,
through: :attachments,
source: :asset
through: :attachments,
source: :asset
#has_many :images,
# has_many :images,
# -> { with_attached_file.where('assets.content_type LIKE ?', "image/%") },
# through: :attachments,
# source: :asset
#
#has_many :images_favorites_first,
#
# has_many :images_favorites_first,
# -> { with_attached_file.where('assets.content_type LIKE ?', "image/%").reorder('attachments.is_favorite DESC', 'attachments.position ASC') },
# through: :attachments,
# source: :asset
accepts_nested_attributes_for :attachments, allow_destroy: true
end
def icon
assets.find { |a| a.file.attached? && a.file.content_type&.start_with?("image/") }
end
end

+ 4
- 13
app/models/node.rb View File

@ -26,7 +26,8 @@ class Node < ApplicationRecord
enum :status, { status_published: 0, status_draft: 1, status_archived: 2 }
enum :template, {
start: 0
start: 0,
facts: 1
}
def available_templates
@ -34,19 +35,9 @@ class Node < ApplicationRecord
case depth
when 1
[ :stage, :game_over, :score ]
[ :facts ]
when 2
[ :choice, :chance, :bonus ]
when 3
if parent&.chance?
[ :good, :bad, :game_over, :bonus ]
elsif parent&.choice?
[ :best, :good, :bad ]
elsif parent&.bonus?
[ :good, :bad ]
else
[]
end
[]
else
[]
end


+ 0
- 3
app/views/admin/attachments/_attachment.html.erb View File

@ -10,11 +10,8 @@
locale: I18n.default_locale),
tmp_id: local_assigns[:tmp_id]
} %>
</div>
<%= button_tag 'delete',
type: 'button',


+ 8
- 38
app/views/layouts/application.html.erb View File

@ -17,47 +17,17 @@
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= stylesheet_link_tag "reset", "application" %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller] %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller] %>
</head>
<body>
<div data-controller="language-menu">
<header>
<%= link_to svg("ikea-foundation-203x22"), url_for(controller: "site", action: "index") %>
<button type="button"
class="language-button"
data-language-menu-target="button"
data-action="click->language-menu#toggle"
aria-controls="language-menu"
aria-expanded="false"
aria-label="<%= strip_tags t("game.please_choose_a_language") %>">
<%= svg "ico-globe" %>
<%= svg "ico-close" %>
</button>
</header>
<nav id="language-menu"
data-language-menu-target="menu"
aria-labelledby="language-title"
hidden>
<h2 id="language-title"><%= t("game.please_choose_a_language").html_safe %></h2>
<ul class="languages">
<% t("language_names").each do |k, v| %>
<% current = (k == I18n.locale) %>
<li><%= link_to v,
url_for(locale: k),
class: ("current" if current),
"aria-current": ("page" if current) %></li>
<% end%>
</ul>
</nav>
</div>
<body>
<%= content_for :header %>
<main>
<%= tag.main class: @node.template,
data: {
controller: content_for(:main_controller)
} do %>
<%= yield %>
</main>
<% end %>
</body>
</html>

+ 32
- 0
app/views/site/_language-menu.html.erb View File

@ -0,0 +1,32 @@
<div data-controller="language-menu">
<header>
<%= link_to svg("ikea-foundation-203x22"), url_for(controller: "site", action: "index") %>
<button type="button"
class="language-button"
data-language-menu-target="button"
data-action="click->language-menu#toggle"
aria-controls="language-menu"
aria-expanded="false"
aria-label="<%= strip_tags t("game.please_choose_a_language") %>">
<%= svg "ico-globe" %>
<%= svg "ico-close" %>
</button>
</header>
<nav id="language-menu"
data-language-menu-target="menu"
aria-labelledby="language-title"
hidden>
<h2 id="language-title"><%= t("game.please_choose_a_language").html_safe %></h2>
<ul class="languages">
<% t("language_names").each do |k, v| %>
<% current = (k == I18n.locale) %>
<li><%= link_to v,
url_for(locale: k),
class: ("current" if current),
"aria-current": ("page" if current) %></li>
<% end%>
</ul>
</nav>
</div>

+ 33
- 0
app/views/site/facts.html.erb View File

@ -0,0 +1,33 @@
<%- content_for :title, node_title(@node) %>
<%- content_for :main_controller, "carousel" %>
<ul class="carousel" data-carousel-target="carousel">
<% @node.attachments.each do |attachment| %>
<li>
<div class="hero-container">
<%= render_responsive_picture(attachment.asset, alt: "Hero", fetchpriority: "high") %>
<%= svg "ico-wave" %>
</div>
<article>
<%= attachment.body.html_safe %>
</article>
</li>
<% end %>
</ul>
<div class="scroll-marker-group" role="tablist">
<% @node.attachments.each_with_index do |_attachment, index| %>
<button type="button"
class="scroll-marker"
role="tab"
data-carousel-target="marker"
data-index="<%= index %>"
data-action="click->carousel#goto"
aria-label="<%= t("game.go_to_slide", index: index + 1, default: "Go to slide %{index}") %>"></button>
<% end %>
</div>
<article>
<%= link_to tag.span(t("game.got_it_lets_get_started")), {action: "facts"}, class: "cta" %>
</article>

+ 16
- 12
app/views/site/start.html.erb View File

@ -1,17 +1,21 @@
<%- content_for :title, node_title(@node) %>
<div class="intro-container">
<%= render_responsive_picture(@node.icon, alt: "Hero", fetchpriority: "high") %>
<%- content_for :header do %>
<%= render partial: "language-menu" %>
<% end %>
<article>
<%= tag.h1 t("game.intro_title").html_safe %>
<%= tag.p t("game.intro_description") %>
<div class="hero-container">
<%= render_responsive_picture(@node.icon, alt: "Hero", fetchpriority: "high") %>
<%= svg "ico-wave" %>
</div>
<%= 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 %>
</article>
<article>
<%= tag.h1 t("game.intro_title").html_safe %>
<%= tag.p t("game.intro_description") %>
</div>
<%= link_to tag.span(t("game.let_me_try")), {action: "facts"}, class: "cta" %>
<%= tag.div class: "play-time" do %>
<%= svg "ico-clock" %>
<%= tag.span t("game.play_time") %>
<% end %>
</article>

+ 1
- 0
config/importmap.rb View File

@ -15,3 +15,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 "language_menu_controller", preload: false
pin "carousel_controller", preload: false

+ 5
- 9
config/locales/en.yml View File

@ -13,6 +13,9 @@ en:
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
go_to_slide: Go to slide
got_it_lets_get_started: Got it, let’s get started
countries:
au: Australia
@ -269,15 +272,8 @@ en:
de: German
templates:
start: Start
stage: Stage
choice: Choice
chance: Chance
best: Best
bad: Bad
game_over: Game Over
good: Good
bonus: Bonus
score: Result page
facts: Facts
categories:
box: Box


+ 1
- 2
config/routes.rb View File

@ -58,9 +58,8 @@ Rails.application.routes.draw do
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 "facts", to: "site#facts"
get "", to: "site#index", as: :locale_root
end


Loading…
Cancel
Save