bingo: a page per datacenter

Everything lived on one URL per language, with each datacenter's
details and incidents inside a <dialog>. That page can rank for the
brand and a couple of head terms, but not for searches like
"Yandex Sasovo drone strike".

Add /:locale/dc/:slug for every datacenter, reserve included. Each page
has its own title (naming the latest incident when there is one), a
description built from the facts, canonical and hreflang links, the
photo as og:image, and JSON-LD Place + BreadcrumbList. It shows the
facts in a sentence or two, the incident list with sources, and
prev/next links by position. The slug never changes, so the URLs are
stable.

The card cells are now links to these pages. A plain click still opens
the dialog, while modified clicks open the page. The dialog and the
incident log link to the page too, and the sitemap lists every page in
all three languages. The layout takes title, description, alternates
and JSON-LD from the route, and the dialog and the page share the meta
and incident partials.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 09:40:16 +04:00
commit feac965d02
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
9 changed files with 253 additions and 53 deletions

View file

@ -28,6 +28,8 @@ class BingoWeb < Sinatra::Base
view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}"
end
def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}"
def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}"
# WebSite + ItemList of the card's datacenters, for rich results.
@ -54,7 +56,8 @@ class BingoWeb < Sinatra::Base
# Incidents are Events at the place.
def ld_place(dc)
address = { '@type' => 'PostalAddress', 'addressLocality' => dc.l(:city, @locale), 'addressCountry' => 'RU' }
place = { '@type' => 'Place', 'name' => dc.l(:name, @locale), 'address' => address }
url = "#{base_url}#{dc_path(dc)}"
place = { '@type' => 'Place', '@id' => "#{url}#place", 'url' => url, 'name' => dc.l(:name, @locale), 'address' => address }
place['image'] = "#{base_url}#{photo_path(dc, 1200)}" if dc.photo_key
place['subjectOf'] = { '@type' => 'CreativeWork', 'url' => dc.info_url } if dc.info_url
events = dc.incidents.map do |i|
@ -66,6 +69,38 @@ class BingoWeb < Sinatra::Base
place
end
# Datacenter page: the Place plus a breadcrumb back to the card.
def dc_json_ld(dc)
JSON.generate(
'@context' => 'https://schema.org',
'@graph' => [
ld_place(dc),
{ '@type' => 'BreadcrumbList', 'itemListElement' => [
{ '@type' => 'ListItem', 'position' => 1, 'name' => t(:title), 'item' => "#{base_url}/#{@locale}" },
{ '@type' => 'ListItem', 'position' => 2, 'name' => dc.l(:name, @locale), 'item' => "#{base_url}#{dc_path(dc)}" }
] }
]
).gsub('</', '<\/')
end
# Plain-language facts for the datacenter page and its meta description.
def dc_facts(dc)
[format(t(:dc_intro), name: dc.l(:name, @locale), city: dc.l(:city, @locale), operator: dc.operator),
(format(t(:dc_capacity), mw: mw(dc)) if mw(dc)),
{ 'building' => t(:dc_building), 'planned' => t(:dc_planned) }[dc.status] || (dc.year && format(t(:dc_since), dc.year)),
format(t(:dc_rank), pos: dc.position)].compact
end
def dc_latest(dc)
i = dc.incidents.first or return t(:no_incidents)
format(t(:dc_latest), date: fmt_date(i.occurred_on), title: i.l(:title, @locale))
end
# Cut at a word boundary to fit a search snippet.
def snippet(text, max = 160)
text.length <= max ? text : "#{text[0, max - 1][/\A.*(?=\s)/m] || text[0, max - 1]}…"
end
# Preview image: the most recent hit with a photo, else the first photo.
def og_dc(board)
board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
@ -105,27 +140,54 @@ class BingoWeb < Sinatra::Base
"User-agent: *\nAllow: /\n\nSitemap: #{base_url}/sitemap.xml\n"
end
# The card and every datacenter page (reserve included), each with its
# language alternates.
get '/sitemap.xml' do
content_type 'application/xml'
cache_control :public, max_age: 3600
@lastmod = Bingo::Board.load.updated_on
dcs = Bingo::Datacenter.order(:position, :id).eager(:incidents).all
@entries = [[->(l) { "/#{l}" }, Bingo::Board.load.updated_on, '/']] +
dcs.map { |dc| [->(l) { dc_path(dc, l) }, [dc.updated_at, *dc.incidents.map(&:updated_at)].compact.max] }
erb :sitemap, layout: false
end
# One URL per page: /en/ is a duplicate of /en.
get %r{/(ru|en|uk)/} do |locale|
get %r{/((?:ru|en|uk)(?:/dc/[a-z0-9-]+)?)/} do |path|
qs = request.query_string
redirect "/#{locale}#{"?#{qs}" unless qs.empty?}", 301
redirect "/#{path}#{"?#{qs}" unless qs.empty?}", 301
end
get %r{/(ru|en|uk)} do |locale|
@locale = locale
@view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all'
@board = Bingo::Board.load(@view)
@title = t(:seo_title)
@desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size)
@noindex = @board.operational_only?
@path_for = ->(l) { "/#{l}" }
@x_default = '/'
@og = og_dc(@board)
@ld = json_ld(@board, @desc)
cache_control :public, max_age: 60
erb :index
end
get %r{/(ru|en|uk)/dc/([a-z0-9-]+)} do |locale, slug|
@locale = locale
@dc = Bingo::Datacenter.where(slug: slug).eager(:incidents).first or halt 404
@prev, @next = [-1, 1].map { |d| Bingo::Datacenter.first(position: @dc.position + d) }
name, city = @dc.l(:name, locale), @dc.l(:city, locale)
hit = @dc.incidents.first
@title = hit ? format(t(:dc_title_hit), name: name, city: city, kind: kind_label(hit.kind), date: fmt_date(hit.occurred_on))
: format(t(:dc_title), name: name, city: city)
@desc = snippet([*dc_facts(@dc).first(2), dc_latest(@dc)].join(' '))
@path_for = ->(l) { dc_path(@dc, l) }
@og = @dc if @dc.photo_key
@ld = dc_json_ld(@dc)
cache_control :public, max_age: 60
erb :dc
end
# Icons from assets/bingo/ (public/ is asxp.io's). Search engines want
# a crawlable favicon URL; a data: URI doesn't count.
ICONS = { 'favicon.svg' => 'image/svg+xml', 'favicon.ico' => 'image/x-icon',

View file

@ -40,7 +40,15 @@ module Bingo
footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' },
letters: %w[Б И Н Г О],
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать'
view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать',
dc_title: '%{name} (%{city}) — инциденты ЦОДа | Бинго ЦОДов',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бинго ЦОДов',
dc_intro: '%{name} — дата-центр в городе %{city}, оператор — %{operator}.',
dc_capacity: 'Мощность: %{mw}.', dc_building: 'Строится.', dc_planned: 'Планируется.',
dc_since: 'Работает с %d года.',
dc_rank: '№ %{pos} по мощности среди крупнейших дата-центров России.',
dc_latest: 'Последний инцидент (%{date}): %{title}', incidents: 'Инциденты',
back: '← К карточке', dc_page: 'Страница ЦОДа →'
},
'en' => {
seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters',
@ -59,7 +67,15 @@ module Bingo
footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.',
kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' },
letters: %w[B I N G O],
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show'
view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show',
dc_title: '%{name} (%{city}) — datacenter incidents | Datacenter Bingo',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Datacenter Bingo',
dc_intro: '%{name} is a datacenter in %{city} operated by %{operator}.',
dc_capacity: 'Capacity: %{mw}.', dc_building: 'Under construction.', dc_planned: 'Planned.',
dc_since: 'In service since %d.',
dc_rank: "No. %{pos} by power among Russia's largest datacenters.",
dc_latest: 'Latest reported incident (%{date}): %{title}', incidents: 'Incidents',
back: '← Back to the card', dc_page: 'Datacenter page →'
},
'uk' => {
seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії',
@ -78,7 +94,15 @@ module Bingo
footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.',
kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' },
letters: %w[Б І Н Г О],
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати'
view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати',
dc_title: '%{name} (%{city}) — інциденти ЦОДу | Бінго ЦОДів',
dc_title_hit: '%{name} (%{city}) — %{kind}, %{date} | Бінго ЦОДів',
dc_intro: '%{name} — дата-центр у місті %{city}, оператор — %{operator}.',
dc_capacity: 'Потужність: %{mw}.', dc_building: 'Будується.', dc_planned: 'Планується.',
dc_since: 'Працює з %d року.',
dc_rank: '№ %{pos} за потужністю серед найбільших дата-центрів Росії.',
dc_latest: 'Останній інцидент (%{date}): %{title}', incidents: 'Інциденти',
back: '← До картки', dc_page: 'Сторінка ЦОДу →'
}
}.freeze

