Add admin role with an admin-only hand range study page
- Optional ADMIN_KEY (6 digits, distinct from ACCESS_KEY): entering it on the key screen signs in as admin (role-specific HMAC cookie; member cookies stay valid). Logged-in members can open /key?admin to enter it. - /range (range chart + color/category quiz, ported from poker-hand and restyled to the site design) and its files under admin/ are served only to admins; everyone else gets 404. Card images cached privately for a day. - Lobby shows a link to the page for admins only (GET /api/me). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -0,0 +1,82 @@
|
||||
/* ハンドレンジ学習(管理者専用)。基本のデザインは /assets/style.css を使う */
|
||||
|
||||
.range-wrap { padding-bottom: 32px; }
|
||||
|
||||
/* タブ */
|
||||
.seg { display: flex; gap: 6px; background: var(--panel); border-radius: 12px; padding: 4px; margin: 12px 0 16px; }
|
||||
.seg-btn {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
padding: 10px 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg-btn.active { background: var(--panel-2); color: var(--text); }
|
||||
|
||||
/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
|
||||
.cat-navy { --c: #20365d; --fg: #fff; }
|
||||
.cat-red { --c: #9e342e; --fg: #fff; }
|
||||
.cat-yellow { --c: #ddb356; --fg: #14191f; }
|
||||
.cat-green { --c: #4e8051; --fg: #101910; }
|
||||
.cat-blue { --c: #6aa5c9; --fg: #14191f; }
|
||||
.cat-white { --c: #f7f7f7; --fg: #222; }
|
||||
.cat-gray, .cat-purple { --c: #929292; --fg: #222; }
|
||||
|
||||
/* 13×13 の表 */
|
||||
.range-matrix { display: grid; grid-template-columns: repeat(13, 1fr); gap: 2px; }
|
||||
.range-cell {
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--c);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: clamp(8px, 2.4vw, 12px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.range-cell.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; }
|
||||
.range-cell.selected { outline: 2px solid var(--gold); outline-offset: 1px; z-index: 1; }
|
||||
|
||||
/* 凡例 */
|
||||
.range-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 12px; font-size: 12px; color: var(--muted); }
|
||||
.range-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 4px; background: var(--c); flex: none; }
|
||||
.swatch-dot.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; }
|
||||
|
||||
/* 選択したハンド */
|
||||
.sel-hand { font-size: 28px; font-weight: 900; line-height: 1.1; }
|
||||
.sel-label { display: flex; align-items: center; gap: 8px; font-weight: 800; margin-top: 6px; }
|
||||
|
||||
/* クイズ */
|
||||
.quiz-progress { height: 6px; background: var(--input); border-radius: 999px; overflow: hidden; margin: 16px 0 8px; }
|
||||
.quiz-progress i { display: block; height: 100%; width: 0; background: var(--green); transition: width 0.2s; }
|
||||
.quiz-stats { display: flex; justify-content: center; gap: 12px; font-size: 13px; color: var(--muted); }
|
||||
.quiz-hand { text-align: center; margin: 12px 0; padding: 18px 12px; }
|
||||
.quiz-cards { display: flex; justify-content: center; gap: 10px; min-height: 0; }
|
||||
.quiz-cards img { width: min(32%, 120px); height: auto; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); }
|
||||
.quiz-hand-name { font-size: 40px; font-weight: 900; margin-top: 8px; line-height: 1.1; }
|
||||
.quiz-answers { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.quiz-answers .btn {
|
||||
font-size: 15px;
|
||||
padding: 12px 8px;
|
||||
justify-content: flex-start;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.quiz-answers .btn:disabled { opacity: 1; }
|
||||
.quiz-answers .btn.correct { border-color: var(--green); background: rgba(46, 204, 113, 0.18); }
|
||||
.quiz-answers .btn.wrong { border-color: var(--red); background: rgba(255, 90, 90, 0.15); }
|
||||
.quiz-feedback { margin-top: 12px; padding: 12px 14px; border-radius: 10px; background: var(--panel); font-size: 14px; }
|
||||
.quiz-feedback.good { box-shadow: inset 0 0 0 2px var(--green); }
|
||||
.quiz-feedback.bad { box-shadow: inset 0 0 0 2px var(--red); }
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#24493b">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>ハンドレンジ - Hold'em Night</title>
|
||||
<link rel="icon" href="/assets/favicon.ico">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<link rel="stylesheet" href="/range/files/range.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap range-wrap">
|
||||
<div class="topbar">
|
||||
<a class="icon-btn" href="/" aria-label="ロビーへ戻る">←</a>
|
||||
<h1>ハンドレンジ</h1>
|
||||
</div>
|
||||
|
||||
<div class="seg" id="tabs">
|
||||
<button type="button" class="seg-btn active" data-view="range">レンジ表</button>
|
||||
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
|
||||
</div>
|
||||
|
||||
<!-- レンジ表 -->
|
||||
<section id="view-range">
|
||||
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
|
||||
<div class="range-legend" id="legend"></div>
|
||||
|
||||
<div class="card mt-12" id="selected">
|
||||
<div class="sel-hand">—</div>
|
||||
<div class="hint">セルをタップすると分類を確認できます</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-orange btn-block mt-12" id="start-from-range">クイズを始める</button>
|
||||
</section>
|
||||
|
||||
<!-- クイズ -->
|
||||
<section id="view-quiz" hidden>
|
||||
<div class="label">問題数(全169ハンドからランダム)</div>
|
||||
<div class="seg" id="count">
|
||||
<button type="button" class="seg-btn active" data-count="10">10問</button>
|
||||
<button type="button" class="seg-btn" data-count="20">20問</button>
|
||||
<button type="button" class="seg-btn" data-count="0">全問</button>
|
||||
</div>
|
||||
|
||||
<div class="quiz-progress"><i id="progress-bar"></i></div>
|
||||
<div class="quiz-stats">
|
||||
<span id="q-count">開始前</span>
|
||||
<span id="q-score">正解 0 / 0</span>
|
||||
<span id="q-accuracy">正答率 --</span>
|
||||
</div>
|
||||
|
||||
<div class="card quiz-hand">
|
||||
<div class="quiz-cards">
|
||||
<img id="card-1" alt="" hidden>
|
||||
<img id="card-2" alt="" hidden>
|
||||
</div>
|
||||
<div class="quiz-hand-name" id="q-hand">?</div>
|
||||
<div class="hint" id="q-prompt">「開始」を押してください</div>
|
||||
</div>
|
||||
|
||||
<div class="quiz-answers" id="answers"></div>
|
||||
<div class="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
|
||||
|
||||
<div class="row mt-12">
|
||||
<button type="button" class="btn btn-dark" id="start-quiz">開始</button>
|
||||
<button type="button" class="btn btn-blue" id="next-question" disabled>次の問題 →</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/range/files/range.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
// ハンドレンジ学習(管理者専用): レンジ表と色・分類クイズ
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'navy', label: '8人(強)' },
|
||||
{ id: 'red', label: '8人(弱)' },
|
||||
{ id: 'yellow', label: '6〜7人' },
|
||||
{ id: 'green', label: '4〜5人' },
|
||||
{ id: 'blue', label: '3人' },
|
||||
{ id: 'white', label: '2人' },
|
||||
{ id: 'purple', label: '紫枠' },
|
||||
{ id: 'gray', label: 'フォールド' },
|
||||
];
|
||||
|
||||
// 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答
|
||||
const RANGE = {
|
||||
AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green',
|
||||
AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white',
|
||||
AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple',
|
||||
AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple',
|
||||
ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple',
|
||||
A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple',
|
||||
A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple',
|
||||
A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple',
|
||||
A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple',
|
||||
55: 'green', '54s': 'purple',
|
||||
44: 'blue',
|
||||
33: 'blue',
|
||||
22: 'blue',
|
||||
};
|
||||
|
||||
const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o');
|
||||
const categoryOf = (hand) => RANGE[hand] || 'gray';
|
||||
const labelOf = (id) => (CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]).label;
|
||||
|
||||
function swatch(id) {
|
||||
const s = document.createElement('span');
|
||||
s.className = `swatch-dot cat-${id}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- タブ ----------
|
||||
function showView(name) {
|
||||
$('view-range').hidden = name !== 'range';
|
||||
$('view-quiz').hidden = name !== 'quiz';
|
||||
for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.view === name);
|
||||
}
|
||||
$('tabs').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-view]');
|
||||
if (b) showView(b.dataset.view);
|
||||
});
|
||||
|
||||
// ---------- レンジ表 ----------
|
||||
for (const c of CATEGORIES) {
|
||||
const item = document.createElement('span');
|
||||
item.append(swatch(c.id), c.label);
|
||||
$('legend').append(item);
|
||||
}
|
||||
|
||||
for (let r = 0; r < 13; r++) {
|
||||
for (let c = 0; c < 13; c++) {
|
||||
const hand = handAt(r, c);
|
||||
const cat = categoryOf(hand);
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = `range-cell cat-${cat}`;
|
||||
b.textContent = hand;
|
||||
b.title = `${hand}:${labelOf(cat)}`;
|
||||
b.addEventListener('click', () => select(b, hand, cat));
|
||||
$('matrix').append(b);
|
||||
}
|
||||
}
|
||||
|
||||
function select(cell, hand, cat) {
|
||||
document.querySelectorAll('.range-cell.selected').forEach((x) => x.classList.remove('selected'));
|
||||
cell.classList.add('selected');
|
||||
const box = $('selected');
|
||||
box.replaceChildren();
|
||||
const name = document.createElement('div');
|
||||
name.className = 'sel-hand';
|
||||
name.textContent = hand;
|
||||
const label = document.createElement('div');
|
||||
label.className = 'sel-label';
|
||||
label.append(swatch(cat), labelOf(cat));
|
||||
const note = document.createElement('div');
|
||||
note.className = 'hint';
|
||||
note.textContent = cat === 'purple' ? '灰色+紫枠。クイズの答えは「紫枠」です。' : 'このハンドの分類です。';
|
||||
box.append(name, label, note);
|
||||
}
|
||||
|
||||
$('start-from-range').addEventListener('click', () => {
|
||||
showView('quiz');
|
||||
startQuiz();
|
||||
});
|
||||
|
||||
// ---------- クイズ ----------
|
||||
const SUITS = ['hearts', 'clubs', 'diamonds', 'spades'];
|
||||
const RANK_FILE = { A: 'ace', K: 'king', Q: 'queen', J: 'jack', T: '10' };
|
||||
let quizCount = 10;
|
||||
let deck = [];
|
||||
let index = 0;
|
||||
let correct = 0;
|
||||
let answered = 0;
|
||||
let locked = false;
|
||||
|
||||
$('count').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-count]');
|
||||
if (!b) return;
|
||||
quizCount = Number(b.dataset.count);
|
||||
for (const x of $('count').children) x.classList.toggle('active', x === b);
|
||||
});
|
||||
|
||||
function shuffle(a) {
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function startQuiz() {
|
||||
const pool = [];
|
||||
for (let r = 0; r < 13; r++) for (let c = 0; c < 13; c++) pool.push(handAt(r, c));
|
||||
deck = shuffle(pool);
|
||||
if (quizCount > 0) deck = deck.slice(0, quizCount);
|
||||
index = 0;
|
||||
correct = 0;
|
||||
answered = 0;
|
||||
$('start-quiz').textContent = '最初から';
|
||||
renderQuestion();
|
||||
}
|
||||
|
||||
// スーテッドは同じマーク、それ以外は別々のマーク
|
||||
function showCards(hand) {
|
||||
const s1 = SUITS[Math.floor(Math.random() * 4)];
|
||||
let s2 = s1;
|
||||
if (hand[2] !== 's') while (s2 === s1) s2 = SUITS[Math.floor(Math.random() * 4)];
|
||||
[hand[0], hand[1]].forEach((rank, i) => {
|
||||
const img = $(`card-${i + 1}`);
|
||||
const suit = i === 0 ? s1 : s2;
|
||||
img.hidden = true;
|
||||
img.onload = () => { img.hidden = false; };
|
||||
img.onerror = () => { img.hidden = true; };
|
||||
img.alt = `${RANK_FILE[rank] || rank} of ${suit}`;
|
||||
img.src = `/range/files/cards/${RANK_FILE[rank] || rank}_of_${suit}.png`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
$('q-score').textContent = `正解 ${correct} / ${answered}`;
|
||||
$('q-accuracy').textContent = `正答率 ${answered ? Math.round((correct / answered) * 100) + '%' : '--'}`;
|
||||
}
|
||||
|
||||
function renderQuestion() {
|
||||
locked = false;
|
||||
$('answers').replaceChildren();
|
||||
$('feedback').hidden = true;
|
||||
$('next-question').disabled = true;
|
||||
if (index >= deck.length) return finishQuiz();
|
||||
|
||||
const hand = deck[index];
|
||||
$('q-hand').textContent = hand;
|
||||
$('q-prompt').textContent = 'このハンドの色・分類は?';
|
||||
$('q-count').textContent = `問題 ${index + 1} / ${deck.length}`;
|
||||
$('progress-bar').style.width = `${(index / deck.length) * 100}%`;
|
||||
updateStats();
|
||||
showCards(hand);
|
||||
|
||||
for (const cat of CATEGORIES) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'btn';
|
||||
b.dataset.cat = cat.id;
|
||||
b.append(swatch(cat.id), cat.label);
|
||||
b.addEventListener('click', () => answer(cat.id, b));
|
||||
$('answers').append(b);
|
||||
}
|
||||
}
|
||||
|
||||
function answer(choice, button) {
|
||||
if (locked) return;
|
||||
locked = true;
|
||||
const hand = deck[index];
|
||||
const right = categoryOf(hand);
|
||||
const ok = choice === right;
|
||||
if (ok) correct++;
|
||||
answered++;
|
||||
for (const b of $('answers').children) {
|
||||
if (b.dataset.cat === right) b.classList.add('correct');
|
||||
else if (b === button) b.classList.add('wrong');
|
||||
b.disabled = true;
|
||||
}
|
||||
const fb = $('feedback');
|
||||
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
|
||||
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
|
||||
fb.hidden = false;
|
||||
updateStats();
|
||||
$('next-question').disabled = false;
|
||||
}
|
||||
|
||||
function finishQuiz() {
|
||||
$('q-hand').textContent = '終了!';
|
||||
$('q-prompt').textContent = `${deck.length}問中 ${correct}問正解(${deck.length ? Math.round((correct / deck.length) * 100) : 0}%)`;
|
||||
$('q-count').textContent = '完了';
|
||||
$('progress-bar').style.width = '100%';
|
||||
$('card-1').hidden = $('card-2').hidden = true;
|
||||
const fb = $('feedback');
|
||||
fb.className = 'quiz-feedback';
|
||||
fb.textContent = '「最初から」で再挑戦できます。';
|
||||
fb.hidden = false;
|
||||
}
|
||||
|
||||
$('start-quiz').addEventListener('click', startQuiz);
|
||||
$('next-question').addEventListener('click', () => {
|
||||
index++;
|
||||
renderQuestion();
|
||||
});
|
||||
})();
|
||||