bingo: serve datacenter bingo on bingo.asxp.io
Some checks failed
Tests / test (push) Successful in 18s
Build and Deploy to Production / test (push) Successful in 18s
Build and Deploy to Production / build (push) Failing after 2s
Build and Deploy to Production / deploy (push) Has been skipped

Add a second public site to the same process: a 5x5 card of the 25
largest Russian datacenters where a cell is crossed out once an incident
with a public source is recorded (Yandex Sasovo and Kaluga drone strikes
on 2026-10-08/09 are seeded). config.ru dispatches by Host, so it rides
the existing container, DB and bucket instead of a new deploy.

The card is localized ru/en/uk with per-locale columns falling back to
ru. Content is managed from /admin/bingo on asxp.io, reusing AdminAuth
and CSRF. Photos go to the existing bucket under bingo/ and are proxied
by the app, so the bucket stays private and storage needs no change.

The seed data was fact-checked against operator sites and press on
2026-10-10; several entries in the original list were wrong or vague.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 04:51:26 +04:00
commit bdb3682e18
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
19 changed files with 1428 additions and 2 deletions

View file

@ -0,0 +1,52 @@
<div class="header">
<h1>#<%= @dc.position %> <%= @dc.name_ru %></h1>
<p class="tagline"><a href="/admin/bingo">← bingo</a></p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<section class="section">
<h2>Photo</h2>
<% if @dc.photo_file %>
<p><img src="/admin/bingo/p/<%= @dc.photo_file %>" alt="" style="max-width: 100%; max-height: 280px" /></p>
<form method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo/delete">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">remove from card</button>
</form>
<% end %>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>/photo" enctype="multipart/form-data">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="field"><label for="photo">Upload <span class="optional">(JPEG/PNG/WebP, ≤ 8 MB)</span></label><input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" /></div>
<div class="field"><label for="photo_url">…or import from URL <span class="optional">(blank = saved source URL)</span></label><input type="url" id="photo_url" name="url" placeholder="<%= @dc.photo_source_url %>" /></div>
<button type="submit" class="download-button">Store photo</button>
</form>
</section>
<section class="section">
<h2>Details</h2>
<form class="invoice-form" method="post" action="/admin/bingo/dc/<%= @dc.id %>" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="invoice-grid">
<% %w[ru en uk].each do |l| %>
<div class="field"><label for="name_<%= l %>">Name (<%= l %>)</label><input type="text" id="name_<%= l %>" name="name_<%= l %>" value="<%= @dc[:"name_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
<% end %>
<% %w[ru en uk].each do |l| %>
<div class="field"><label for="city_<%= l %>">City (<%= l %>)</label><input type="text" id="city_<%= l %>" name="city_<%= l %>" value="<%= @dc[:"city_#{l}"] %>" <%= 'required' if l == 'ru' %> /></div>
<% end %>
<div class="field"><label for="operator">Operator</label><input type="text" id="operator" name="operator" value="<%= @dc.operator %>" required /></div>
<div class="field"><label for="mw">MW <span class="optional">(number or range)</span></label><input type="text" id="mw" name="mw" value="<%= @dc.mw %>" /></div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<% %w[operational building].each do |s| %><option <%= 'selected' if @dc.status == s %>><%= s %></option><% end %>
</select>
</div>
<div class="field"><label for="year">Year</label><input type="text" id="year" name="year" value="<%= @dc.year %>" inputmode="numeric" /></div>
<div class="field"><label for="position">Position (1–25)</label><input type="text" id="position" name="position" value="<%= @dc.position %>" inputmode="numeric" /></div>
</div>
<div class="field"><label for="photo_credit">Photo credit</label><input type="text" id="photo_credit" name="photo_credit" value="<%= @dc.photo_credit %>" /></div>
<div class="field"><label for="photo_source_url">Photo source URL</label><input type="url" id="photo_source_url" name="photo_source_url" value="<%= @dc.photo_source_url %>" /></div>
<div class="field"><label for="info_url">Info / fact source URL</label><input type="url" id="info_url" name="info_url" value="<%= @dc.info_url %>" /></div>
<button type="submit" class="download-button">Save</button>
</form>
</section>

View file

