lite quiz dev notes

every post and answer is a small statement

Powered by donations and ads.

Go to main site (weputit.com)

Lite quiz dev (Cloudflare Workers + D1)

ここでは、Cloudflare Workers と D1 を前提とした「軽量クイズ」の HTML テンプレートを公開しています。 下記のコードブロックを、そのまま自分のサイトの HTML に貼り付けて使うことも可能です。

lite quiz (this project)

前提条件(Cloudflare 運用セット) / Prerequisites (Cloudflare set)

このページは Cloudflare Workers + D1 を前提にした lite quiz 用の開発メモです。
This page is a dev note for a lite quiz implementation on Cloudflare Workers + D1.

※ 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

wrangler のインストール方法や認証フローの詳細は、Cloudflare 公式ドキュメントに従ってください。 このメモでは、wrangler --versionwrangler login が成功している状態を前提とします。

D1 の準備 / D1 setup

D1 データベースを quiz-worker から使うための最低限の手順です。
Minimal steps to use a D1 database from the quiz Worker.

  1. Windows ターミナルで root フォルダに移動し、次を実行する:
    wrangler d1 create quiz_db

    実行結果に、データベース名と ID(UUID)が表示されるのでメモしておきます。
    この時点までに wrangler --version が通り、 wrangler login が完了していることを前提にします。

  2. root/wrangler.toml[[d1_databases]] を、この ID を使って設定する:
    [[d1_databases]]
    binding = "quiz_db"
    database_name = "quiz_db"
    database_id = "ここに wrangler d1 create で出た ID を貼る"
    
  3. 必要であれば 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
);
"

テーブルが作成できたか確認したい場合は、次のどちらかの方法でチェックできます。

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 データベースに書き込むということです。

save_result.js / Worker ロジック

この Worker は /save_result への POST を受け取り、 D1 データベース quiz_dbresults テーブルに書き込みます。 それ以外のパスは静的アセット(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_idquestions 配列を持ち、各問題には 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 {
  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