bingo: SEO pass and resized photos
All checks were successful
Build and Deploy to Production / test (push) Successful in 22s
Build and Deploy to Production / build (push) Successful in 1m1s
Build and Deploy to Production / deploy (push) Successful in 39s

The bingo host fell through to asxp.io's public/, so crawlers got
asxp.io's robots.txt pointing at asxp.io's sitemap (and the keys and
invoice JS were served too). Turn static files off for BingoWeb and give
it its own robots.txt and a sitemap with hreflang alternates.

The card loaded 15.6 MB of full-size photos as cell backgrounds. Derive
480/1200 px WebP variants with ImageMagick on store (memory/area limits,
input coder pinned to the sniffed type), render cells as <img> with lazy
loading past the first two rows, and add an admin action that rebuilds
variants from the stored originals.

Head gets per-locale titles and descriptions carrying the live score,
og:image from the latest hit, Twitter card, og:locale alternates,
JSON-LD WebSite + ItemList, and Vary: Accept-Language on the redirect.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 06:26:03 +04:00
commit 201a70b07b
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
11 changed files with 236 additions and 23 deletions

View file

@ -76,6 +76,10 @@
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">↻ Import all missing photos from their source URLs</button>
</form>
<form method="post" action="/admin/bingo/photos/rebuild">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">⟳ Rebuild resized photos from stored originals</button>
</form>
<form method="post" action="/admin/bingo/photos/import" onsubmit="return confirm('Re-download every photo that has a source URL? Manually uploaded photos with a source URL get replaced too.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<input type="hidden" name="all" value="1" />
@ -90,7 +94,7 @@
<% end %>
<tr>
<td class="mono"><%= dc.position %></td>
<td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td>
<td><% if dc.photo_file %><img src="/admin/bingo/p/<%= dc.photo_file(480) %>" alt="" width="64" height="40" style="object-fit: cover" /><% else %>—<% end %></td>
<td><%= dc.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td>
<td><%= dc.operator %></td>
<td class="num mono"><%= dc.mw %></td>

View file

@ -25,11 +25,11 @@
<div class="letters" aria-hidden="true"><% t(:letters).each do |ch| %><div><%= ch %></div><% end %></div>
<div class="grid">
<% b.cells.each_with_index do |dc, idx| %>
<% photo = photo_path(dc) %>
<% photo = photo_path(dc, 480) %>
<button type="button" data-dlg="dlg-<%= dc.slug %>"
class="cell<%= ' photo' if photo %><%= ' hit' if b.hit[idx] %><%= ' win' if b.win_cells.include?(idx) %>"
<% if photo %>style="background-image: url('<%= photo %>')"<% end %>
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 %>
<div>
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %></div>
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
@ -70,7 +70,7 @@
<% b.cells.each do |dc| %>
<dialog id="dlg-<%= dc.slug %>">
<% if (photo = photo_path(dc)) %><img src="<%= photo %>" alt="<%= dc.l(:name, @locale) %>" loading="lazy"><% end %>
<% 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">

View file

@ -1,20 +1,39 @@
<% base = "https://#{ENV.fetch('BINGO_HOST', request.host)}" %>
<% base = base_url %>
<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %>
<% og_img = og_photo(@board) %>
<!doctype html>
<html lang="<%= @locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= t(:title) %></title>
<meta name="description" content="<%= t(:desc) %>">
<title><%= t(:seo_title) %></title>
<meta name="description" content="<%= desc %>">
<meta name="robots" content="index, follow, max-image-preview:large">
<meta name="theme-color" content="#121212">
<link rel="canonical" href="<%= base %>/<%= @locale %>">
<% Bingo::LOCALES.each do |l| %>
<link rel="alternate" hreflang="<%= l %>" href="<%= base %>/<%= l %>">
<% 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:title" content="<%= t(:title) %>">
<meta property="og:description" content="<%= t(:desc) %>">
<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: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 %>">
<meta property="og:image:alt" content="<%= t(:title) %>">
<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>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎱</text></svg>">
<style>
:root {
@ -52,13 +71,14 @@
.grid { margin-top: 6px; }
.cell {
position: relative; aspect-ratio: 1; padding: 8px; overflow: hidden; width: 100%;
background: var(--card) center / cover no-repeat; border: 1px solid var(--line); border-radius: 8px;
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;
}
.cell.photo { color: var(--on-photo); border-color: transparent; }
.cell.photo::before { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
.cell > * { position: relative; z-index: 1; }
.cell .bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
.cell.photo::before { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, var(--shade), rgb(0 0 0 / .15) 70%); }
.cell > :not(.bg) { position: relative; z-index: 2; }
.cell .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.cell.photo .n, .cell.photo .sub { color: var(--on-photo-muted); }
.cell .name { font-weight: 700; font-size: clamp(11px, 1.9vw, 15px); line-height: 1.15; }
@ -67,12 +87,12 @@
.cell.hit { border-color: var(--hit); }
.cell.hit:not(.photo) { background-color: var(--hit-bg); }
.cell.hit::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; opacity: .8;
content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: .8;
background:
linear-gradient(to top right, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px)),
linear-gradient(to top left, transparent calc(50% - 2px), var(--hit) calc(50% - 2px), var(--hit) calc(50% + 2px), transparent calc(50% + 2px));
}
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 3; }
.cell .stamp { position: absolute; right: 6px; top: 4px; font-size: clamp(16px, 3vw, 24px); z-index: 4; }
.cell.win { box-shadow: 0 0 0 3px var(--win); }
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

12
views/bingo/sitemap.erb Normal file
View file

@ -0,0 +1,12 @@
<?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">
<% Bingo::LOCALES.each do |l| %>
<url>
<loc><%= base_url %>/<%= l %></loc>
<% if @lastmod %> <lastmod><%= @lastmod.utc.strftime('%Y-%m-%d') %></lastmod>
<% end %> <changefreq>daily</changefreq>
<% 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 %>
</urlset>