@ -0,0 +1,91 @@
<div class="header">
<h1>Bingo</h1>
<p class="tagline">
<a href="/admin/invoices">invoices</a>
<% if bingo_public_url %> · <a href="<%= bingo_public_url %>" target="_blank" rel="noopener">public card ↗</a><% end %>
</p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<section class="section">
<h2>Record incident</h2>
<form class="invoice-form" method="post" action="/admin/bingo/incidents" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<div class="invoice-grid">
<div class="field">
<label for="datacenter_id">Datacenter</label>
<select id="datacenter_id" name="datacenter_id" required>
<% @dcs.each do |dc| %><option value="<%= dc.id %>">#<%= dc.position %> <%= dc.name_ru %></option><% end %>
</select>
</div>
<div class="field">
<label for="occurred_on">Date</label>
<input type="date" id="occurred_on" name="occurred_on" value="<%= Date.today %>" required />
</div>
<div class="field">
<label for="kind">Kind</label>
<select id="kind" name="kind">
<% Bingo::KINDS.each do |k| %><option value="<%= k %>"><%= Bingo::ICONS[k] %> <%= k %></option><% end %>
</select>
</div>
</div>
<div class="field"><label for="title_ru">Title (ru)</label><input type="text" id="title_ru" name="title_ru" required maxlength="300" /></div>
<div class="field"><label for="title_en">Title (en) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_en" name="title_en" maxlength="300" /></div>
<div class="field"><label for="title_uk">Title (uk) <span class="optional">(falls back to ru)</span></label><input type="text" id="title_uk" name="title_uk" maxlength="300" /></div>
<div class="field"><label for="url">Source URL</label><input type="url" id="url" name="url" placeholder="https://" /></div>
<button type="submit" class="download-button">Cross it out</button>
</form>
</section>
<section class="section">
<h2>Incidents</h2>
<% if @incidents.empty? %>
<p>None yet.</p>
<% else %>
<table class="invoice-list">
<thead><tr><th>Date</th><th>Datacenter</th><th>Kind</th><th>Title</th><th></th></tr></thead>
<tbody>
<% @incidents.each do |i| %>
<tr>
<td class="mono"><%= i.occurred_on %></td>
<td><%= i.datacenter.name_ru %></td>
<td><%= Bingo::ICONS[i.kind] %> <%= i.kind %></td>
<td><% if i.url %><a href="<%= i.url %>" target="_blank" rel="noopener"><%= i.title_ru %></a><% else %><%= i.title_ru %><% end %></td>
<td class="actions">
<form method="post" action="/admin/bingo/incidents/<%= i.id %>/delete" onsubmit="return confirm('Delete this incident?')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">delete</button>
</form>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</section>
<section class="section">
<h2>Card</h2>
<form method="post" action="/admin/bingo/photos/import">
<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>
<table class="invoice-list">
<thead><tr><th>#</th><th>Photo</th><th>Name</th><th>Operator</th><th class="num">MW</th><th>Status</th><th class="num">Incidents</th><th></th></tr></thead>
<tbody>
<% @dcs.each do |dc| %>
<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><%= dc.name_ru %><br /><span class="optional"><%= dc.city_ru %></span></td>
<td><%= dc.operator %></td>
<td class="num mono"><%= dc.mw %></td>
<td><%= dc.status %></td>
<td class="num mono"><%= dc.incidents.size %></td>
<td class="actions"><a href="/admin/bingo/dc/<%= dc.id %>">edit</a></td>
</tr>
<% end %>
</tbody>
</table>
</section>

View file

@ -1,6 +1,6 @@
<div class="header">
<h1>Invoices</h1>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p>
<p class="tagline"><a href="/admin/invoices/new">+ New invoice</a> · <a href="/admin/bingo">bingo</a></p>
</div>
<% if @invoices.empty? %>

100
views/bingo/index.erb Normal file
View file