View file

@ -174,6 +174,9 @@ class BingoTest < Minitest::Test
get '/sitemap.xml'
assert last_response.ok?
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}/dc/dc-25</loc>" }
assert_includes last_response.body, '<xhtml:link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-7"/>'
assert_equal 3, last_response.body.scan('hreflang="x-default"').size # card URLs only
assert_includes last_response.body, 'hreflang="x-default"'
refute_includes last_response.body, 'changefreq'
assert_match %r{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body
@ -222,6 +225,59 @@ class BingoTest < Minitest::Test
assert_equal 'nosniff', last_response.headers['x-content-type-options']
end
def test_dc_page
hit!(3)
get '/en/dc/dc-4'
assert last_response.ok?
body = last_response.body
assert_includes body, "<title>ЦОД 4 (Moscow) — Outage, #{Date.today.strftime('%d.%m.%Y')} | Datacenter Bingo</title>"
assert_includes body, '<link rel="canonical" href="https://example.org/en/dc/dc-4">'
assert_includes body, '<link rel="alternate" hreflang="uk" href="https://example.org/uk/dc/dc-4">'
refute_includes body, 'x-default'
assert_includes body, '<h1>ЦОД 4</h1>'
assert_includes body, 'ЦОД 4 is a datacenter in Moscow operated by Op. Capacity: 4 MW.'
assert_includes body, 'No. 4 by power'
assert_includes body, 'href="/en/dc/dc-3" rel="prev"'
assert_includes body, 'href="/en/dc/dc-5" rel="next"'
assert_match(/<meta name="description" content="[^"]*Latest reported incident/, body)
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
place = ld['@graph'].find { |n| n['@type'] == 'Place' }
assert_equal 'https://example.org/en/dc/dc-4', place['url']
assert_equal 'Outage: Авария 3', place['event'][0]['name']
crumbs = ld['@graph'].find { |n| n['@type'] == 'BreadcrumbList' }
assert_equal 'https://example.org/en', crumbs['itemListElement'][0]['item']
get '/uk/dc/dc-1'
assert_includes last_response.body, '<title>ЦОД 1 (Москва) — інциденти ЦОДу | Бінго ЦОДів</title>'
refute_includes last_response.body, 'rel="prev"'
end
def test_reserve_dc_has_a_page
Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Тверь', operator: 'Op')
get '/ru/dc/dc-26'
assert last_response.ok?
assert_includes last_response.body, 'href="/ru/dc/dc-25" rel="prev"'
get '/sitemap.xml'
assert_includes last_response.body, '<loc>https://example.org/ru/dc/dc-26</loc>'
end
def test_dc_page_unknown_and_trailing_slash
get '/en/dc/nope'
assert_equal 404, last_response.status
get '/en/dc/dc-1/'
assert_equal 301, last_response.status
assert_equal 'http://example.org/en/dc/dc-1', last_response.location
end
def test_card_links_to_dc_pages
hit!(0)
get '/en'
body = last_response.body
assert_includes body, '<a href="/en/dc/dc-1" data-dlg="dlg-dc-1"'
assert_includes body, '<a href="/en/dc/dc-2">Datacenter page →</a>'
assert_includes body, '<div class="dc"><a href="/en/dc/dc-1">DC 1</a>'
end
def test_root_varies_on_language
get '/'
assert_equal 'Accept-Language', last_response.headers['Vary']

