Openai/6926aad9-faf0-8007-bcb8-9e64cd1f86b1


This conversation was exported from ChatGPT[1]

gpt-5-1-pro edit

User: フロントエンド完結のメモアプリをつくりたい edit

フロントエンド完結のメモアプリをつくりたい データはブラウザのストレージに保存する メモは複数個作成でき、トップ画面は2ペインで、左はメモ一覧表示とCRUDをサポート、右がメモ編集画面 メモは自動保存する メモの名前は先頭から流用し、内部的には機械的なIDでkvで保存すればいい

Assistant: じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 edit

じゃあ、要件そのまま満たす「フロントエンド完結メモアプリ」のサンプルを丸ごと1ファイルで置きますね。 index.htmlとして保存 → ブラウザで開くだけで動きます。

  • 完全フロントエンド
  • localStorageに保存
  • 左ペイン:メモ一覧 & CRUD
  • 右ペイン:メモ編集
  • 入力と同時に自動保存(少しデバウンス)
  • メモ名 = 先頭行を流用(空なら「無題メモ」)
  • 内部的には機械的IDでKV保存

====

<!DOCTYPE html> ====
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>ブラウザ完結メモアプリ</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    :root {
      --border-color: #ddd;
      --accent-color: #2563eb;
      --bg-sidebar: #f5f5f5;
      --bg-active: #e0edff;
      --text-muted: #666;
      --radius: 8px;
      --shadow: 0 8px 16px rgba(0,0,0,0.06);
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f3f4f6;
      color: #111827;
      height: 100vh;
      display: flex;
      align-items: stretch;
      justify-content: center;
    }

    .app-shell {
      margin: 16px;
      background: white;
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      display: flex;
      flex: 1;
      max-width: 1200px;
      min-height: 0;
    }

    .sidebar {
      width: 280px;
      min-width: 220px;
      max-width: 360px;
      border-right: 1px solid var(--border-color);
      background: var(--bg-sidebar);
      display: flex;
      flex-direction: column;
    }

    .sidebar-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 16px;
      border-bottom: 1px solid var(--border-color);
    }

    .sidebar-title {
      font-size: 15px;
      font-weight: 600;
    }

    .new-btn {
      border: none;
      border-radius: 999px;
      padding: 6px 12px;
      font-size: 13px;
      cursor: pointer;
      background: var(--accent-color);
      color: white;
      display: inline-flex;
      align-items: center;
      gap: 4px;
    }

    .new-btn span {
      font-size: 16px;
      line-height: 1;
      transform: translateY(-1px);
    }

    .memo-list {
      flex: 1;
      overflow-y: auto;
      padding: 8px;
    }

    .memo-item {
      background: white;
      border-radius: 6px;
      padding: 8px 10px;
      margin-bottom: 6px;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 8px;
      border: 1px solid transparent;
      transition: background 0.1s ease, border-color 0.1s ease, transform 0.05s ease;
    }

    .memo-item:hover {
      background: #eef2ff;
      transform: translateY(-1px);
    }

    .memo-item.active {
      background: var(--bg-active);
      border-color: var(--accent-color);
    }

    .memo-main {
      flex: 1;
      min-width: 0;
    }

    .memo-title {
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .memo-meta {
      font-size: 11px;
      color: var(--text-muted);
      margin-top: 2px;
    }

    .memo-delete {
      border: none;
      background: transparent;
      cursor: pointer;
      padding: 4px;
      border-radius: 999px;
      font-size: 14px;
      color: #9ca3af;
      flex-shrink: 0;
    }

    .memo-delete:hover {
      background: #fee2e2;
      color: #b91c1c;
    }

    .editor-pane {
      flex: 1;
      display: flex;
      flex-direction: column;
      min-width: 0;
    }

    .editor-header {
      padding: 10px 16px;
      border-bottom: 1px solid var(--border-color);
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }

    .editor-title {
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .editor-meta {
      font-size: 11px;
      color: var(--text-muted);
    }

    .editor-body {
      flex: 1;
      display: flex;
      flex-direction: column;
      min-height: 0;
    }

    .editor-textarea {
      flex: 1;
      padding: 12px 16px;
      border: none;
      resize: none;
      font-family: inherit;
      font-size: 14px;
      line-height: 1.6;
      outline: none;
      min-height: 0;
    }

    .editor-textarea::placeholder {
      color: #9ca3af;
    }

    .status-bar {
      font-size: 11px;
      color: var(--text-muted);
      padding: 4px 10px 8px 16px;
      border-top: 1px solid var(--border-color);
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    @media (max-width: 768px) {
      .app-shell {
        flex-direction: column;
      }
      .sidebar {
        width: 100%;
        max-width: none;
        border-right: none;
        border-bottom: 1px solid var(--border-color);
      }
    }
  </style>
</head>
<body>
  <div class="app-shell">
    <!-- 左ペイン:メモ一覧 -->
    <aside class="sidebar">
      <div class="sidebar-header">
        <div class="sidebar-title">メモ一覧</div>
        <button class="new-btn" id="newMemoBtn"><span></span>新規</button>
      </div>
      <div class="memo-list" id="memoList"></div>
    </aside>

    <!-- 右ペイン:エディタ -->
    <main class="editor-pane">
      <div class="editor-header">
        <div class="editor-title" id="currentTitle">メモが選択されていません</div>
        <div class="editor-meta" id="currentMeta"></div>
      </div>
      <div class="editor-body">
        <textarea
          id="editor"
          class="editor-textarea"
          placeholder="メモを書き始めてください…(自動保存されます)"
        ></textarea>
        <div class="status-bar">
          <span id="statusText">準備完了</span>
          <span id="charCount">0 文字</span>
        </div>
      </div>
    </main>
  </div>

  <script>
    (function () {
      const STORAGE_KEY = "memoApp.v1";

      /**
       * state:
       *  memos: { [id]: { id, content, updatedAt } }
       *  order: string[] (表示順)
       *  currentId: string | null
       */
      let state = {
        memos: {},
        order: [],
        currentId: null,
      };

      const memoListEl = document.getElementById("memoList");
      const editorEl = document.getElementById("editor");
      const newMemoBtn = document.getElementById("newMemoBtn");
      const currentTitleEl = document.getElementById("currentTitle");
      const currentMetaEl = document.getElementById("currentMeta");
      const statusTextEl = document.getElementById("statusText");
      const charCountEl = document.getElementById("charCount");

      let saveTimer = null;
      let isLoading = true;

      // ---- ストレージ関連 ----
      function loadFromStorage() {
        try {
          const raw = localStorage.getItem(STORAGE_KEY);
          if (!raw) return;

          const parsed = JSON.parse(raw);
          if (!parsed || typeof parsed !== "object") return;

          state.memos = parsed.memos || {};
          state.order = Array.isArray(parsed.order) ? parsed.order : [];
          state.currentId = parsed.currentId || null;
        } catch (e) {
          console.error("Failed to load memos:", e);
        }
      }

      function saveToStorage() {
        const payload = {
          memos: state.memos,
          order: state.order,
          currentId: state.currentId,
        };
        try {
          localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
        } catch (e) {
          console.error("Failed to save memos:", e);
          setStatus("保存に失敗しました(容量オーバーかもしれません)");
        }
      }

      // ---- ユーティリティ ----
      function createId() {
        return (
          "m_" +
          Date.now().toString(36) +
          "_" +
          Math.random().toString(16).slice(2)
        );
      }

      function getTitleFromContent(content) {
        const firstLine = (content || "").split(/\r?\n/)[0].trim();
        return firstLine || "無題メモ";
      }

      function formatDate(ts) {
        const d = new Date(ts);
        const yyyy = d.getFullYear();
        const mm = String(d.getMonth() + 1).padStart(2, "0");
        const dd = String(d.getDate()).padStart(2, "0");
        const hh = String(d.getHours()).padStart(2, "0");
        const mi = String(d.getMinutes()).padStart(2, "0");
        return <code>${yyyy}/${mm}/${dd} ${hh}:${mi}</code>;
      }

      function setStatus(text) {
        statusTextEl.textContent = text;
      }

      function updateCharCount() {
        charCountEl.textContent = <code>${editorEl.value.length} 文字</code>;
      }

      // ---- CRUD ----
      function createMemo() {
        const id = createId();
        const now = Date.now();
        const memo = {
          id,
          content: "",
          updatedAt: now,
        };

        state.memos[id] = memo;
        state.order.unshift(id); // 新しいメモを先頭に
        state.currentId = id;
        saveToStorage();
        renderList();
        renderCurrentMemo();
        editorEl.focus();
      }

      function deleteMemo(id) {
        if (!state.memos[id]) return;

        const ok = confirm("このメモを削除しますか?");
        if (!ok) return;

        delete state.memos[id];
        state.order = state.order.filter((mId) => mId !== id);

        if (state.currentId === id) {
          state.currentId = state.order[0] || null;
        }

        if (!state.currentId && state.order.length === 0) {
          // メモが一つもない場合は空のメモを1つ作る
          createMemo();
          return;
        }

        saveToStorage();
        renderList();
        renderCurrentMemo();
      }

      function selectMemo(id) {
        if (!state.memos[id]) return;
        state.currentId = id;
        saveToStorage();
        renderList();
        renderCurrentMemo();
      }

      function updateCurrentMemoContent(content) {
        const id = state.currentId;
        if (!id) return;
        const memo = state.memos[id];
        if (!memo) return;

        memo.content = content;
        memo.updatedAt = Date.now();

        // 表示順を前に持ってくる(最近編集したものを上にしたい場合)
        const idx = state.order.indexOf(id);
        if (idx > 0) {
          state.order.splice(idx, 1);
          state.order.unshift(id);
        }

        // 自動保存(デバウンス)
        if (saveTimer) clearTimeout(saveTimer);
        setStatus("保存中…");
        saveTimer = setTimeout(() => {
          saveToStorage();
          renderList();
          renderCurrentMetaOnly();
          setStatus("保存しました");
        }, 500);
      }

      // ---- 描画 ----
      function renderList() {
        memoListEl.innerHTML = "";

        if (state.order.length === 0) {
          const emptyEl = document.createElement("div");
          emptyEl.style.padding = "12px";
          emptyEl.style.fontSize = "13px";
          emptyEl.style.color = "#9ca3af";
          emptyEl.textContent = "メモがありません。「新規」ボタンから作成できます。";
          memoListEl.appendChild(emptyEl);
          return;
        }

        state.order.forEach((id) => {
          const memo = state.memos[id];
          if (!memo) return;

          const itemEl = document.createElement("div");
          itemEl.className =
            "memo-item" + (id === state.currentId ? " active" : "");
          itemEl.addEventListener("click", () => selectMemo(id));

          const mainEl = document.createElement("div");
          mainEl.className = "memo-main";

          const titleEl = document.createElement("div");
          titleEl.className = "memo-title";
          titleEl.textContent = getTitleFromContent(memo.content);

          const metaEl = document.createElement("div");
          metaEl.className = "memo-meta";
          metaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;

          mainEl.appendChild(titleEl);
          mainEl.appendChild(metaEl);

          const deleteBtn = document.createElement("button");
          deleteBtn.className = "memo-delete";
          deleteBtn.innerHTML = "✕";
          deleteBtn.title = "削除";
          deleteBtn.addEventListener("click", (e) => {
            e.stopPropagation();
            deleteMemo(id);
          });

          itemEl.appendChild(mainEl);
          itemEl.appendChild(deleteBtn);
          memoListEl.appendChild(itemEl);
        });
      }

      function renderCurrentMetaOnly() {
        const id = state.currentId;
        if (!id || !state.memos[id]) {
          currentMetaEl.textContent = "";
          return;
        }
        const memo = state.memos[id];
        currentTitleEl.textContent = getTitleFromContent(memo.content);
        currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;
      }

      function renderCurrentMemo() {
        const id = state.currentId;

        if (!id || !state.memos[id]) {
          currentTitleEl.textContent = "メモが選択されていません";
          currentMetaEl.textContent = "";
          editorEl.value = "";
          editorEl.disabled = true;
          updateCharCount();
          return;
        }

        const memo = state.memos[id];
        editorEl.disabled = false;
        editorEl.value = memo.content;
        currentTitleEl.textContent = getTitleFromContent(memo.content);
        currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;
        updateCharCount();
      }

      // ---- イベント ----
      newMemoBtn.addEventListener("click", () => {
        createMemo();
      });

      editorEl.addEventListener("input", () => {
        if (isLoading) return;
        updateCharCount();
        updateCurrentMemoContent(editorEl.value);
        renderCurrentMetaOnly(); // タイトルも更新
      });

      window.addEventListener("beforeunload", () => {
        // 念のため即保存
        saveToStorage();
      });

      // ---- 初期化 ----
      function init() {
        loadFromStorage();

        // メモが全くなければ1つ作る
        if (Object.keys(state.memos).length === 0 || state.order.length === 0) {
          state.memos = {};
          state.order = [];
          state.currentId = null;
          createMemo();
        } else {
          // currentIdがおかしい場合は先頭を選択
          if (!state.currentId || !state.memos[state.currentId]) {
            state.currentId = state.order[0] || null;
          }
        }

        renderList();
        renderCurrentMemo();
        isLoading = false;
        setStatus("自動保存が有効です");
      }

      init();
    })();
  </script>
</body>
</html>

- データ構造(localStorage) ``js { memos: { [id]: { id, content, updatedAt } }, order: [id1, id2, ...], // 表示順 currentId: "m_..." } `` edit

  • タイトル - getTitleFromContent で「先頭行 or 無題メモ」を生成 - 編集のたびにタイトルも再計算して左ペインに反映
  • 自動保存 - textarea の input で updateCurrentMemoContent - 500ms デバウンスして localStorage に保存 - 同時にメモ一覧の最上部に移動(最近編集したものが上に来る)
  • CRUD - 新規: createMemo() で空メモ作成+選択 - 削除: 左ペインの「✕」ボタンで confirm → 削除 - 一覧クリックで選択 - 更新は編集+自動保存

もし「メモをタグでフィルタしたい」「Markdownプレビューを付けたい」「IndexedDBにしたい」みたいな拡張があれば、それ前提で次のバージョンも一緒に考えましょう。

User: 10万文字のメモも保存したい。ローカルストレージで可能? edit

10万文字のメモも保存したい。ローカルストレージで可能?

Assistant: 結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍 edit

結論から言うと、10万文字のメモくらいなら localStorage で全然いけます 👍

- localStorage はブラウザにもよりますが、1オリジンあたりだいたい 5MB 前後(5〜10MB) が上限です。MDN Web Docs[2] edit

  • 文字列は内部的にだいたい 2バイト/1文字(UTF-16) と見ておくと安全です。

なので:

  • 10万文字 × 2バイト ≒ 200KB ≒ 0.2MB

つまり、1メモ10万文字でも 5MB 制限に対して 1/25 くらい しか使いません。

アプリ全体で JSON のオーバーヘッドや他のメモを含めても、

  • 10万文字メモを数個〜十数個 → まず問題なし
  • 10万文字メモを何十個もため込む → さすがに 5MB 近くなってくる可能性あり

というイメージです。

1. 上限超えるとどうなる? localStorage.setItem したときに QuotaExceededError が投げられます。DEV Community[3] → try/catch で握って「容量オーバーです」みたいなメッセージを出すと安心。 ``js try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (e) { if (e.name === "QuotaExceededError") { alert("保存容量の上限に達しました。古いメモを削除してください。"); } } `` edit

  1. localStorage は同期処理 読み書きが同期なので、巨大な JSON を頻繁に書き換えるとメインスレッドを少しブロックします。 ・今の規模(数百KB〜数MB)なら大体問題ないですが、 ・オフラインノートアプリ級に巨大化させるなら IndexedDB に移行を検討したほうがいいです。web.dev[4]

- 10万文字のメモ単体なら localStorage で余裕。 edit

  • そのアプリ構成(メモを KV で保存+一覧用の index)でも特に問題なし。
  • もし「10万文字メモ × 100個」みたいな世界に行きそうなら、 - 「古いメモから削除」ロジックを入れる - もしくは IndexedDB へ移行 のどちらかを考えておくと安心です。

もしよければ、「今想定してる最大メモ数」がどれくらいか教えてもらえれば、それ前提で安全ラインをざっくり見積もったり、設計アドバイスもできます!

User: indexDBに切り替えた版も欲しい edit

indexDBに切り替えた版も欲しい

Assistant: localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。 edit

localStorage版と見た目・挙動はほぼ同じで、保存先だけ IndexedDB に切り替えた版を丸ごと置きますね。 index.htmlとして保存 → ブラウザで開けばそのまま動きます。

IndexedDB 版 index.html edit

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>ブラウザ完結メモアプリ (IndexedDB)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    :root {
      --border-color: #ddd;
      --accent-color: #2563eb;
      --bg-sidebar: #f5f5f5;
      --bg-active: #e0edff;
      --text-muted: #666;
      --radius: 8px;
      --shadow: 0 8px 16px rgba(0,0,0,0.06);
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f3f4f6;
      color: #111827;
      height: 100vh;
      display: flex;
      align-items: stretch;
      justify-content: center;
    }

    .app-shell {
      margin: 16px;
      background: white;
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      display: flex;
      flex: 1;
      max-width: 1200px;
      min-height: 0;
    }

    .sidebar {
      width: 280px;
      min-width: 220px;
      max-width: 360px;
      border-right: 1px solid var(--border-color);
      background: var(--bg-sidebar);
      display: flex;
      flex-direction: column;
    }

    .sidebar-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 16px;
      border-bottom: 1px solid var(--border-color);
    }

    .sidebar-title {
      font-size: 15px;
      font-weight: 600;
    }

    .new-btn {
      border: none;
      border-radius: 999px;
      padding: 6px 12px;
      font-size: 13px;
      cursor: pointer;
      background: var(--accent-color);
      color: white;
      display: inline-flex;
      align-items: center;
      gap: 4px;
    }

    .new-btn span {
      font-size: 16px;
      line-height: 1;
      transform: translateY(-1px);
    }

    .memo-list {
      flex: 1;
      overflow-y: auto;
      padding: 8px;
    }

    .memo-item {
      background: white;
      border-radius: 6px;
      padding: 8px 10px;
      margin-bottom: 6px;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 8px;
      border: 1px solid transparent;
      transition: background 0.1s ease, border-color 0.1s ease, transform 0.05s ease;
    }

    .memo-item:hover {
      background: #eef2ff;
      transform: translateY(-1px);
    }

    .memo-item.active {
      background: var(--bg-active);
      border-color: var(--accent-color);
    }

    .memo-main {
      flex: 1;
      min-width: 0;
    }

    .memo-title {
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .memo-meta {
      font-size: 11px;
      color: var(--text-muted);
      margin-top: 2px;
    }

    .memo-delete {
      border: none;
      background: transparent;
      cursor: pointer;
      padding: 4px;
      border-radius: 999px;
      font-size: 14px;
      color: #9ca3af;
      flex-shrink: 0;
    }

    .memo-delete:hover {
      background: #fee2e2;
      color: #b91c1c;
    }

    .editor-pane {
      flex: 1;
      display: flex;
      flex-direction: column;
      min-width: 0;
    }

    .editor-header {
      padding: 10px 16px;
      border-bottom: 1px solid var(--border-color);
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }

    .editor-title {
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .editor-meta {
      font-size: 11px;
      color: var(--text-muted);
    }

    .editor-body {
      flex: 1;
      display: flex;
      flex-direction: column;
      min-height: 0;
    }

    .editor-textarea {
      flex: 1;
      padding: 12px 16px;
      border: none;
      resize: none;
      font-family: inherit;
      font-size: 14px;
      line-height: 1.6;
      outline: none;
      min-height: 0;
    }

    .editor-textarea::placeholder {
      color: #9ca3af;
    }

    .status-bar {
      font-size: 11px;
      color: var(--text-muted);
      padding: 4px 10px 8px 16px;
      border-top: 1px solid var(--border-color);
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    @media (max-width: 768px) {
      .app-shell {
        flex-direction: column;
      }
      .sidebar {
        width: 100%;
        max-width: none;
        border-right: none;
        border-bottom: 1px solid var(--border-color);
      }
    }
  </style>
</head>
<body>
  <div class="app-shell">
    <!-- 左ペイン:メモ一覧 -->
    <aside class="sidebar">
      <div class="sidebar-header">
        <div class="sidebar-title">メモ一覧</div>
        <button class="new-btn" id="newMemoBtn"><span></span>新規</button>
      </div>
      <div class="memo-list" id="memoList"></div>
    </aside>

    <!-- 右ペイン:エディタ -->
    <main class="editor-pane">
      <div class="editor-header">
        <div class="editor-title" id="currentTitle">メモが選択されていません</div>
        <div class="editor-meta" id="currentMeta"></div>
      </div>
      <div class="editor-body">
        <textarea
          id="editor"
          class="editor-textarea"
          placeholder="メモを書き始めてください…(自動保存されます)"
        ></textarea>
        <div class="status-bar">
          <span id="statusText">準備完了</span>
          <span id="charCount">0 文字</span>
        </div>
      </div>
    </main>
  </div>

  <script>
    (function () {
      // ==== IndexedDB 設定 ====
      const DB_NAME = "memoAppDB.v1";
      const DB_VERSION = 1;
      const MEMOS_STORE = "memos";
      const STATE_STORE = "state";

      let dbPromise = null;

      function openDB() {
        if (!("indexedDB" in window)) {
          alert("このブラウザは IndexedDB に対応していません。");
          throw new Error("IndexedDB not supported");
        }
        if (dbPromise) return dbPromise;

        dbPromise = new Promise((resolve, reject) => {
          const request = indexedDB.open(DB_NAME, DB_VERSION);

          request.onupgradeneeded = (event) => {
            const db = request.result;
            if (!db.objectStoreNames.contains(MEMOS_STORE)) {
              db.createObjectStore(MEMOS_STORE, { keyPath: "id" });
            }
            if (!db.objectStoreNames.contains(STATE_STORE)) {
              db.createObjectStore(STATE_STORE, { keyPath: "key" });
            }
          };

          request.onsuccess = () => {
            resolve(request.result);
          };

          request.onerror = () => {
            reject(request.error);
          };
        });

        return dbPromise;
      }

      async function getAllMemos() {
        const db = await openDB();
        return new Promise((resolve, reject) => {
          const tx = db.transaction(MEMOS_STORE, "readonly");
          const store = tx.objectStore(MEMOS_STORE);
          const req = store.getAll();
          req.onsuccess = () => {
            resolve(req.result || []);
          };
          req.onerror = () => reject(req.error);
        });
      }

      async function putMemo(memo) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
          const tx = db.transaction(MEMOS_STORE, "readwrite");
          const store = tx.objectStore(MEMOS_STORE);
          store.put(memo);
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        });
      }

      async function deleteMemoFromDB(id) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
          const tx = db.transaction(MEMOS_STORE, "readwrite");
          const store = tx.objectStore(MEMOS_STORE);
          store.delete(id);
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        });
      }

      async function getState(key) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
          const tx = db.transaction(STATE_STORE, "readonly");
          const store = tx.objectStore(STATE_STORE);
          const req = store.get(key);
          req.onsuccess = () => {
            const result = req.result;
            resolve(result ? result.value : null);
          };
          req.onerror = () => reject(req.error);
        });
      }

      async function setState(key, value) {
        const db = await openDB();
        return new Promise((resolve, reject) => {
          const tx = db.transaction(STATE_STORE, "readwrite");
          const store = tx.objectStore(STATE_STORE);
          store.put({ key, value });
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        });
      }

      // ==== アプリ状態 ====
      let state = {
        memos: {},   // id -> { id, content, updatedAt }
        order: [],   // 表示順 id[]
        currentId: null,
      };

      const memoListEl = document.getElementById("memoList");
      const editorEl = document.getElementById("editor");
      const newMemoBtn = document.getElementById("newMemoBtn");
      const currentTitleEl = document.getElementById("currentTitle");
      const currentMetaEl = document.getElementById("currentMeta");
      const statusTextEl = document.getElementById("statusText");
      const charCountEl = document.getElementById("charCount");

      let saveTimer = null;
      let isLoading = true;

      // ==== ユーティリティ ====
      function createId() {
        return (
          "m_" +
          Date.now().toString(36) +
          "_" +
          Math.random().toString(16).slice(2)
        );
      }

      function getTitleFromContent(content) {
        const firstLine = (content || "").split(/\r?\n/)[0].trim();
        return firstLine || "無題メモ";
      }

      function formatDate(ts) {
        const d = new Date(ts);
        const yyyy = d.getFullYear();
        const mm = String(d.getMonth() + 1).padStart(2, "0");
        const dd = String(d.getDate()).padStart(2, "0");
        const hh = String(d.getHours()).padStart(2, "0");
        const mi = String(d.getMinutes()).padStart(2, "0");
        return <code>${yyyy}/${mm}/${dd} ${hh}:${mi}</code>;
      }

      function setStatus(text) {
        statusTextEl.textContent = text;
      }

      function updateCharCount() {
        charCountEl.textContent = <code>${editorEl.value.length} 文字</code>;
      }

      function rebuildOrder() {
        const arr = Object.values(state.memos);
        arr.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
        state.order = arr.map((m) => m.id);
      }

      // ==== CRUD ====
      async function createMemo(initialContent = "") {
        const id = createId();
        const now = Date.now();
        const memo = {
          id,
          content: initialContent,
          updatedAt: now,
        };

        state.memos[id] = memo;
        rebuildOrder();
        state.currentId = id;
        renderList();
        renderCurrentMemo();

        setStatus("保存中…");
        try {
          await putMemo(memo);
          await setState("currentId", id);
          setStatus("保存しました");
        } catch (e) {
          console.error("Failed to create memo:", e);
          setStatus("保存に失敗しました");
        }
      }

      async function deleteMemo(id) {
        if (!state.memos[id]) return;

        const ok = confirm("このメモを削除しますか?");
        if (!ok) return;

        setStatus("保存中…");

        try {
          await deleteMemoFromDB(id);
        } catch (e) {
          console.error("Failed to delete from DB:", e);
          // DB 失敗しても UI は進めてしまう(シンプル優先)
        }

        delete state.memos[id];
        rebuildOrder();

        if (state.order.length === 0) {
          state.currentId = null;
          renderList();
          renderCurrentMemo();
          // 1つも無くなったら新しく1つ作る
          await createMemo();
          return;
        }

        if (state.currentId === id) {
          state.currentId = state.order[0] || null;
        }

        renderList();
        renderCurrentMemo();

        try {
          await setState("currentId", state.currentId);
          setStatus("保存しました");
        } catch (e) {
          console.error("Failed to save currentId:", e);
          setStatus("保存に失敗しました");
        }
      }

      function selectMemo(id) {
        if (!state.memos[id]) return;
        state.currentId = id;
        renderList();
        renderCurrentMemo();
        // currentId は裏で保存しておくだけ
        setState("currentId", id).catch((e) => {
          console.error("Failed to save currentId:", e);
        });
      }

      function updateCurrentMemoContent(content) {
        const id = state.currentId;
        if (!id) return;
        const memo = state.memos[id];
        if (!memo) return;

        memo.content = content;
        memo.updatedAt = Date.now();

        renderCurrentMetaOnly();
        scheduleSaveCurrentMemo();
      }

      function scheduleSaveCurrentMemo() {
        if (saveTimer) clearTimeout(saveTimer);
        setStatus("保存中…");
        saveTimer = setTimeout(() => {
          saveTimer = null;
          saveCurrentMemoToDB();
        }, 500);
      }

      async function saveCurrentMemoToDB() {
        const id = state.currentId;
        if (!id) return;
        const memo = state.memos[id];
        if (!memo) return;

        try {
          rebuildOrder();
          renderList();
          renderCurrentMetaOnly();
          await putMemo(memo);
          setStatus("保存しました");
        } catch (e) {
          console.error("Failed to save memo:", e);
          setStatus("保存に失敗しました");
        }
      }

      // ==== 描画 ====
      function renderList() {
        memoListEl.innerHTML = "";

        if (state.order.length === 0) {
          const emptyEl = document.createElement("div");
          emptyEl.style.padding = "12px";
          emptyEl.style.fontSize = "13px";
          emptyEl.style.color = "#9ca3af";
          emptyEl.textContent = "メモがありません。「新規」ボタンから作成できます。";
          memoListEl.appendChild(emptyEl);
          return;
        }

        state.order.forEach((id) => {
          const memo = state.memos[id];
          if (!memo) return;

          const itemEl = document.createElement("div");
          itemEl.className =
            "memo-item" + (id === state.currentId ? " active" : "");
          itemEl.addEventListener("click", () => selectMemo(id));

          const mainEl = document.createElement("div");
          mainEl.className = "memo-main";

          const titleEl = document.createElement("div");
          titleEl.className = "memo-title";
          titleEl.textContent = getTitleFromContent(memo.content);

          const metaEl = document.createElement("div");
          metaEl.className = "memo-meta";
          metaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;

          mainEl.appendChild(titleEl);
          mainEl.appendChild(metaEl);

          const deleteBtn = document.createElement("button");
          deleteBtn.className = "memo-delete";
          deleteBtn.innerHTML = "✕";
          deleteBtn.title = "削除";
          deleteBtn.addEventListener("click", (e) => {
            e.stopPropagation();
            deleteMemo(id);
          });

          itemEl.appendChild(mainEl);
          itemEl.appendChild(deleteBtn);
          memoListEl.appendChild(itemEl);
        });
      }

      function renderCurrentMetaOnly() {
        const id = state.currentId;
        if (!id || !state.memos[id]) {
          currentMetaEl.textContent = "";
          return;
        }
        const memo = state.memos[id];
        currentTitleEl.textContent = getTitleFromContent(memo.content);
        currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;
      }

      function renderCurrentMemo() {
        const id = state.currentId;

        if (!id || !state.memos[id]) {
          currentTitleEl.textContent = "メモが選択されていません";
          currentMetaEl.textContent = "";
          editorEl.value = "";
          editorEl.disabled = true;
          updateCharCount();
          return;
        }

        const memo = state.memos[id];
        editorEl.disabled = false;
        editorEl.value = memo.content;
        currentTitleEl.textContent = getTitleFromContent(memo.content);
        currentMetaEl.textContent = <code>最終更新: ${formatDate(memo.updatedAt)}</code>;
        updateCharCount();
      }

      // ==== イベント ====
      newMemoBtn.addEventListener("click", () => {
        createMemo();
      });

      editorEl.addEventListener("input", () => {
        if (isLoading) return;
        updateCharCount();
        updateCurrentMemoContent(editorEl.value);
      });

      window.addEventListener("beforeunload", () => {
        // 残っているデバウンスがあれば即保存をキック(完了は保証されない)
        if (saveTimer) {
          clearTimeout(saveTimer);
          saveTimer = null;
          saveCurrentMemoToDB();
        }
      });

      // ==== 初期化 ====
      async function init() {
        isLoading = true;
        setStatus("読み込み中…");

        try {
          const memos = await getAllMemos();
          state.memos = {};
          memos.forEach((m) => {
            state.memos[m.id] = m;
          });
          rebuildOrder();

          const storedId = await getState("currentId");
          if (storedId && state.memos[storedId]) {
            state.currentId = storedId;
          } else {
            state.currentId = state.order[0] || null;
          }

          if (!state.currentId) {
            await createMemo();
          } else {
            renderList();
            renderCurrentMemo();
          }

          setStatus("自動保存が有効です");
        } catch (e) {
          console.error("Failed to initialize app:", e);
          state.memos = {};
          state.order = [];
          state.currentId = null;
          await createMemo();
          setStatus("自動保存が有効です");
        } finally {
          isLoading = false;
        }
      }

      init();
    })();
  </script>
