<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>IELTS Speaking Coach (Pages版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body { touch-action: manipulation; background:#f8fafc; }
.message-bubble { max-width: 90%; border-radius: 1.25rem; }
.recording { animation: pulse 1.5s infinite; background-color: #ef4444 !important; }
@keyframes pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.05)} }
#start-overlay, #topic-overlay, #prep-overlay {
position:absolute; inset:0; z-index:50; display:flex; flex-direction:column;
align-items:center; justify-content:center; color:#fff; text-align:center; padding:1.5rem;
}
#start-overlay { background: linear-gradient(135deg, #4f46e5 0%, #3730a3 100%); }
#topic-overlay { background:#312e81; display:none; overflow-y:auto; }
#prep-overlay { background: rgba(0,0,0,0.92); display:none; }
.menu-card, .topic-card {
background: rgba(255,255,255,0.12);
border:1px solid rgba(255,255,255,0.20);
border-radius: 14px;
padding: 16px;
width: 100%;
max-width: 340px;
transition: all .2s;
cursor: pointer;
margin-bottom: 12px;
}
.menu-card:hover, .topic-card:hover { background: rgba(255,255,255,0.20); transform: translateY(-2px); }
.btn-action {
padding: 8px 14px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all .15s;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.btn-repeat { background:#f59e0b; color:#fff; }
.btn-next { background:#4f46e5; color:#fff; }
.btn-repeat.active { background:#ef4444; transform: scale(0.98); }
.small-note { font-size: 11px; color:#334155; }
</style>
</head>
<body class="min-h-screen font-sans text-slate-900">
<div id="app" class="max-w-md mx-auto h-screen flex flex-col shadow-2xl bg-white relative overflow-hidden">
<!-- Start -->
<div id="start-overlay">
<i class="fas fa-graduation-cap text-6xl mb-4 text-indigo-200"></i>
<h1 class="text-3xl font-extrabold mb-2">IELTS Coach</h1>
<p class="mb-6 text-indigo-100 text-sm">まずはトピックを選んで練習開始</p>
<div class="space-y-2 w-full flex flex-col items-center">
<div class="menu-card" onclick="openTopicSelection('Part 1')">
<b>Part 1: Interview</b><br><span class="text-xs opacity-80">5.0–6.0向け(短く・簡単に)</span>
</div>
<div class="menu-card" onclick="openTopicSelection('Part 2')">
<b>Part 2: Long Turn</b><br><span class="text-xs opacity-80">1分準備 → 1.5〜2分</span>
</div>
<div class="menu-card" onclick="openTopicSelection('Part 3')">
<b>Part 3: Discussion</b><br><span class="text-xs opacity-80">理由+例(難語は少なめ)</span>
</div>
</div>
<div class="mt-6 text-xs text-indigo-100 opacity-90">
※今は「配布できる箱」を先に作る段階です。Gemini連携はあとでOK。
</div>
</div>
<!-- Topic -->
<div id="topic-overlay">
<button onclick="showStartMenuUI()" class="absolute top-6 left-6 text-white opacity-80">
<i class="fas fa-arrow-left"></i> 戻る
</button>
<h2 id="topic-title" class="text-xl font-bold mb-6 mt-12">Topics</h2>
<div id="topic-list" class="w-full flex flex-col items-center pb-8"></div>
</div>
<!-- Part2 Prep -->
<div id="prep-overlay">
<div class="text-indigo-300 text-xs font-bold mb-2 uppercase tracking-widest">Preparation Time</div>
<h2 class="text-xl font-bold mb-6 px-4" id="prep-topic-text">--</h2>
<div id="prep-timer" class="text-8xl font-mono font-bold mb-6 text-white">60</div>
<p class="text-sm text-slate-300 mb-8 px-8 italic">メモを取ってOK。時間が来たら開始します。</p>
<button onclick="skipPrep()" class="bg-indigo-600 hover:bg-indigo-500 px-10 py-4 rounded-full font-bold transition shadow-xl">
今すぐ始める
</button>
</div>
<!-- Header -->
<header class="bg-indigo-700 text-white p-4 flex justify-between items-center shadow-lg shrink-0">
<div class="flex items-center gap-3">
<button onclick="confirmReset()" class="p-2 hover:bg-indigo-600 rounded-full" title="ホームへ">
<i class="fas fa-home"></i>
</button>
<div>
<div id="header-part" class="text-[10px] uppercase font-bold text-indigo-200 leading-none">Ready</div>
<div id="header-topic" class="text-sm font-bold">Select Topic</div>
</div>
</div>
<button id="tts-toggle" onclick="toggleTts()" class="p-2 bg-indigo-800 rounded-full" title="音声ON/OFF">
<i id="tts-icon" class="fas fa-volume-up"></i>
</button>
</header>
<!-- Chat -->
<main id="chat-container" class="flex-grow overflow-y-auto p-4 space-y-4 bg-slate-50"></main>
<!-- Footer -->
<footer class="p-4 bg-white border-t shrink-0">
<div id="rec-hint" class="hidden text-[10px] text-red-500 font-bold mb-2 animate-pulse uppercase tracking-tighter">
<i class="fas fa-circle mr-1"></i> Recording...
</div>
<div class="flex items-center gap-2">
<button id="mic-btn" onclick="toggleMic()" class="w-12 h-12 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center transition active:scale-90">
<i class="fas fa-microphone text-lg"></i>
</button>
<input id="user-input" type="text" placeholder="英語で答えてください..." class="flex-grow border border-slate-300 rounded-full py-2.5 px-4 text-sm focus:ring-2 focus:ring-indigo-500 outline-none" />
<button onclick="handleSend()" class="w-11 h-11 bg-indigo-100 text-indigo-700 rounded-full flex items-center justify-center hover:bg-indigo-200 transition">
<i class="fas fa-paper-plane text-sm"></i>
</button>
</div>
<div class="mt-2 small-note">
※マイク入力を「確実に」動かすには、次にCloudflare WorkersでGemini(STT)をつなぎます。
</div>
</footer>
</div>
<script>
// ★ここは後でWorkersを作ったらURLに置き換える(今は空でOK)
const WORKER_BASE_URL = ""; // 例: "https://xxxx.yourname.workers.dev"
const topicsData = {
"Part 1": [
{t:"Hometown", d:"地元の紹介"},
{t:"Work or Study", d:"仕事や学業"},
{t:"Daily Routine", d:"日課"},
{t:"Hobbies", d:"趣味"},
{t:"Technology", d:"スマホ・SNS"},
{t:"Transport", d:"移動手段"}
],
"Part 2": [
{t:"Describe a Person", d:"人物"},
{t:"Describe a Place", d:"場所"},
{t:"Describe an Experience", d:"体験"}
],
"Part 3": [
{t:"Society & Technology", d:"AI/広告/仕事"},
{t:"Education & Career", d:"教育/キャリア"},
{t:"Tourism & History", d:"観光/歴史"}
]
};
let state = {
menu: "",
topic: "",
isTts: true,
isRec: false,
isProcessing: false,
prepInterval: null,
repeatingMode: false,
currentModelAnswer: ""
};
// -------- UI navigation --------
function showStartMenuUI(){
document.getElementById("start-overlay").style.display = "flex";
document.getElementById("topic-overlay").style.display = "none";
}
function openTopicSelection(menu) {
state.menu = menu;
document.getElementById("start-overlay").style.display = "none";
document.getElementById("topic-overlay").style.display = "flex";
document.getElementById("topic-title").innerText = menu + " Topics";
const list = document.getElementById("topic-list");
list.innerHTML = "";
topicsData[menu].forEach(item => {
const d = document.createElement("div");
d.className = "topic-card";
d.innerHTML = `<div class="font-bold text-white">${item.t}</div><div class="text-[10px] text-indigo-200">${item.d}</div>`;
d.onclick = () => selectTopic(item.t);
list.appendChild(d);
});
}
function selectTopic(t) {
state.topic = t;
document.getElementById("topic-overlay").style.display = "none";
document.getElementById("header-part").innerText = state.menu;
document.getElementById("header-topic").innerText = t;
if (state.menu === "Part 2") startPart2Prep();
else {
addMessage("ai", `✅ ${state.menu} (${t}) を開始します。まずは質問を出します。`);
askNextQuestion();
}
}
function startPart2Prep() {
document.getElementById("prep-overlay").style.display = "flex";
document.getElementById("prep-topic-text").innerText = `Topic: ${state.topic}`;
let time = 60;
document.getElementById("prep-timer").innerText = time;
state.prepInterval = setInterval(() => {
time--;
document.getElementById("prep-timer").innerText = time;
if (time <= 0) skipPrep();
}, 1000);
}
function skipPrep() {
clearInterval(state.prepInterval);
document.getElementById("prep-overlay").style.display = "none";
addMessage("ai", "⏱ 準備時間が終了しました。質問を出します。");
askNextQuestion();
}
function confirmReset() {
if (confirm("ホームに戻るとチャット履歴が消えます。戻りますか?")) location.reload();
}
// -------- Chat rendering --------
function addMessage(role, text, withActions=false) {
const container = document.getElementById("chat-container");
const wrap = document.createElement("div");
wrap.className = `flex flex-col ${role==='user'?'items-end':'items-start'} w-full`;
const bubble = document.createElement("div");
bubble.className = `message-bubble p-4 text-sm shadow-sm ${
role==='user' ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-800'
}`;
bubble.innerHTML = formatText(text);
wrap.appendChild(bubble);
if (withActions) {
const actions = document.createElement("div");
actions.className = "flex gap-2 mt-2";
actions.innerHTML = `
<button class="btn-action btn-repeat" onclick="startRepeat(this)">
<i class="fas fa-redo"></i> リピート練習
</button>
<button class="btn-action btn-next" onclick="askNextQuestion()">
次の質問 <i class="fas fa-arrow-right"></i>
</button>
`;
wrap.appendChild(actions);
}
container.appendChild(wrap);
container.scrollTop = container.scrollHeight;
}
function formatText(text) {
// 表示用の整形(タグを見やすく)
const safe = String(text)
.replace(/\[Scores\]/g, "📊 <b class='text-indigo-600'>Estimated Score</b><br>")
.replace(/\[Feedback\]/g, "<br>💡 <b class='text-amber-600'>Advice</b><br>")
.replace(/\[Model Answer\]/g, "<br>📘 <b class='text-blue-600'>Model Answer</b><br>")
.replace(/\[Next Question\]/g, "<br>❓ <b class='text-indigo-600'>Question</b><br>");
return safe.replace(/\n/g, "<br>");
}
// -------- Speech (cute-ish on iPhone) --------
function speak(text) {
if (!state.isTts) return;
try { window.speechSynthesis.cancel(); } catch(e){}
// Feedbackは読まない(英語部分中心)
let speakText = "";
const modelMatch = text.match(/\[Model Answer\]:?\s*([\s\S]*?)(?=\[|$)/i);
const qMatch = text.match(/\[Next Question\]:?\s*([\s\S]*?)$/i);
if (modelMatch) speakText += modelMatch[1].trim() + " ";
if (qMatch) speakText += qMatch[1].trim();
if (!speakText) speakText = String(text);
const ut = new SpeechSynthesisUtterance(speakText.replace(/<br>/g, " "));
ut.lang = "en-US";
// iPhone Safari:声の種類は端末依存。取れたら英語声を優先
const voices = window.speechSynthesis.getVoices?.() || [];
const v = voices.find(x => x.lang && x.lang.startsWith("en")) || null;
if (v) ut.voice = v;
// かわいく寄せる(やりすぎると聞き取りにくいので控えめ)
ut.pitch = 1.18;
ut.rate = 0.92;
window.speechSynthesis.speak(ut);
}
window.speechSynthesis.onvoiceschanged = () => { try { window.speechSynthesis.getVoices(); } catch(e){} };
function toggleTts() {
state.isTts = !state.isTts;
document.getElementById("tts-icon").className = state.isTts ? "fas fa-volume-up" : "fas fa-volume-mute";
if (!state.isTts) { try { window.speechSynthesis.cancel(); } catch(e){} }
}
// -------- API helpers (Workerが無い間は案内を返す) --------
async function callGemini(prompt) {
if (!WORKER_BASE_URL) {
return "[Feedback]\n今はGemini未接続です。まずPagesで配布できる状態を作っています。\n\n[Model Answer]\nI can answer in simple English. (Gemini is not connected yet.)\n\n[Next Question]\nPlease type your answer for now.";
}
const res = await fetch(`${WORKER_BASE_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type":"application/json" },
body: JSON.stringify({ prompt })
});
const data = await res.json();
return data?.text || "[Error] No response.";
}
// -------- Core flow --------
async function askNextQuestion() {
if (!state.menu || !state.topic) return;
state.isProcessing = true;
const prompt = `As an IELTS examiner, ask ONE question for ${state.menu} about "${state.topic}". Start with [Next Question]. Keep it short.`;
const q = await callGemini(prompt);
state.isProcessing = false;
addMessage("ai", q);
speak(q);
}
async function handleSend() {
const input = document.getElementById("user-input");
const text = input.value.trim();
if (!text || state.isProcessing) return;
addMessage("user", text);
input.value = "";
// リピート練習中:モデル文と比較
if (state.repeatingMode) {
state.repeatingMode = false;
const prompt = `Compare User to Model. Give short feedback in Japanese.
Model: "${state.currentModelAnswer}"
User: "${text}"`;
state.isProcessing = true;
const fb = await callGemini(prompt);
state.isProcessing = false;
addMessage("ai", fb);
speak(fb);
return;
}
// 通常:スコア/FB/モデル/次質問
const levelInstruction = (() => {
if (state.menu === "Part 1") {
return "Model Answer must be IELTS Band 5.0-6.0: simple words, 2-3 sentences, about 25-35 words. Use Answer + Reason (+ small detail).";
}
if (state.menu === "Part 2") {
return "Model Answer should be Band 5.5-6.5: clear structure, simple vocabulary, 120-160 words, 1.5-2 minutes style.";
}
return "Model Answer should be Band 5.5-6.5: clear reasons + one example, avoid rare words, 60-90 words.";
})();
const prompt = `You are an IELTS Speaking Coach.
Menu: ${state.menu}
Topic: ${state.topic}
User Answer: "${text}"
${levelInstruction}
Respond with:
1. [Scores] Estimated Band 0-9 (rough)
2. [Feedback] concise advice in Japanese (2-4 bullets)
3. [Model Answer] (English)
4. [Next Question] (one follow-up question)`;
state.isProcessing = true;
const response = await callGemini(prompt);
state.isProcessing = false;
// モデル答え抽出(リピート用)
const m = response.match(/\[Model Answer\]:?\s*([\s\S]*?)(?=\[|$)/i);
state.currentModelAnswer = m ? m[1].trim() : "";
addMessage("ai", response, !!state.currentModelAnswer);
speak(response);
}
// -------- Repeat practice --------
function startRepeat(btn) {
if (!state.currentModelAnswer) return;
// ボタン見た目
document.querySelectorAll(".btn-repeat").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.repeatingMode = true;
speak(`[Model Answer]: ${state.currentModelAnswer}`);
addMessage("ai",
"[Feedback]\nリピート練習:今のModel Answerを聞いて、できるだけ同じ内容で言ってみてください。\n(音声入力はWorkers接続後に安定します。今はタイピングでもOK)"
);
}
// -------- Mic (今は “録音はするがSTTはWorkers接続後” の設計) --------
let mediaRecorder = null;
let recStream = null;
let audioChunks = [];
async function toggleMic() {
const micBtn = document.getElementById("mic-btn");
const hint = document.getElementById("rec-hint");
// stop
if (state.isRec && mediaRecorder) {
mediaRecorder.stop();
return;
}
// start
try {
recStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(recStream);
mediaRecorder.onstart = () => {
state.isRec = true;
micBtn.classList.add("recording");
hint.classList.remove("hidden");
};
mediaRecorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
state.isRec = false;
micBtn.classList.remove("recording");
hint.classList.add("hidden");
if (recStream) {
recStream.getTracks().forEach(t => t.stop());
recStream = null;
}
// Workers未接続なら案内だけ
if (!WORKER_BASE_URL) {
addMessage("ai", "[Feedback]\nマイク録音はできました。\n次にCloudflare WorkersでGemini(STT)をつなぐと、録音→文字起こし→自動送信ができます。\n今はタイピングで進めてOKです。");
return;
}
// ここはWorkers接続後に有効化(/api/stt)
try {
const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || "audio/webm" });
audioChunks = [];
const base64 = await blobToBase64(blob);
const res = await fetch(`${WORKER_BASE_URL}/api/stt`, {
method: "POST",
headers: { "Content-Type":"application/json" },
body: JSON.stringify({ audioBase64: base64, mimeType: blob.type || "audio/webm" })
});
const data = await res.json();
const transcript = (data?.text || "").trim();
if (!transcript) {
addMessage("ai", "[Feedback]\nうまく聞き取れませんでした。もう一度ゆっくり話してください。");
return;
}
document.getElementById("user-input").value = transcript;
setTimeout(handleSend, 120);
} catch(e) {
addMessage("ai", "[Error]\nSTT通信に失敗しました。");
}
};
mediaRecorder.start();
} catch (e) {
addMessage("ai", "[Feedback]\nマイクの許可が必要です。Safariの設定でマイクを許可してください。");
}
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(String(r.result).split(",")[1]);
r.onerror = reject;
r.readAsDataURL(blob);
});
}
// Enterで送信
document.getElementById("user-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") handleSend();
});
</script>
</body>
</html>