View file

@ -0,0 +1,10 @@
<% if dc.incidents.empty? %>
<p class="empty"><%= t(:no_incidents) %></p>
<% else %>
<ul>
<% dc.incidents.each do |i| %>
<li><%= kind_icon(i.kind) %> <time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time> —
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %></li>
<% end %>
</ul>
<% end %>

9
views/bingo/_dc_meta.erb Normal file
View file

@ -0,0 +1,9 @@
<div class="meta">
<%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
</div>
<% if dc.photo_credit || dc.info_url %>
<div class="credit">
<% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %>
<% if dc.info_url %> · <a href="<%= dc.info_url %>" target="_blank" rel="noopener nofollow"><%= t(:source) %></a><% end %>
</div>
<% end %>

33
views/bingo/dc.erb Normal file
View file

@ -0,0 +1,33 @@
<% dc = @dc %>
<main>
<header>
<nav class="crumbs"><a href="/<%= @locale %>"><%= t(:title) %></a></nav>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="<%= @path_for.(l) %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
</header>
<article class="dc-page<%= ' hit' if dc.incidents.any? %>">
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" decoding="async"><% end %>
<h1><%= dc.l(:name, @locale) %></h1>
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
<p class="facts"><%= dc_facts(dc).join(' ') %></p>
<section>
<h2><%= t(:incidents) %></h2>
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
</section>
</article>
<nav class="pager">
<% if @prev %><a href="<%= dc_path(@prev) %>" rel="prev">← #<%= @prev.position %> <%= @prev.l(:name, @locale) %></a><% else %><span></span><% end %>
<% if @next %><a href="<%= dc_path(@next) %>" rel="next">#<%= @next.position %> <%= @next.l(:name, @locale) %> →</a><% end %>
</nav>
<p><a href="/<%= @locale %>"><%= t(:back) %></a></p>
<footer>
<p><%= t(:footer) %></p>
<p>© <%= Date.today.year %> <a href="https://asxp.io">asxp.io</a></p>
</footer>
</main>