@ -0,0 +1,100 @@
<% b = @board %>
<main>
<header>
<h1><%= t(:lead) %> <span><%= t(:accent) %></span></h1>
<nav class="langs">
<% Bingo::LOCALES.each do |l| %>
<a href="/<%= l %>" hreflang="<%= l %>" lang="<%= l %>" <%== 'aria-current="page"' if l == @locale %>><%= l %></a>
<% end %>
</nav>
</header>
<p class="lead"><%= t(:desc) %></p>
<p class="meta">
<%= t(:crossed) %> <b><%= b.hit.count(true) %></b>/<%= b.cells.size %>
· <%= t(:lines) %> <b><%= b.wins.size %></b>
<% if b.updated_on %>· <%= t(:updated) %> <b><%= fmt_date(b.updated_on) %></b><% end %>
</p>
<% if b.wins.any? %>
<div class="banner">
<%= b.wins.size == Bingo::LINES.size ? t(:full) : b.wins.size > 1 ? format(t(:bingo_n), b.wins.size) : t(:bingo) %>
</div>
<% end %>
<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) %>
<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] %>">
<div>
<div class="n">#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %></div>
<% if dc.building? %><div class="tag"><%= t(:building) %></div><% end %>
</div>
<div>
<div class="name"><%= dc.l(:name, @locale) %></div>
<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>
<% end %>
</div>
<section>
<h2><%= t(:log) %></h2>
<ul class="log">
<% if b.incidents.empty? %>
<li class="empty"><%= t(:empty) %></li>
<% end %>
<% b.incidents.each do |i| %>
<li>
<time datetime="<%= i.occurred_on %>"><%= fmt_date(i.occurred_on) %></time>
<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>
</li>
<% end %>
</ul>
</section>
<footer><%= t(:footer) %></footer>
</main>
<% 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 %>
<div class="body">
<h3><%= dc.l(:name, @locale) %></h3>
<div class="meta">
<%= [dc.operator, mw(dc), dc.l(:city, @locale), dc.building? ? t(:building) : (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 %>
<% 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()));
</script>

107
views/bingo/layout.erb Normal file
View file

@ -0,0 +1,107 @@
<% base = "https://#{ENV.fetch('BINGO_HOST', request.host)}" %>
<!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) %>">
<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 %>/">
<meta property="og:type" content="website">
<meta property="og:title" content="<%= t(:title) %>">
<meta property="og:description" content="<%= t(:desc) %>">
<meta property="og:url" content="<%= base %>/<%= @locale %>">
<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 {
--bg: #f4f1ea; --card: #fffdf8; --ink: #1d1b18; --muted: #6f6a60; --line: #d9d3c6;
--hit: #c8361d; --hit-bg: #fbe3dc; --win: #e7b416; --on-win: #1d1b18;
--shade: rgb(0 0 0 / .62); --on-photo: #fff; --on-photo-muted: rgb(255 255 255 / .75);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
}
}
:root[data-theme="dark"] {
--bg: #121212; --card: #1c1b19; --ink: #ece7dc; --muted: #9a9384; --line: #34312b;
--hit: #ff6a4d; --hit-bg: #3a1d16; --win: #f0c43a;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 48px; }
a { color: inherit; }
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-bottom: 16px; }
h1 { margin: 0; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.02em; }
h1 span { color: var(--hit); }
.langs { display: flex; gap: 4px; }
.langs a { padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; font-size: 13px; text-transform: uppercase; }
.langs a[aria-current] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.meta { color: var(--muted); font-size: 14px; }
.meta b { color: var(--ink); font-variant-numeric: tabular-nums; }
.lead { color: var(--muted); margin: 0 0 16px; max-width: 60ch; }
.banner { margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; background: var(--win); color: var(--on-win); font-weight: 700; font-size: 18px; }
.letters, .grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.letters div { text-align: center; font-weight: 900; font-size: clamp(22px, 5vw, 36px); color: var(--hit); }
.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;
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 .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; }
.cell .sub { font-size: clamp(10px, 1.4vw, 12px); color: var(--muted); }
.cell .tag { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
.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;
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.win { box-shadow: 0 0 0 3px var(--win); }
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
section { margin-top: 32px; }
h2 { font-size: 18px; margin: 0 0 12px; }
.log { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.log li { display: grid; grid-template-columns: 96px 28px 1fr; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.log time, .log .dc { color: var(--muted); }
.log .dc { font-size: 13px; }
.empty { color: var(--muted); }
dialog { width: min(520px, calc(100vw - 32px)); border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--ink); padding: 0; }
dialog::backdrop { background: rgb(0 0 0 / .55); }
dialog img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
dialog .body { padding: 16px 20px 20px; }
dialog h3 { margin: 0 0 4px; }
dialog .credit { font-size: 12px; color: var(--muted); margin-top: 4px; }
dialog ul { padding-left: 18px; }
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; }
@media (max-width: 560px) {
.cell { padding: 5px; border-radius: 6px; }
.cell .sub, .cell .n, .cell .tag { display: none; }
.letters, .grid { gap: 4px; }
.log li { grid-template-columns: 84px 24px 1fr; }
}
</style>
</head>
<body>
<%== yield %>
</body>
</html>