</body>
</html>
ざっくり変更点 edit
  • localStorage は使わず、 - DB名: memoAppDB.v1 - ストア: - memos … メモ本体(id, content, updatedAt) - state … currentId だけ保存 という IndexedDB の 2 ストア構成にしています。
  • 表示順は updatedAt でソートして state.order を組み立て。
  • 自動保存はこれまで通り 500ms デバウンスで、裏で putMemo() を叩く形。

このままでも 10万文字クラスのメモを複数持てるので、 「さらにタグ付けしたい」「検索入れたい」「バックアップ用にJSONエクスポートしたい」 みたいなのがあれば、それ前提の拡張版も書きます。

gpt-5-1 edit

User: このアプリはスマホのブラウザでも機能しますか edit

このアプリはスマホのブラウザでも機能しますか

Assistant: はい、そのままでスマホのブラウザでも普通に動きます。 edit

はい、そのままでスマホのブラウザでも普通に動きます。 すでに提示した設計・コードは 完全フロントエンド + localStorage + 2ペイン(レスポンシブあり) のため、スマホでも問題なく動作します。

ただし、実際にスマホで快適に動かすにはいくつかポイントがあります。

- localStorage → スマホの Safari / Chrome などでもサポート済み edit

  • DOM・イベント → スマホ対応
  • textarea → スマホのキーボードで普通に編集可能
  • 2ペインレイアウト → CSS の @media でスマホでは縦分割(上:リスト、下:エディタ)に自動切り替わる