View file

@ -33,7 +33,7 @@
<div class="grid">
<% b.cells.each_with_index do |dc, idx| %>
<% photo = photo_path(dc, 480) %>
<button type="button" data-dlg="dlg-<%= dc.slug %>"
<a href="<%= dc_path(dc) %>" data-dlg="dlg-<%= dc.slug %>"
class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>"
aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>">
<% if photo %><img class="bg" src="<%= photo %>" alt="" width="480" height="480" decoding="async"<%== ' loading="lazy"' if idx >= 10 %>><% end %>
@ -46,7 +46,7 @@
<div class="sub"><%= dc.l(:city, @locale) %></div>
</div>
<% if b.hit[idx] %><div class="stamp"><%= kind_icon(dc.incidents.first.kind) %></div><% end %>
</button>
</a>
<% end %>
</div>
@ -62,7 +62,7 @@
<span><%= kind_icon(i.kind) %></span>
<div>
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %>
<div class="dc"><%= i.datacenter.l(:name, @locale) %> · <%= kind_label(i.kind) %></div>
<div class="dc"><a href="<%= dc_path(i.datacenter) %>"><%= i.datacenter.l(:name, @locale) %></a> · <%= kind_label(i.kind) %></div>
</div>
</li>
<% end %>
@ -80,31 +80,22 @@
<% if (photo = photo_path(dc, 1200)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>, <%= dc.l(:city, @locale) %>" loading="lazy" decoding="async"><% end %>
<div class="body">
<h3><%= dc.l(:name, @locale) %></h3>
<div class="meta">
<%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
<%== erb :_dc_meta, layout: false, locals: { dc: dc } %>
<%== erb :_dc_incidents, layout: false, locals: { dc: dc } %>
<div class="actions">
<a href="<%= dc_path(dc) %>"><%= t(:dc_page) %></a>
<form method="dialog"><button><%= t(:close) %></button></form>
</div>
<% if dc.photo_credit || dc.info_url %>
<div class="credit">
<% if dc.photo_credit %><%= t(:photo) %>: <%= dc.photo_credit %><% end %>
<% if dc.info_url %> · <a href="<%= dc.info_url %>" target="_blank" rel="noopener nofollow"><%= t(:source) %></a><% end %>
</div>
<% end %>
<% if dc.incidents.empty? %>
<p class="empty"><%= t(:no_incidents) %></p>
<% else %>
<ul>
<% dc.incidents.each do |i| %>
<li><%= kind_icon(i.kind) %> <%= fmt_date(i.occurred_on) %> —
<% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener nofollow"><%= i.l(:title, @locale) %></a><% else %><%= i.l(:title, @locale) %><% end %></li>
<% end %>
</ul>
<% end %>
<form method="dialog"><button><%= t(:close) %></button></form>
</div>
</dialog>
<% end %>
<script>
document.querySelectorAll('[data-dlg]').forEach(b =>
b.addEventListener('click', () => document.getElementById(b.dataset.dlg).showModal()));
// Cells are links to the datacenter pages; a plain click opens the dialog
// instead, modified clicks (new tab etc.) keep the link.
document.querySelectorAll('[data-dlg]').forEach(a => a.addEventListener('click', e => {
if (e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
document.getElementById(a.dataset.dlg).showModal();
}));
</script>

View file

@ -1,45 +1,45 @@
<% base = base_url %>
<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %>
<% og = og_dc(@board) %>
<% og_img = og && "#{base}/p/#{og.og_file}" %>
<% og_img = @og && "#{base}/p/#{@og.og_file}" %>
<!doctype html>
<html lang="<%= @locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= t(:seo_title) %></title>
<meta name="description" content="<%= desc %>">
<meta name="robots" content="<%= @board.operational_only? ? 'noindex, follow' : 'index, follow, max-image-preview:large' %>">
<title><%= @title %></title>
<meta name="description" content="<%= @desc %>">
<meta name="robots" content="<%= @noindex ? 'noindex, follow' : 'index, follow, max-image-preview:large' %>">
<meta name="theme-color" content="#121212">
<link rel="canonical" href="<%= base %>/<%= @locale %>">
<link rel="canonical" href="<%= base %><%= @path_for.(@locale) %>">
<% Bingo::LOCALES.each do |l| %>
<link rel="alternate" hreflang="<%= l %>" href="<%= base %>/<%= l %>">
<link rel="alternate" hreflang="<%= l %>" href="<%= base %><%= @path_for.(l) %>">
<% end %>
<% if @x_default %>
<link rel="alternate" hreflang="x-default" href="<%= base %><%= @x_default %>">
<% end %>
<link rel="alternate" hreflang="x-default" href="<%= base %>/">
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<%= t(:title) %>">
<meta property="og:title" content="<%= t(:seo_title) %>">
<meta property="og:description" content="<%= desc %>">
<meta property="og:url" content="<%= base %>/<%= @locale %>">
<meta property="og:title" content="<%= @title %>">
<meta property="og:description" content="<%= @desc %>">
<meta property="og:url" content="<%= base %><%= @path_for.(@locale) %>">
<meta property="og:locale" content="<%= t(:og_locale) %>">
<% (Bingo::LOCALES - [@locale]).each do |l| %>
<meta property="og:locale:alternate" content="<%= Bingo::T[l][:og_locale] %>">
<% end %>
<% if og_img %>
<meta property="og:image" content="<%= og_img %>">
<% if og.photo_og %>
<% if @og.photo_og %>
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="<%= Bingo::Photo::OG_SIZE[0] %>">
<meta property="og:image:height" content="<%= Bingo::Photo::OG_SIZE[1] %>">
<% end %>
<meta property="og:image:alt" content="<%= og.l(:name, @locale) %>, <%= og.l(:city, @locale) %>">
<meta property="og:image:alt" content="<%= @og.l(:name, @locale) %>, <%= @og.l(:city, @locale) %>">
<meta name="twitter:image" content="<%= og_img %>">
<% end %>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= t(:seo_title) %>">
<meta name="twitter:description" content="<%= desc %>">
<script type="application/ld+json"><%== json_ld(@board, desc) %></script>
<meta name="twitter:title" content="<%= @title %>">
<meta name="twitter:description" content="<%= @desc %>">
<script type="application/ld+json"><%== @ld %></script>
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@ -87,7 +87,7 @@
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
background: var(--card); border: 1px solid var(--line); border-radius: 8px;
display: flex; flex-direction: column; justify-content: space-between;
text-align: left; color: inherit; font: inherit; cursor: pointer;
text-align: left; color: inherit; font: inherit; text-decoration: none; cursor: pointer;
}
.cell.photo { color: var(--on-photo); border-color: transparent; }
.cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
@ -125,6 +125,17 @@
dialog h3 { margin: 0 0 4px; }
dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
dialog ul { padding-left: 18px; }
dialog .actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; }
dialog form { margin: 0; }
.crumbs a { font-weight: 700; text-decoration: none; }
.crumbs a:hover { text-decoration: underline; }
.dc-page img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; margin-bottom: 16px; }
.dc-page h1 { font-size: clamp(26px, 5vw, 38px); }
.dc-page.hit h1 { color: var(--hit); }
.dc-page .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
.dc-page .facts { max-width: 65ch; }
.dc-page ul { padding-left: 18px; }
.pager { display: flex; justify-content: space-between; gap: 12px; margin-top: 32px; padding-top: 12px; border-top: 1px solid var(--line); }
dialog button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); cursor: pointer; }
footer { margin-top: 32px; color: var(--muted); font-size: 13px; }
footer p { margin: 0 0 4px; }

View file

@ -1,12 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<% @entries.each do |path_for, lastmod, x_default| %>
<% Bingo::LOCALES.each do |l| %>
<url>
<loc><%= base_url %>/<%= l %></loc>
<% if @lastmod %> <lastmod><%= @lastmod.utc.iso8601 %></lastmod>
<loc><%= base_url %><%= path_for.(l) %></loc>
<% if lastmod %> <lastmod><%= lastmod.utc.iso8601 %></lastmod>
<% end %>
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %><%= path_for.(alt) %>"/>
<% end %>
<% if x_default %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %><%= x_default %>"/>
<% end %>
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %>/<%= alt %>"/>
<% end %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %>/"/>
</url>
<% end %>
<% end %>
</urlset>