Lite quiz dev (Cloudflare Workers + D1)
ここでは、Cloudflare Workers と D1 を前提とした「軽量クイズ」の HTML テンプレートを公開しています。 下記のコードブロックを、そのまま自分のサイトの HTML に貼り付けて使うことも可能です。
前提条件(Cloudflare 運用セット) / Prerequisites (Cloudflare set)
このページは Cloudflare Workers + D1 を前提にした lite quiz 用の開発メモです。
This page is a dev note for a lite quiz implementation on Cloudflare Workers + D1.
-
Cloudflare のアカウント
Cloudflare account
-
自分のドメインを Cloudflare DNS に載せていること
A custom domain is managed by Cloudflare DNS.
-
wrangler v4.51(Windows ターミナル前提)
Commands in this note assume wrangler v4.51 on Windows Terminal.
-
D1 データベースを作成する(→「D1 の準備」を参照)
Create a D1 database (see "D1 setup" section).
-
クイズ用 API ルートの決定(→「API ルート設定」を参照)
Decide an API route for the quiz Worker (see "API route"). このルート(例:
quiz.weputit.com)に来たリクエストをすべてこの Worker に通し、 静的ファイルと/save_resultの API をまとめて処理します。 -
wrangler login 済み(→「wrangler 認証」の節を参照)
wrangler loginis completed (see "wrangler auth").
※ CDN を使わず別ホスティングのページから呼び出す場合は、
Workers 側で Access-Control-Allow-Origin などの CORS 設定が必要です。
このページでは詳細は扱いません。
If you call this API from another origin without Cloudflare CDN,
you must configure CORS (Access-Control-Allow-Origin, etc.) in Workers.
フォルダ構成 / Folder layout
このセットは、Cloudflare Workers + D1 用の最小構成です。
.html / .toml / .js はすべてテキストファイルなので、メモ帳などのテキストエディタで作成できます。
画像はあらかじめ WebP 形式に変換して img/ フォルダに保存してください。
The project structure for the lite quiz (Workers + D1).
root/
├── wrangler.toml
│ # name = "quiz-worker"
│ # main = "worker/save_result.js"
│ # assets = { directory = "lite_quiz" }
│
├── worker/
│ └── save_result.js
│ # /save_result に POST → D1 へ INSERT
│
└── lite_quiz/
├── index.html
│ # クイズ本体ページ(logo, 説明文, #quizContainer, #scoreBox)
│
├── lite_quiz.css
│ # 全体レイアウト+ .quiz-* 系スタイル
│
├── lite_quiz.js
│ # quiz_sample.json を読み込み → 出題 → /save_result に送信
│
├── quiz_sample.json
│ # dataset_id と questions 定義(image_path=./img/…)
│
└── img/
├── logo.webp
├── front_hero.webp
├── picto_0001.webp
├── ...
wrangler.toml のポイント / wrangler.toml overview
root/wrangler.toml で、Worker のエントリ・静的アセット・D1 の binding をまとめて設定しています。 この1枚で「静的ファイル(lite_quiz 配下)の配信」と「/save_result への API 処理」を同じ Worker に担当させます。
name = "quiz-worker"
main = "worker/save_result.js"
compatibility_date = "2025-11-29" # プロジェクト作成時の日付。通常は変更しない
workers_dev = false
routes = [
{ pattern = "quiz.weputit.com", custom_domain = true }
]
# pattern はあなたのドメインに変える
assets = { directory = "lite_quiz" }
[[d1_databases]]
binding = "quiz_db"
database_name = "quiz_db"
database_id = "…" # Cloudflare D1 の画面に出る ID を貼る
・main で Worker の JS ファイル(worker/save_result.js)を指定
・assets.directory で lite_quiz フォルダを静的アセットとして公開
・binding = "quiz_db" を Worker 側の env.quiz_db として使う
wrangler の接続 / wrangler auth
-
初回は CLI から wrangler をグローバルインストールして接続した:
ブラウザで Cloudflare にログイン済みだったため、npm install -g wrangler wrangler loginwrangler login実行時にそのままアカウント連携が完了した。
wrangler のインストール方法や認証フローの詳細は、Cloudflare 公式ドキュメントに従ってください。
このメモでは、wrangler --version と wrangler login が成功している状態を前提とします。
D1 の準備 / D1 setup
D1 データベースを quiz-worker から使うための最低限の手順です。
Minimal steps to use a D1 database from the quiz Worker.
-
Windows ターミナルで
rootフォルダに移動し、次を実行する:wrangler d1 create quiz_db実行結果に、データベース名と ID(UUID)が表示されるのでメモしておきます。
この時点までにwrangler --versionが通り、wrangler loginが完了していることを前提にします。 -
root/wrangler.tomlの[[d1_databases]]を、この ID を使って設定する:[[d1_databases]] binding = "quiz_db" database_name = "quiz_db" database_id = "ここに wrangler d1 create で出た ID を貼る" -
必要であれば Cloudflare ダッシュボード左メニューの「D1」から
quiz_dbを開き、同じdatabase_idが表示されていることを確認しておく。
binding 名(ここでは quiz_db)は、Worker 側の env.quiz_db として参照します。
最初に一度、DB(データベース)に results テーブルを作成します(root フォルダで実行):
wrangler d1 execute quiz_db --command "
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id TEXT,
question_id TEXT,
answer TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"
テーブルが作成できたか確認したい場合は、次のどちらかの方法でチェックできます。
-
Cloudflare ダッシュボード >
D1>quiz_dbを開き、 コンソールで.tablesやSELECT * FROM results LIMIT 1;を実行する。 -
ローカルのターミナル(root)で:
wrangler d1 execute quiz_db --command "SELECT * FROM results LIMIT 5;"
API ルート設定 / API route
このセットでは、ブラウザから https://quiz.weputit.com/save_result に
POST されたデータを、Cloudflare Worker (save_result.js) が受け取り、
D1 (quiz_db) に書き込みます。
HTML / JS 側と wrangler.toml の URL をそろえるだけです。
つまり、この構成では Cloudflare Worker が API を処理して D1 データベースに書き込むということです。
-
wrangler.tomlのroutesでquiz.weputit.comを Worker に割り当てるDefinequiz.weputit.cominroutesso requests go to the Worker. -
Worker 側の
save_result.jsでは/save_resultへの POST を処理するThe Worker handlesPOST /save_resultand writes to D1. -
lite_quiz.jsの送信先 URL をhttps://quiz.weputit.com/save_resultにしてそろえるInlite_quiz.js, set the fetch URL to the same endpoint.
save_result.js / Worker ロジック
この Worker は /save_result への POST を受け取り、
D1 データベース quiz_db の results テーブルに書き込みます。
それ以外のパスは静的アセット(lite_quiz/ 以下のファイル)を返します。
The Worker handles POST /save_result and writes to D1, or serves static assets.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// POST: /save_result → D1 書き込み
if (url.pathname === "/save_result" && request.method === "POST") {
try {
const data = await request.json();
if (Array.isArray(data.answers)) {
for (const r of data.answers) {
await env.quiz_db
.prepare("INSERT INTO results (dataset_id, question_id, answer) VALUES (?, ?, ?)")
.bind(data.dataset_id, r.question_id, r.answer)
.run();
}
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// 静的ファイルの配信(wrangler assets 連携)
try {
const assetResponse = await env.__STATIC_CONTENT.fetch(request);
if (assetResponse && assetResponse.status !== 404) {
return assetResponse;
}
} catch (_) {
// 404 などは次の処理へ
}
return new Response("Not Found", { status: 404 });
},
};
index.html / Quiz page
root/lite_quiz/index.html は、クイズ本体のページです。
ヘッダーと説明文のあとに #quizContainer と #scoreBox を用意し、
最後に lite_quiz.js を読み込むだけの構成になっています。
Cloudflare Workers の assets.directory = "lite_quiz" から配信される前提です。
<head> 内に CSS の指定が必要です:
<link rel="stylesheet" href="./lite_quiz.css">
フッターの直前に次の行を置きます:
<script type="module" src="./lite_quiz.js"></script>
This is the main quiz page, served as a static asset from lite_quiz/ via Cloudflare Workers.
<!-- root/lite_quiz/index.html より、クイズ本体部分 -->
<main>
<div class="card">
<h1 class="text-center mb-4">🧩 Interactive Quiz</h1>
<p class="text-center mb-2">Please answer in your native language.</p>
<p class="text-center mb-2">
This project uses the language of each response and the region selected by respondents
to visualize differences in lifestyles and cultural values as overall trends.
...
</p>
<p class="text-center mb-2">
この統計は、回答に用いられた言語と、回答者が選んだ地域に基づいて、...
</p>
<div class="quiz-container" id="quizContainer"></div>
<div id="scoreBox"></div>
</div>
</main>
<script type="module" src="./lite_quiz.js"></script>
lite_quiz.js / Frontend logic
root/lite_quiz/lite_quiz.js は、クイズの問題を読み込み、画面に出題し、
最後に回答を Cloudflare Worker (/save_result) へ送信するフロント側の処理です。
This script loads questions, renders the quiz UI, and sends results to the Worker.
"use strict";
// JSONファイルのパス
const QUIZ_SPEC_URL = "./quiz_sample.json";
let quizState = {
questions: [],
currentIndex: 0, // 現在の質問番号
results: [], // { question_id, answer } の配列
datasetId: ""
};
document.addEventListener("DOMContentLoaded", () => {
const quizContainer = document.getElementById("quizContainer");
const scoreBox = document.getElementById("scoreBox");
if (!quizContainer) {
console.error("#quizContainer not found");
return;
}
loadQuestionsFromJson(quizContainer, scoreBox);
});
// JSONをロード
async function loadQuestionsFromJson(quizContainer, scoreBox) {
quizContainer.innerHTML = "<p>Loading...</p>";
try {
const res = await fetch(QUIZ_SPEC_URL, { cache: "no-cache" });
if (!res.ok) throw new Error("Failed to fetch JSON");
const data = await res.json();
const questions = Array.isArray(data.questions) ? data.questions : [];
if (!questions.length) {
quizContainer.innerHTML = "<p>No questions registered yet.</p>";
return;
}
quizState.questions = questions;
quizState.currentIndex = 0;
quizState.results = [];
quizState.datasetId = data.dataset_id || "";
renderCurrentQuestion(quizContainer, scoreBox);
} catch (e) {
console.error(e);
quizContainer.innerHTML =
"<p>Failed to load questions. Please try again later.</p>";
}
}
// 現在の質問を表示
function renderCurrentQuestion(quizContainer, scoreBox) {
const questions = quizState.questions;
const idx = quizState.currentIndex;
const q = questions[idx];
quizContainer.innerHTML = "";
scoreBox.textContent = "";
const form = document.createElement("form");
form.id = "quizForm";
const wrap = document.createElement("div");
wrap.className = "quiz-question-block";
// 画像
if (q.image_path) {
const img = document.createElement("img");
img.src = q.image_path;
img.alt = "";
img.className = "quiz-image";
wrap.appendChild(img);
}
// 質問文
const title = document.createElement("p");
title.className = "quiz-question";
title.textContent = `Q${idx + 1}. ${q.question_short || ""}`;
wrap.appendChild(title);
// 回答UI
const name = `q_${q.question_id}`;
if (q.type === "choice" && Array.isArray(q.choices)) {
const choicesWrap = document.createElement("div");
choicesWrap.className = "quiz-choices";
q.choices.forEach((opt, optIndex) => {
const row = document.createElement("div");
row.className = "quiz-choice-row";
const input = document.createElement("input");
input.type = "radio";
input.name = name;
input.value = opt;
input.id = `${name}_opt${optIndex}`;
const label = document.createElement("label");
label.setAttribute("for", input.id);
label.textContent = opt;
row.appendChild(input);
row.appendChild(label);
choicesWrap.appendChild(row);
});
// 「その他」入力欄
if (q.allow_other_text) {
const otherWrap = document.createElement("div");
otherWrap.className = "quiz-other";
const otherInput = document.createElement("input");
otherInput.type = "text";
otherInput.name = `${name}_other`;
otherInput.id = `${name}_other`; // ID を付与
const otherLabel = document.createElement("label");
otherLabel.textContent = "Other:";
otherLabel.setAttribute("for", otherInput.id); // for で関連付け
otherWrap.appendChild(otherLabel);
otherWrap.appendChild(otherInput);
choicesWrap.appendChild(otherWrap);
}
wrap.appendChild(choicesWrap);
} else {
// 記述式
const input = document.createElement("input");
input.type = "text";
input.name = name;
input.className = "quiz-text-input";
input.placeholder = "Type your answer here";
wrap.appendChild(input);
}
form.appendChild(wrap);
// ボタン設定
const actions = document.createElement("div");
actions.className = "quiz-actions";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "quiz-submit-btn";
btn.textContent =
idx === quizState.questions.length - 1 ? "View Results" : "Next Question";
btn.addEventListener("click", () =>
handleCurrentSubmit(form, scoreBox)
);
actions.appendChild(btn);
form.appendChild(actions);
quizContainer.appendChild(form);
}
// 回答を保存して次へ
function handleCurrentSubmit(form, scoreBox) {
const q = quizState.questions[quizState.currentIndex];
const name = `q_${q.question_id}`;
let answer = "";
const radios = form.querySelectorAll(`input[name="${name}"][type="radio"]`);
if (radios.length > 0) {
const checked = Array.from(radios).find((r) => r.checked);
answer = checked ? checked.value : "";
const other = form.querySelector(`input[name="${name}_other"]`);
if (other && other.value.trim()) {
answer = `Other: ${other.value.trim()}`;
}
} else {
const input = form.querySelector(`input[name="${name}"]`);
answer = input ? input.value.trim() : "";
}
// 無回答チェック
if (!answer || answer.trim() === "") {
alert("Please select or enter an answer before proceeding.");
return;
}
quizState.results.push({
question_id: q.question_id,
answer,
});
// 最後なら結果表示へ
if (quizState.currentIndex >= quizState.questions.length - 1) {
showFinalResult(scoreBox);
const quizContainer = document.getElementById("quizContainer");
if (quizContainer) {
quizContainer.innerHTML = "<p>All questions answered.</p>";
}
return;
}
// 次の質問
quizState.currentIndex += 1;
const quizContainer = document.getElementById("quizContainer");
if (quizContainer) {
renderCurrentQuestion(quizContainer, scoreBox);
}
}
// 最後に結果表示 + 送信ボタン
async function showFinalResult(scoreBox) {
scoreBox.textContent = "";
const pre = document.createElement("pre");
pre.textContent =
"Thank you for your answers.\n\n" +
quizState.results
.map((r, i) => {
const a = r.answer && r.answer !== "" ? r.answer : "(No answer)";
return `Q${i + 1}: ${a}`;
})
.join("\n");
scoreBox.appendChild(pre);
// 「送信」ボタンを追加
const sendBtn = document.createElement("button");
sendBtn.textContent = "Send Results";
sendBtn.className = "quiz-send-btn";
sendBtn.addEventListener("click", async () => {
try {
const res = await fetch("https://quiz.weputit.com/save_result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dataset_id: quizState.datasetId,
answers: quizState.results,
}),
});
if (!res.ok) {
console.error("Server error:", res.statusText);
alert("Failed to send results.");
} else {
alert("Results sent successfully!");
}
} catch (err) {
console.error("Send failed:", err);
alert("Error sending results.");
}
});
scoreBox.appendChild(sendBtn);
}
quiz_sample.json / Question spec
root/lite_quiz/quiz_sample.json は、クイズで使う問題セットを定義するファイルです。
.jsで引用してhtmlに書き込まれます。仕様は4択+記述式です。
dataset_id と questions 配列を持ち、各問題には
question_id, image_path, question_short,
type, choices, allow_other_text を含めます。
This JSON file defines the quiz dataset (ID and question list).
{
"dataset_id": "weputit_com_2025",
"questions": [
{
"question_id": "q001",
"image_path": "./img/front_hero.webp",
"question_short": "your region — seasons, colors, and everyday food : Alin sa mga sumusunod ang tamang pagsasalin sa Tagalog ng larawan?",
"type": "choice",
"choices": [
"Sa iyong rehiyon — mga panahon, kulay, at pagkaing pang-araw-araw",
"Iyong rehiyon — mga panahon, kulay, at pagkain sa araw-araw",
"Ang rehiyon mo — mga panahon, kulay, at pagkain sa araw-araw",
"Sa iyong rehiyon — mga panahon, kulay, at pagkaing araw-araw"
],
"allow_other_text": true
},
{
"question_id": "q002",
"image_path": "./img/picto_0001.webp",
"question_short": "What is this person in the pictogram doing, and in what situation? /Ano ang ginagawa...?",
"type": "choice",
"choices": ["Walking", "Waiting", "Surprised", "Exercising"],
"allow_other_text": true
}
// ... 以降 q003〜q010
]
}
lite_quiz.css / Styles
root/lite_quiz/lite_quiz.css は、クイズページの最低限のレイアウトと装飾だけを定義します。
レイアウト系・カード枠・クイズ用コンポーネント用のクラスに分けています。
This stylesheet defines basic layout and quiz UI styles.
-
ページ共通レイアウト
body,.page,header,.cardなどで 中央寄せ・余白・背景色を決めています。 -
クイズ全体の枠
.quiz-container,.quiz-question-block,.quiz-image,.quiz-questionなどで、問題ブロックと画像のレイアウトを指定します。 -
回答UI
.quiz-choices,.quiz-choice-row,.quiz-other,.quiz-text-input,.quiz-submit-btn,.quiz-send-btnで 選択肢やボタンの見た目を定義しています。 -
インフォ用テキスト
この dev ページ全体の
.notice-text,.count-textは 補足説明と英語サブテキストのための小さめのグレー文字です。
/* 抜粋: ベースレイアウト */
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #f3f4f6;
color: #111827;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}
.card {
background: #ffffff;
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
}
/* ... quiz-container / quiz-question-block / quiz-choices などが続く ... */
Deploy / 更新時のコマンド
設定やコードを保存したあと、root フォルダで次を実行すると
Cloudflare に反映されます。
After editing files, run this in the root folder to deploy to Cloudflare.
wrangler deploy