Norrisizer/templates/index.html
2026-07-25 17:06:35 +09:00

780 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CHUCK NORRIS FACT GENERATOR — Gemma製・自己流ファクトエンジン</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Zen+Kaku+Gothic+New:wght@400;500;700;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
:root{
--ink:#121014;
--panel:#1C1820;
--panel-line: rgba(243,238,226,0.08);
--gold:#E8B23D;
--crimson:#C1442E;
--bone:#F3EEE2;
--ash:#8B8478;
}
*{ box-sizing:border-box; }
html,body{ margin:0; padding:0; }
body{
background:
radial-gradient(circle at 1px 1px, rgba(243,238,226,0.045) 1.5px, transparent 1.5px) 0 0/26px 26px,
var(--ink);
color:var(--bone);
font-family:'Zen Kaku Gothic New', sans-serif;
min-height:100vh;
display:flex;
justify-content:center;
padding:56px 20px 80px;
}
.stage{ width:100%; max-width:640px; }
.top-row{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:14px;
}
.eyebrow{
font-family:'JetBrains Mono', monospace;
font-size:12px;
letter-spacing:0.14em;
color:var(--gold);
text-transform:uppercase;
display:flex;
align-items:center;
gap:8px;
}
.eyebrow::before{
content:"";
width:7px; height:7px;
background:var(--gold);
display:inline-block;
transform:rotate(45deg);
}
.lang-toggle{
display:flex;
border:1px solid var(--panel-line);
border-radius:2px;
overflow:hidden;
}
.lang-toggle button{
background:transparent;
color:var(--ash);
font-family:'JetBrains Mono', monospace;
font-size:12px;
font-weight:700;
padding:6px 12px;
border:none;
cursor:pointer;
}
.lang-toggle button.active{ background:var(--gold); color:var(--ink); }
h1{
font-family:'Anton', sans-serif;
font-weight:400;
font-size:clamp(34px, 6vw, 54px);
line-height:0.98;
letter-spacing:0.01em;
margin:0 0 10px;
color:var(--bone);
}
h1 span{ color:var(--crimson); }
.sub{
color:var(--ash);
font-size:15px;
line-height:1.7;
margin:0 0 36px;
max-width:52ch;
}
.panel{
background:var(--panel);
border:1px solid var(--panel-line);
border-radius:2px;
padding:28px;
margin-bottom:22px;
}
.row{ display:flex; gap:10px; }
input[type=text]{
flex:1;
background:var(--ink);
border:1px solid var(--panel-line);
color:var(--bone);
font-family:'Zen Kaku Gothic New', sans-serif;
font-size:15px;
padding:14px 16px;
border-radius:1px;
outline:none;
transition:border-color .15s ease;
}
input[type=text]:focus{ border-color:var(--gold); }
input[type=text]::placeholder{ color:var(--ash); }
button{
font-family:'Zen Kaku Gothic New', sans-serif;
font-weight:700;
font-size:15px;
border:none;
border-radius:1px;
cursor:pointer;
transition:transform .12s ease, opacity .12s ease;
}
button:active{ transform:scale(0.97); }
button:focus-visible{ outline:2px solid var(--gold); outline-offset:2px; }
button:disabled{ cursor:default; opacity:0.4; transform:none; }
.kick{
background:var(--crimson);
color:var(--bone);
padding:14px 22px;
white-space:nowrap;
}
.kick:hover:not(:disabled){ opacity:0.9; }
.result{
margin-top:24px;
padding-top:22px;
border-top:1px dashed var(--panel-line);
min-height:64px;
}
.result-empty{ color:var(--ash); font-size:14px; }
.fact{
font-family:'Anton', sans-serif;
font-weight:400;
font-size:clamp(20px, 3.4vw, 27px);
line-height:1.35;
letter-spacing:0.005em;
}
.fact::before{ content:"“ "; color:var(--gold); }
.fact::after{ content:" ”"; color:var(--gold); }
.rate-grid{
margin-top:18px;
display:grid;
grid-template-columns:1fr 1fr;
gap:8px;
}
.rate-btn{
background:transparent;
border:1px solid var(--panel-line);
color:var(--bone);
padding:10px 12px;
font-size:13px;
border-radius:1px;
text-align:left;
}
.rate-btn .emoji{ margin-right:6px; }
.rate-btn[data-rating="love"]:hover:not(:disabled){ border-color:var(--gold); color:var(--gold); }
.rate-btn[data-rating="dislike"]:hover:not(:disabled){ border-color:var(--crimson); color:var(--crimson); }
.rate-btn[data-rating="too_much"]:hover:not(:disabled),
.rate-btn[data-rating="not_enough"]:hover:not(:disabled){ border-color:var(--bone); }
.rate-btn.selected{ border-color:var(--gold); background:rgba(232,178,61,0.12); opacity:1; }
.rate-note{
margin-top:10px;
font-family:'JetBrains Mono', monospace;
font-size:12px;
color:var(--ash);
}
.belt-section .label-row{
display:flex;
justify-content:space-between;
align-items:baseline;
margin-bottom:6px;
}
.belt-section .label{
font-family:'JetBrains Mono', monospace;
font-size:12px;
letter-spacing:0.1em;
color:var(--ash);
text-transform:uppercase;
}
.belt-explainer{
font-size:12px;
color:var(--ash);
margin:0 0 14px;
line-height:1.6;
}
.belt-name{
font-family:'Anton', sans-serif;
font-size:20px;
color:var(--gold);
}
.belt-strap{
position:relative;
height:26px;
background:var(--ink);
border:1px solid var(--panel-line);
border-radius:2px;
overflow:hidden;
}
.belt-fill{
height:100%;
width:0%;
background:linear-gradient(90deg, var(--crimson), var(--gold));
transition:width .5s ease;
}
.belt-stats{
margin-top:10px;
font-family:'JetBrains Mono', monospace;
font-size:11px;
color:var(--ash);
display:flex;
gap:14px;
flex-wrap:wrap;
}
.belt-stats b{ color:var(--bone); }
.hypothesis-box{
margin-top:18px;
padding-top:16px;
border-top:1px dashed var(--panel-line);
}
.hypothesis-box p{
margin:8px 0 0;
font-size:14px;
line-height:1.6;
color:var(--bone);
}
.hypothesis-box p.updating{ color:var(--ash); font-style:italic; }
.hypothesis-box ul{
list-style:none;
margin:8px 0 0;
padding:0;
display:flex;
flex-direction:column;
gap:6px;
}
.hypothesis-box li{
font-size:14px;
line-height:1.5;
color:var(--bone);
padding-left:14px;
position:relative;
}
.hypothesis-box li::before{
content:"—";
position:absolute;
left:0;
color:var(--gold);
}
.hypothesis-box li.placeholder{
color:var(--ash);
font-style:italic;
}
.hypothesis-box li.placeholder::before{ content:""; }
.hypothesis-box.updating li{ color:var(--ash); font-style:italic; }
.hypothesis-box li .note{
display:block;
color:var(--gold);
font-size:11px;
font-family:'JetBrains Mono', monospace;
margin-top:2px;
}
footer{
margin-top:34px;
color:var(--ash);
font-size:12px;
line-height:1.7;
}
footer code{ color:var(--gold); font-family:'JetBrains Mono', monospace; }
.unlock-panel{ padding:18px 28px; }
.unlock-row{
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
flex-wrap:wrap;
}
.unlock-row .label{
font-family:'JetBrains Mono', monospace;
font-size:11px;
letter-spacing:0.1em;
color:var(--ash);
}
.unlock-row .label.gemini{ color:var(--gold); }
.unlock-form{ display:flex; gap:6px; }
.unlock-form input{
background:var(--ink);
border:1px solid var(--panel-line);
color:var(--bone);
font-family:'JetBrains Mono', monospace;
font-size:12px;
padding:8px 10px;
border-radius:1px;
outline:none;
width:140px;
}
.unlock-form input:focus{ border-color:var(--gold); }
.unlock-form button{
background:transparent;
border:1px solid var(--panel-line);
color:var(--bone);
font-size:12px;
padding:8px 12px;
border-radius:1px;
}
.unlock-form button:hover:not(:disabled){ border-color:var(--gold); color:var(--gold); }
.unlock-note{
margin:8px 0 0;
font-size:11px;
color:var(--ash);
font-family:'JetBrains Mono', monospace;
}
.rick-overlay{
display:none;
position:fixed;
inset:0;
background:rgba(0,0,0,0.85);
z-index:999;
align-items:center;
justify-content:center;
padding:20px;
}
.rick-overlay.open{ display:flex; }
.rick-modal{
background:var(--panel);
border:1px solid var(--gold);
border-radius:2px;
padding:28px;
max-width:520px;
width:100%;
position:relative;
box-shadow:0 0 60px rgba(232,178,61,0.15);
}
.rick-close{
position:absolute;
top:10px; right:10px;
background:transparent;
border:none;
color:var(--ash);
font-size:22px;
line-height:1;
padding:4px 8px;
}
.rick-close:hover{ color:var(--bone); }
.rick-text{
font-family:'Anton', sans-serif;
font-size:clamp(22px, 5vw, 32px);
text-align:center;
margin:0 0 20px;
color:var(--bone);
}
.rick-text span{ color:var(--crimson); }
.rick-video-wrap{
position:relative;
width:100%;
padding-top:56.25%;
background:#000;
}
.rick-video-wrap iframe{
position:absolute;
top:0; left:0;
width:100%; height:100%;
border:0;
}
@media (prefers-reduced-motion: reduce){
.belt-fill, button{ transition:none; }
}
</style>
</head>
<body>
<main class="stage">
<div class="top-row">
<div class="eyebrow" id="eyebrow">Gemma 4 — self-tuning nonsense engine</div>
<div class="lang-toggle">
<button id="langJa" class="active">JA</button>
<button id="langEn">EN</button>
</div>
</div>
<h1 id="titleMain">CHUCK NORRIS<br><span>FACT</span> GENERATOR</h1>
<p class="sub" id="subText">
好きなテーマを入れると、Gemmaが不条理チャック・リス・ファクトを1つ生成します。
評価を返すほど、あなた好みのテイストに寄っていきます。
</p>
<section class="panel">
<div class="row">
<input id="topic" type="text" placeholder="例:ラーメン、締め切り、猫" maxlength="40" />
<button class="kick" id="genBtn">喰らえ</button>
</div>
<div class="result" id="result">
<p class="result-empty" id="resultEmpty">まだ何も生成していません。テーマを入れて「喰らえ」を押してください。</p>
</div>
<div class="rate-grid" id="rateGrid" style="display:none;">
<button class="rate-btn" data-rating="love"><span class="emoji">😍</span><span class="rlabel">大好き</span></button>
<button class="rate-btn" data-rating="too_much"><span class="emoji">🔥</span><span class="rlabel">やりすぎ!</span></button>
<button class="rate-btn" data-rating="not_enough"><span class="emoji">😐</span><span class="rlabel">まだまだ</span></button>
<button class="rate-btn" data-rating="dislike"><span class="emoji">👎</span><span class="rlabel">きらい</span></button>
</div>
<div class="rate-note" id="rateNote"></div>
</section>
<section class="panel belt-section">
<div class="label-row">
<span class="label" id="rankLabel">Personalization Rank</span>
<span class="belt-name" id="beltName">白帯</span>
</div>
<p class="belt-explainer" id="beltExplainer">
帯は「評価を何回返したか」の目安です。評価が増えるほど、Gemmaがあなたの好みについて
より具体的な仮説を持つようになります。
</p>
<div class="belt-strap"><div class="belt-fill" id="beltFill"></div></div>
<div class="belt-stats" id="beltStats">
<span>😍 <b id="cLove">0</b></span>
<span>🔥 <b id="cTooMuch">0</b></span>
<span>😐 <b id="cNotEnough">0</b></span>
<span>👎 <b id="cDislike">0</b></span>
</div>
<div class="hypothesis-box">
<span class="label" id="hypLabel">Current Hypothesis</span>
<ul id="traitsList"><li class="placeholder">まだ十分なデータがない。特定の好みの傾向は未確認。</li></ul>
</div>
<div class="hypothesis-box">
<span class="label" id="bestLabel">Best Of Collection</span>
<ul id="examplesList"></ul>
</div>
</section>
<section class="panel unlock-panel">
<div class="unlock-row">
<span class="label" id="tierLabel">MODEL: GEMMA</span>
<div class="unlock-form">
<input id="unlockCode" type="text" placeholder="unlock code" maxlength="40" />
<button id="unlockBtn">Unlock</button>
</div>
</div>
<p class="unlock-note" id="unlockNote"></p>
</section>
<footer id="footerText">
モデル: <code>gemma-4-26b-a4b-it</code>Gemini API経由
好み調整はモデルの重み更新ではなく、評価結果をもとにGemma自身が仮説を書き直す
サーバーサイドのプロンプトベース・パーソナライズです。
</footer>
</main>
<div class="rick-overlay" id="rickOverlay">
<div class="rick-modal">
<button class="rick-close" id="rickClose" aria-label="close">×</button>
<p class="rick-text">No, I am <span>CHUCK NORRIS.</span></p>
<div class="rick-video-wrap">
<iframe id="rickIframe" src="" title="???" frameborder="0"
allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
</div>
<script>
const BELT_MAX = 20;
const UI = {
ja: {
eyebrow: "Gemma 4 — 自己流ネタ生成エンジン",
sub: "好きなテーマを入れると、Gemmaが不条理チャック・リス・ファクトを1つ生成します。評価を返すほど、あなた好みのテイストに寄っていきます。",
placeholder: "例:ラーメン、締め切り、猫",
genBtn: "喰らえ",
genBtnLoading: "生成中…",
resultEmpty: "まだ何も生成していません。テーマを入れて「喰らえ」を押してください。",
rankLabel: "パーソナライズ・ランク",
beltExplainer: "帯は「評価を何回返したか」の目安です。評価が増えるほど、Gemmaがあなたの好みについてより具体的な仮説を持つようになります。",
hypLabel: "現在の仮説",
bestLabel: "ベストネタ集",
noTraits: "まだ十分なデータがない。特定の好みの傾向は未確認。",
ratings: { love: "大好き", too_much: "やりすぎ!", not_enough: "まだまだ", dislike: "きらい" },
rateNoteDone: "評価を反映しました",
rateUpdating: "評価をもとに仮説を考え直し中…",
fallbackNote: "(APIキー未設定のためサンプル表示中)",
errorPrefix: "エラー: ",
networkError: "通信エラーが発生しました。",
footer: 'モデル: <code>gemma-4-26b-a4b-it</code>Gemini API経由。好み調整はモデルの重み更新ではなく、評価結果をもとにGemma自身が仮説を書き直すサーバーサイドのプロンプトベース・パーソナライズです。',
},
en: {
eyebrow: "Gemma 4 — self-tuning nonsense engine",
sub: "Enter a topic and Gemma generates one absurd Chuck Norris fact. The more you rate, the more it leans toward your taste.",
placeholder: "e.g. ramen, deadlines, cats",
genBtn: "Bring it on",
genBtnLoading: "Generating…",
resultEmpty: "Nothing generated yet. Type a topic and hit \"Bring it on\".",
rankLabel: "Personalization Rank",
beltExplainer: "The belt tracks how many ratings you've given. The more feedback, the more specific Gemma's hypothesis about your taste becomes.",
hypLabel: "Current Hypothesis",
bestLabel: "Best Of Collection",
noTraits: "Not enough data yet. No clear taste detected.",
ratings: { love: "Love it", too_much: "Too much!", not_enough: "Needs more", dislike: "Not for me" },
rateNoteDone: "Rating recorded",
rateUpdating: "Rethinking the hypothesis based on your rating…",
fallbackNote: "(showing a sample — no API key configured)",
errorPrefix: "Error: ",
networkError: "A network error occurred.",
footer: 'Model: <code>gemma-4-26b-a4b-it</code> (via the Gemini API). Personalization does not update model weights — it\'s server-side, prompt-based: Gemma rewrites its own hypothesis about your taste after each rating.',
}
};
let currentLang = 'ja';
let lastFact = "";
let lastTopic = "";
let lastFactId = "";
let isComposing = false;
const el = (id) => document.getElementById(id);
function applyStaticText(){
const t = UI[currentLang];
el('eyebrow').textContent = t.eyebrow;
el('subText').textContent = t.sub;
el('topic').placeholder = t.placeholder;
el('genBtn').textContent = t.genBtn;
el('resultEmpty').textContent = t.resultEmpty;
el('rankLabel').textContent = t.rankLabel;
el('beltExplainer').textContent = t.beltExplainer;
el('hypLabel').textContent = t.hypLabel;
el('bestLabel').textContent = t.bestLabel;
document.querySelectorAll('.rate-btn').forEach(btn => {
const key = btn.dataset.rating;
btn.querySelector('.rlabel').textContent = t.ratings[key];
});
el('footerText').innerHTML = t.footer;
}
function setLang(lang){
currentLang = lang;
el('langJa').classList.toggle('active', lang === 'ja');
el('langEn').classList.toggle('active', lang === 'en');
applyStaticText();
// 結果表示中のネタはそのままにして、静的UIのみ切り替える
refreshProfile();
}
function renderTraits(traits){
const ul = el('traitsList');
ul.innerHTML = '';
if(!traits || traits.length === 0){
const li = document.createElement('li');
li.className = 'placeholder';
li.textContent = UI[currentLang].noTraits;
ul.appendChild(li);
return;
}
traits.forEach(t => {
const li = document.createElement('li');
li.textContent = t;
ul.appendChild(li);
});
}
function showTraitsUpdating(){
const ul = el('traitsList');
ul.innerHTML = '';
const li = document.createElement('li');
li.className = 'placeholder';
li.textContent = UI[currentLang].rateUpdating;
ul.appendChild(li);
}
function renderExamples(examples){
const ul = el('examplesList');
ul.innerHTML = '';
(examples || []).forEach(e => {
const li = document.createElement('li');
const factSpan = document.createElement('span');
factSpan.textContent = e.fact;
const noteSpan = document.createElement('span');
noteSpan.className = 'note';
noteSpan.textContent = e.note;
li.appendChild(factSpan);
li.appendChild(noteSpan);
ul.appendChild(li);
});
}
function applyBelt(data){
el('beltName').textContent = data.belt;
if(data.counts){
el('cLove').textContent = data.counts.love ?? 0;
el('cTooMuch').textContent = data.counts.too_much ?? 0;
el('cNotEnough').textContent = data.counts.not_enough ?? 0;
el('cDislike').textContent = data.counts.dislike ?? 0;
}
const total = data.total ?? 0;
const pct = Math.min(100, Math.round((total / BELT_MAX) * 100));
el('beltFill').style.width = pct + '%';
renderTraits(data.traits);
renderExamples(data.examples);
}
function applyTier(tier){
const label = el('tierLabel');
if(tier === 'gemini'){
label.textContent = 'MODEL: GEMINI';
label.classList.add('gemini');
} else {
label.textContent = 'MODEL: GEMMA';
label.classList.remove('gemini');
}
}
async function refreshProfile(){
const res = await fetch('/api/profile?lang=' + currentLang);
const data = await res.json();
applyBelt(data);
applyTier(data.tier);
}
function resetRateButtons(){
document.querySelectorAll('.rate-btn').forEach(btn => {
btn.disabled = false;
btn.classList.remove('selected');
});
}
function openRickModal(){
el('rickIframe').src = 'https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1';
el('rickOverlay').classList.add('open');
}
function closeRickModal(){
el('rickOverlay').classList.remove('open');
el('rickIframe').src = '';
}
async function generateFact(){
const topic = el('topic').value.trim();
const btn = el('genBtn');
const t = UI[currentLang];
btn.disabled = true;
btn.textContent = t.genBtnLoading;
el('rateGrid').style.display = 'none';
el('rateNote').textContent = '';
resetRateButtons();
try{
const res = await fetch('/api/fact', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({topic, lang: currentLang})
});
const data = await res.json();
if(data.rickroll){
openRickModal();
return;
}
if(data.error){
el('result').innerHTML = `<p class="result-empty">${t.errorPrefix}${escapeHtml(data.error)}</p>`;
return;
}
lastFact = data.fact;
lastTopic = data.topic || topic;
lastFactId = data.fact_id || "";
el('result').innerHTML = `<p class="fact">${escapeHtml(lastFact)}</p>`;
el('rateGrid').style.display = 'grid';
applyTier(data.tier);
if(data.fallback){
el('rateNote').textContent = t.fallbackNote;
}
} catch(e){
el('result').innerHTML = `<p class="result-empty">${t.networkError}</p>`;
} finally{
btn.disabled = false;
btn.textContent = t.genBtn;
}
}
async function rate(rating, clickedBtn){
if(!lastFact || !lastFactId) return;
const t = UI[currentLang];
// 連打防止: 押した瞬間に全ボタンを無効化し、選んだものだけハイライト
document.querySelectorAll('.rate-btn').forEach(b => b.disabled = true);
clickedBtn.classList.add('selected');
showTraitsUpdating();
el('rateNote').textContent = '';
try{
const res = await fetch('/api/rate', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({fact:lastFact, topic:lastTopic, fact_id:lastFactId, rating, lang: currentLang})
});
const data = await res.json();
if(!data.error){
applyBelt(data);
el('rateNote').textContent = t.rateNoteDone;
}
} catch(e){
renderTraits(null);
}
// サーバー側でも fact_id 単位で一度きりに制限しているので、
// ボタンはこの評価が終わるまで(=次に生成するまで)無効のままにする
}
function escapeHtml(str){
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
el('genBtn').addEventListener('click', generateFact);
el('topic').addEventListener('compositionstart', () => { isComposing = true; });
el('topic').addEventListener('compositionend', () => { isComposing = false; });
el('topic').addEventListener('keydown', (e) => {
if(e.key !== 'Enter') return;
// 日本語入力の変換確定Enterでは送信しないisComposing / keyCode 229 の二重チェック)
if(isComposing || e.isComposing || e.keyCode === 229) return;
generateFact();
});
document.querySelectorAll('.rate-btn').forEach(btn => {
btn.addEventListener('click', () => rate(btn.dataset.rating, btn));
});
el('langJa').addEventListener('click', () => setLang('ja'));
el('langEn').addEventListener('click', () => setLang('en'));
el('rickClose').addEventListener('click', closeRickModal);
el('rickOverlay').addEventListener('click', (e) => {
if(e.target.id === 'rickOverlay') closeRickModal();
});
el('unlockBtn').addEventListener('click', async () => {
const code = el('unlockCode').value.trim();
const note = el('unlockNote');
if(!code) return;
const btn = el('unlockBtn');
btn.disabled = true;
try{
const res = await fetch('/api/unlock', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({code})
});
const data = await res.json();
applyTier(data.tier);
note.textContent = data.ok
? (currentLang === 'ja' ? '✨ Geminiモードに切り替わりました' : '✨ Switched to Gemini mode')
: (currentLang === 'ja' ? 'コードが違うようです' : 'That code doesn\'t look right');
} catch(e){
note.textContent = currentLang === 'ja' ? '通信エラーが発生しました。' : 'A network error occurred.';
} finally{
btn.disabled = false;
}
});
applyStaticText();
refreshProfile();
</script>
</body>
</html>