すでに公開したコードは画面幅が狭いときに:

  • 左右 2 ペイン → 上下レイアウトに自動変更(スマホ最適化)

になるようにしているので、そのまま使えます。

## edit

  • iOS Safari は理論上 5MB あるものの、実際は 2.5〜3MB 付近でエラーが出ることもあります。
  • ただし 10万文字(0.2MB)なら余裕。

→ 大量のメモを積まなければ問題なし

スマホは PC より CPU が弱いので、 edit

  • メモ100個
  • 保存データ 2〜3MB

みたいな規模になると、 JSON.stringify → localStorage.setItem の瞬間にフリーズが起こる場合があります。

→ 回避策:

  • メモ1つずつを別 key で保存
  • setItem の頻度を減らす

(必要ならコード書き替え版も作ります)

スマホでは IME の変換中の文字が input イベントで一気に来たりするため、 edit

  • 自動保存のデバウンス(今は500ms)
  • IME の確定に合わせた保存

はすでに実運用で特に問題なしの設計です。

必要なら「IME変換中は保存しない版」も作れます。

画面幅が狭すぎるので edit

  • 上:メモ一覧
  • 下:エディタ

の1 カラム UIになります(自動切り替え済み)。

あなたが作りたいこのメモアプリは、スマホでも問題なく使えます。 edit

しかも、提示コードはスマホ最適化された状態になっています。

- スマホ特化 UI(スワイプでメモ切り替え) edit

  • PWA 化(ホーム画面アイコン、オフライン利用、フルスクリーン化)
  • IndexedDB 版(大量メモでも高速)
  • ファイルエクスポート/インポート(バックアップ)

なども追加できます。

スマホファースト設計にしたい?

PWA化したい?

長期運用できるデータ構造にしたい?

方向性を教えてくれたら次のコードを用意します。

References edit

  1. "フロントエンドメモアプリ". ChatGPT. Retrieved 2025-11-27.
  2. "MDN Web Docs". MDN Web Docs. Retrieved 2025-11-27.
  3. "DEV Community". DEV Community. Retrieved 2025-11-27.
  4. "web.dev". web.dev. Retrieved 2025-11-27.