PR

爆速開発!AI(bolt.new)と協力してGoogle Apps Scriptの高度なフォーム機能を作り上げる裏側

GAS
記事内に広告が含まれています。

今回は、以前作成したシンプルなスプレッドシートの入力フォームを大幅にアップグレードする過程をご紹介しました。

ただ入力するだけでなく、「データの見える化」と「自由な編集」を可能にすることで、スプレッドシートの利便性を一気に高める内容になっています。
今回のアップデートでできるようになったこと
以前のフォームをベースに、以下の4つの機能を搭載した動的なフォームへ変更しました。

データの一覧表示: スプレッドシートに保存されているデータをフォーム上で確認できます。
新規追加: フォームから新しいデータを即座に登録できます。
データの編集: 既存のデータを呼び出して、内容を修正できます。
データの削除: 不要になったデータをフォームから直接削除できます。

bolt.newへの指示

開発には、AIツール(動画内では「ボルト」と言及)を活用しました。
まず、現在のGoogle Apps Scriptの状態をAIに確認してもらいました。

下記URLはスプレッドシートでフォームでの入力等を行う設定です。
内容を詳しく分析して

スプレッドシートアドレス

コード.gsのコード内容

そこから「どのような機能を変更・追加したいか」を指示して作業を進めていくスタイルで作成しています。

「このGASの入力フォームを、モダンで使いやすいデザインに作り直してください。 1. デザイン: Tailwind CSSを使用して、清潔感のある白基調のカードスタイルにしてください。スマホでも操作しやすいレスポンシブ対応で。 2. 機能: スプレッドシートのデータを一覧表示し、新規追加、編集、削除ができるフォームを作成してください。 3. UIパーツ: 入力エラーのバリデーションや、保存時のアニメーション(ローディング)も追加してください。 4. 言語: 項目名、ボタン、アラートメッセージはすべて日本語にしてください。」


最新のツールを使うことで、複雑なCRUD(作成・読み取り・更新・削除)機能も効率的に実装することが可能になりました。

コードの公開について

作成したコードについては、動画のツール上で直接公開しようと試みましたが、無料版の制限にかかったのか、エラーになりました。
有料プランにすると可能になるのではないでしょうか?

サンプルコード

「一覧表示・追加・編集・削除」を実現するための、一般的なGASのコードサンプルを以下に掲載します。
※このコードは一般的な実装例として作成したものです。ご自身のシート名や列に合わせて調整してください。

コード.gs

// スプレッドシートが開かれたときにカスタムメニューを追加する関数
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('カスタムメニュー')
    .addItem('ダッシュボードを開く', 'showDashboard')       // 統合ダッシュボード(おすすめ)
    .addSeparator()
    .addItem('新規登録フォーム', 'showForm')                 // Form.html を開く
    .addItem('選択中の行を修正する', 'showEditForm')         // EditForm.html を開く
    .addToUi();
}

// ==========================================
// 0. 統合ダッシュボード (Main.html)
// ==========================================
function showDashboard() {
  const html = HtmlService.createHtmlOutputFromFile('Main')
      .setWidth(1200)
      .setHeight(700)
      .setTitle('Chromeアカウント管理');
  SpreadsheetApp.getUi().showModalDialog(html, 'Chromeアカウント管理');
}

// 全データを取得して Main.html に返す
function getAllRows() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const lastRow = sheet.getLastRow();
  const lastCol = sheet.getLastColumn();

  // データなしの場合は空配列を返す
  if (lastRow <= 2) return [];

  // 3行目以降の全データを取得(1〜2行目は見出し)
  const values = sheet.getRange(3, 1, lastRow - 2, 6).getValues();

  // 行番号を付与してオブジェクトの配列に変換
  return values.map(function(row, i) {
    return {
      rowNum: i + 3,              // 実際のスプレッドシート行番号
      chromeId:        row[0],
      address:         row[1],
      password:        row[2],
      info:            row[3],
      recoveryAddress: row[4],
      location:        row[5]
    };
  });
}

// 行を削除する
function deleteRow(rowNum) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const row = parseInt(rowNum, 10);
  // 見出し行のガード
  if (row <= 2) throw new Error('見出し行は削除できません');
  sheet.deleteRow(row);
}

// ==========================================
// 1. 新規登録フォーム用の処理 (Form.html)
// ==========================================
function showForm() {
  const htmlOutput = HtmlService.createHtmlOutputFromFile('Form')
      .setWidth(450)
      .setHeight(620)
      .setTitle('データ入力フォーム');
  SpreadsheetApp.getUi().showModalDialog(htmlOutput, '新規データ登録');
}

// データを末尾に追加
function addNewRow(formData) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const rowData = [
    formData.chromeId,
    formData.address,
    formData.password,
    formData.info,
    formData.recoveryAddress,
    formData.location
  ];
  sheet.appendRow(rowData);
}

// ==========================================
// 2. 選択行の修正フォーム用の処理 (EditForm.html)
// ==========================================
function showEditForm() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const activeRange = sheet.getActiveRange();
  const row = activeRange.getRow();

  if (row <= 2) {
    SpreadsheetApp.getUi().alert('修正したいデータの行を選択してください。');
    return;
  }

  const rowData = sheet.getRange(row, 1, 1, 6).getValues()[0];

  const template = HtmlService.createTemplateFromFile('EditForm');
  template.rowData = rowData;
  template.rowNum = row;

  const html = template.evaluate()
      .setWidth(450)
      .setHeight(620)
      .setTitle('行データの修正');

  SpreadsheetApp.getUi().showModalDialog(html, '選択行の情報を修正');
}

// 修正内容を上書き保存
function saveEditedRow(formData) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const row = parseInt(formData.rowNumber, 10);

  const rowData = [
    formData.chromeId,
    formData.address,
    formData.password,
    formData.info,
    formData.recoveryAddress,
    formData.location
  ];

  sheet.getRange(row, 1, 1, 6).setValues([rowData]);
}

Form.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    @keyframes spin { to { transform: rotate(360deg); } }
    .spinner { animation: spin 0.75s linear infinite; }
  </style>
</head>
<body class="bg-slate-50 font-sans p-4">

  <form id="inputForm" class="space-y-4">

    <!-- ID(必須) -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">
        ID(chrome名) <span class="text-red-500">*</span>
      </label>
      <input type="text" id="chromeId" name="chromeId"
             oninput="clearError()"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             placeholder="例: work-account">
      <p id="err_chromeId"
         class="hidden text-red-500 text-xs mt-1.5 flex items-center gap-1">
        <svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
          <path fill-rule="evenodd"
                d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1
                   0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                clip-rule="evenodd"/>
        </svg>
        IDは必須項目です
      </p>
    </div>

    <!-- アドレス -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">アドレス</label>
      <input type="text" id="address" name="address"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             placeholder="例: example@gmail.com">
    </div>

    <!-- パスワード -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">パスワード</label>
      <div class="relative">
        <input type="password" id="password" name="password"
               class="w-full px-3 py-2.5 pr-10 border border-slate-200 rounded-xl text-sm
                      focus:outline-none focus:ring-2 focus:ring-blue-400
                      focus:border-transparent transition bg-white"
               placeholder="パスワードを入力">
        <!-- パスワード表示切り替えボタン -->
        <button type="button" onclick="togglePassword()"
                class="absolute right-3 top-1/2 -translate-y-1/2
                       text-slate-400 hover:text-slate-600 transition-colors">
          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                  d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                  d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943
                     9.542 7-1.274 4.057-5.064 7-9.542 7-4.477
                     0-8.268-2.943-9.542-7z"/>
          </svg>
        </button>
      </div>
    </div>

    <!-- 登録情報 -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">登録情報</label>
      <input type="text" id="info" name="info"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             placeholder="登録情報を入力">
    </div>

    <!-- 再設定アドレス -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">再設定アドレス</label>
      <input type="text" id="recoveryAddress" name="recoveryAddress"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             placeholder="例: recovery@example.com">
    </div>

    <!-- 使用場所 -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">使用場所</label>
      <input type="text" id="location" name="location"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             placeholder="例: 本社PC・自宅">
    </div>

    <!-- ボタン群 -->
    <div class="flex gap-3 justify-end pt-2">
      <button type="button" onclick="google.script.host.close()"
              class="px-4 py-2.5 text-sm font-semibold text-slate-600
                     bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors">
        閉じる
      </button>
      <button type="button" id="submitBtn" onclick="submitForm()"
              class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold
                     text-white bg-blue-500 hover:bg-blue-600 rounded-xl
                     transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
        <span id="submitSpinner"
              class="hidden w-4 h-4 border-2 border-white/30 border-t-white rounded-full spinner"></span>
        <span id="submitBtnText">新規追加</span>
      </button>
    </div>

  </form>

  <!-- 追加完了メッセージ -->
  <div id="successMsg"
       class="hidden mt-3 p-3 bg-emerald-50 border border-emerald-200 rounded-xl
              flex items-center gap-2 text-emerald-700 text-sm font-medium">
    <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
    </svg>
    追加しました!続けて入力できます
  </div>

  <script>
    function submitForm() {
      var form = document.getElementById('inputForm');
      var chromeId = form.chromeId.value.trim();

      // 必須チェック
      if (!chromeId) {
        document.getElementById('err_chromeId').classList.remove('hidden');
        form.chromeId.classList.add('border-red-400', 'ring-2', 'ring-red-100');
        form.chromeId.focus();
        return;
      }

      setBusy(true);

      google.script.run
        .withSuccessHandler(function() {
          setBusy(false);
          form.reset();
          // 完了メッセージを3秒表示
          var msg = document.getElementById('successMsg');
          msg.classList.remove('hidden');
          setTimeout(function() { msg.classList.add('hidden'); }, 3000);
        })
        .withFailureHandler(function(err) {
          setBusy(false);
          alert('エラーが発生しました: ' + (err.message || '不明なエラー'));
        })
        .addNewRow(form);
    }

    function clearError() {
      document.getElementById('err_chromeId').classList.add('hidden');
      document.getElementById('chromeId').classList.remove('border-red-400', 'ring-2', 'ring-red-100');
    }

    function togglePassword() {
      var input = document.getElementById('password');
      input.type = (input.type === 'password') ? 'text' : 'password';
    }

    function setBusy(busy) {
      document.getElementById('submitBtn').disabled = busy;
      document.getElementById('submitSpinner').classList.toggle('hidden', !busy);
      document.getElementById('submitBtnText').textContent = busy ? '追加中...' : '新規追加';
    }
  </script>
</body>
</html>

EditForm.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    @keyframes spin { to { transform: rotate(360deg); } }
    .spinner { animation: spin 0.75s linear infinite; }
  </style>
</head>
<body class="bg-slate-50 font-sans p-4">

  <form id="editForm" class="space-y-4">

    <!-- 行番号(非保持フィールド) -->
    <input type="hidden" id="rowNumber" name="rowNumber" value="<?= rowData[0] ?>">
    <!-- ** 注意 **
         rowData[0] にはA列の値が入っていますが、行番号は rowNum という
         別の変数で渡されます。下のスクリプトで上書きして正しい行番号をセットします。 -->

    <!-- ID(必須) -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">
        ID(chrome名) <span class="text-red-500">*</span>
      </label>
      <input type="text" id="chromeId" name="chromeId"
             oninput="clearError()"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             value="<?= rowData[0] ? rowData[0] : '' ?>"
             placeholder="例: work-account">
      <p id="err_chromeId"
         class="hidden text-red-500 text-xs mt-1.5 flex items-center gap-1">
        <svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
          <path fill-rule="evenodd"
                d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1
                   0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                clip-rule="evenodd"/>
        </svg>
        IDは必須項目です
      </p>
    </div>

    <!-- アドレス -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">アドレス</label>
      <input type="text" id="address" name="address"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             value="<?= rowData[1] ? rowData[1] : '' ?>"
             placeholder="例: example@gmail.com">
    </div>

    <!-- パスワード -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">パスワード</label>
      <div class="relative">
        <input type="password" id="password" name="password"
               class="w-full px-3 py-2.5 pr-10 border border-slate-200 rounded-xl text-sm
                      focus:outline-none focus:ring-2 focus:ring-blue-400
                      focus:border-transparent transition bg-white"
               value="<?= rowData[2] ? rowData[2] : '' ?>"
               placeholder="パスワードを入力">
        <button type="button" onclick="togglePassword()"
                class="absolute right-3 top-1/2 -translate-y-1/2
                       text-slate-400 hover:text-slate-600 transition-colors">
          <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                  d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                  d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943
                     9.542 7-1.274 4.057-5.064 7-9.542 7-4.477
                     0-8.268-2.943-9.542-7z"/>
          </svg>
        </button>
      </div>
    </div>

    <!-- 登録情報 -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">登録情報</label>
      <input type="text" id="info" name="info"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             value="<?= rowData[3] ? rowData[3] : '' ?>"
             placeholder="登録情報を入力">
    </div>

    <!-- 再設定アドレス -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">再設定アドレス</label>
      <input type="text" id="recoveryAddress" name="recoveryAddress"
             class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             value="<?= rowData[4] ? rowData[4] : '' ?>"
             placeholder="例: recovery@example.com">
    </div>

    <!-- 使用場所 -->
    <div>
      <label class="block text-sm font-semibold text-slate-700 mb-1.5">使用場所</label>
      <input type="text" id="location" name="location"
             class="whitespace-normal w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                    focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition bg-white"
             value="<?= rowData[5] ? rowData[5] : '' ?>"
             placeholder="例: 本社PC・自宅">
    </div>

    <!-- ボタン群 -->
    <div class="flex gap-3 justify-end pt-2">
      <button type="button" onclick="google.script.host.close()"
              class="px-4 py-2.5 text-sm font-semibold text-slate-600
                     bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors">
        キャンセル
      </button>
      <button type="button" id="submitBtn" onclick="submitForm()"
              class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold
                     text-white bg-blue-500 hover:bg-blue-600 rounded-xl
                     transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
        <span id="submitSpinner"
              class="hidden w-4 h-4 border-2 border-white/30 border-t-white rounded-full spinner"></span>
        <span id="submitBtnText">修正を反映する</span>
      </button>
    </div>

  </form>

  <script>
    // 行番号は rowNum から取得(rowData[0] はID値)
    document.getElementById('rowNumber').value = '<?= rowNum ?>';

    function submitForm() {
      var form = document.getElementById('editForm');
      var chromeId = form.chromeId.value.trim();

      // 必須チェック
      if (!chromeId) {
        document.getElementById('err_chromeId').classList.remove('hidden');
        form.chromeId.classList.add('border-red-400', 'ring-2', 'ring-red-100');
        form.chromeId.focus();
        return;
      }

      setBusy(true);

      google.script.run
        .withSuccessHandler(function() {
          setBusy(false);
          // 成功メッセージを表示してから閉じる
          var btn = document.getElementById('submitBtnText');
          btn.textContent = '更新完了';
          btn.parentElement.classList.remove('bg-blue-500', 'hover:bg-blue-600');
          btn.parentElement.classList.add('bg-emerald-500');
          setTimeout(function() { google.script.host.close(); }, 600);
        })
        .withFailureHandler(function(err) {
          setBusy(false);
          alert('エラーが発生しました: ' + (err.message || '不明なエラー'));
        })
        .saveEditedRow(form);
    }

    function clearError() {
      document.getElementById('err_chromeId').classList.add('hidden');
      document.getElementById('chromeId').classList.remove('border-red-400', 'ring-2', 'ring-red-100');
    }

    function togglePassword() {
      var input = document.getElementById('password');
      input.type = (input.type === 'password') ? 'text' : 'password';
    }

    function setBusy(busy) {
      document.getElementById('submitBtn').disabled = busy;
      document.getElementById('submitSpinner').classList.toggle('hidden', !busy);
      document.getElementById('submitBtnText').textContent = busy ? '更新中...' : '修正を反映する';
    }
  </script>
</body>
</html>

Main.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- Tailwind CSS CDN -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    /* スピナーアニメーション */
    @keyframes spin { to { transform: rotate(360deg); } }
    /* カード・行のフェードイン */
    @keyframes fadeInUp {
      from { opacity: 0; transform: translateY(6px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    /* モーダルのスケールイン */
    @keyframes scaleIn {
      from { opacity: 0; transform: scale(0.95); }
      to   { opacity: 1; transform: scale(1); }
    }
    .spinner    { animation: spin 0.75s linear infinite; }
    .fade-in-up { animation: fadeInUp 0.2s ease-out both; }
    .scale-in   { animation: scaleIn 0.18s ease-out both; }
  </style>
</head>
<body class="bg-slate-50 font-sans text-slate-800 min-h-screen">

  <!-- ===== ローディングオーバーレイ ===== -->
  <div id="loadingOverlay"
       class="fixed inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-50">
    <div class="flex flex-col items-center gap-3">
      <div class="w-10 h-10 border-4 border-blue-100 border-t-blue-500 rounded-full spinner"></div>
      <p class="text-slate-400 text-sm">読み込み中...</p>
    </div>
  </div>

  <!-- ===== トースト通知 ===== -->
  <div id="toast" class="fixed top-4 right-4 z-50 hidden">
    <div id="toastInner"
         class="flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-white text-sm font-medium fade-in-up">
      <span id="toastIcon"></span>
      <span id="toastMsg"></span>
    </div>
  </div>

  <!-- ===== メインコンテンツ ===== -->
  <div class="max-w-6xl mx-auto p-4 pb-10">

    <!-- ヘッダー -->
    <div class="flex flex-wrap items-center justify-between gap-3 mb-6 pt-2">
      <div>
        <h1 class="text-xl font-bold text-slate-800 tracking-tight">Chromeアカウント管理</h1>
        <p id="recordCount" class="text-slate-400 text-sm mt-0.5">— 件のアカウント</p>
      </div>
      <button onclick="openAddModal()"
              class="inline-flex items-center gap-2 bg-blue-500 hover:bg-blue-600
                     active:bg-blue-700 text-white text-sm font-semibold
                     px-4 py-2.5 rounded-xl shadow-sm transition-colors">
        <!-- プラスアイコン -->
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/>
        </svg>
        新規追加
      </button>
    </div>

    <!-- 検索バー -->
    <div class="relative mb-5">
      <svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none"
           fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
              d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
      </svg>
      <input type="text" id="searchInput" oninput="filterRows()"
             placeholder="ID・アドレス・使用場所で検索..."
             class="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl
                    text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-400
                    focus:border-transparent transition">
    </div>

    <!-- データテーブル(PC表示) -->
    <div id="tableWrap"
         class="hidden bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
      <div class="overflow-x-auto">
        <table class="w-full text-sm">
          <thead>
            <tr class="bg-slate-50/80 border-b border-slate-100 text-xs uppercase tracking-wide">
              <th class="text-left px-4 py-3 font-semibold text-slate-500">ID(chrome名)</th>
              <th class="text-left px-4 py-3 font-semibold text-slate-500">アドレス</th>
              <th class="text-left px-4 py-3 font-semibold text-slate-500">パスワード</th>
              <th class="text-left px-4 py-3 font-semibold text-slate-500">登録情報</th>
              <th class="text-left px-4 py-3 font-semibold text-slate-500">再設定アドレス</th>
              <th class="text-left px-4 py-3 font-semibold text-slate-500">使用場所</th>
              <th class="px-4 py-3 w-20"></th>
            </tr>
          </thead>
          <tbody id="tableBody"></tbody>
        </table>
      </div>
    </div>

    <!-- 空状態 -->
    <div id="emptyState" class="hidden text-center py-20">
      <div class="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
        <svg class="w-8 h-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
                d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586
                   a1 1 0 01.707.293l5.414 5.414A1 1 0 0119 8.414V19a2 2 0 01-2 2z"/>
        </svg>
      </div>
      <p class="text-slate-500 font-semibold">データがありません</p>
      <p class="text-slate-400 text-sm mt-1">「新規追加」ボタンで登録を始めてください</p>
    </div>
  </div>

  <!-- ===== 追加 / 編集モーダル ===== -->
  <div id="formModal"
       class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-40 hidden p-4">
    <div class="bg-white rounded-2xl shadow-2xl w-full max-w-md scale-in">

      <!-- モーダルヘッダー -->
      <div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
        <h2 id="modalTitle" class="text-base font-bold text-slate-800">新規追加</h2>
        <button onclick="closeFormModal()"
                class="w-8 h-8 flex items-center justify-center rounded-lg
                       hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors">
          <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
          </svg>
        </button>
      </div>

      <!-- フォーム本体 -->
      <div class="px-6 py-5 space-y-4 max-h-[60vh] overflow-y-auto">
        <input type="hidden" id="editRowNum">

        <!-- ID(必須) -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">
            ID(chrome名) <span class="text-red-500">*</span>
          </label>
          <input type="text" id="f_chromeId"
                 oninput="clearFieldError('chromeId')"
                 class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                        focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                 placeholder="例: work-account">
          <p id="err_chromeId" class="hidden text-red-500 text-xs mt-1.5 flex items-center gap-1">
            <svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
              <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1
                0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                clip-rule="evenodd"/>
            </svg>
            IDは必須項目です
          </p>
        </div>

        <!-- アドレス -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">アドレス</label>
          <input type="text" id="f_address"
                 class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                        focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                 placeholder="例: example@gmail.com">
        </div>

        <!-- パスワード(表示切り替えボタン付き) -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">パスワード</label>
          <div class="relative">
            <input type="password" id="f_password"
                   class="w-full px-3 py-2.5 pr-10 border border-slate-200 rounded-xl text-sm
                          focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                   placeholder="パスワードを入力">
            <button type="button" onclick="togglePassword()"
                    class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors">
              <svg id="eyeIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                      d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                      d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943
                         9.542 7-1.274 4.057-5.064 7-9.542 7-4.477
                         0-8.268-2.943-9.542-7z"/>
              </svg>
            </button>
          </div>
        </div>

        <!-- 登録情報 -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">登録情報</label>
          <input type="text" id="f_info"
                 class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                        focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                 placeholder="登録情報を入力">
        </div>

        <!-- 再設定アドレス -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">再設定アドレス</label>
          <input type="text" id="f_recoveryAddress"
                 class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                        focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                 placeholder="例: recovery@example.com">
        </div>

        <!-- 使用場所 -->
        <div>
          <label class="block text-sm font-semibold text-slate-700 mb-1.5">使用場所</label>
          <input type="text" id="f_location"
                 class="w-full px-3 py-2.5 border border-slate-200 rounded-xl text-sm
                        focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition"
                 placeholder="例: 本社PC・自宅">
        </div>
      </div>

      <!-- モーダルフッター -->
      <div class="flex gap-3 justify-end px-6 py-4 border-t border-slate-100">
        <button onclick="closeFormModal()"
                class="px-4 py-2.5 text-sm font-semibold text-slate-600
                       bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors">
          キャンセル
        </button>
        <button id="submitBtn" onclick="submitForm()"
                class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold
                       text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-colors
                       disabled:opacity-60 disabled:cursor-not-allowed">
          <span id="submitSpinner"
                class="hidden w-4 h-4 border-2 border-white/30 border-t-white rounded-full spinner"></span>
          <span id="submitBtnText">追加する</span>
        </button>
      </div>
    </div>
  </div>

  <!-- ===== 削除確認モーダル ===== -->
  <div id="deleteModal"
       class="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-40 hidden p-4">
    <div class="bg-white rounded-2xl shadow-2xl w-full max-w-sm scale-in">
      <div class="p-6 text-center">
        <!-- 警告アイコン -->
        <div class="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
          <svg class="w-7 h-7 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                  d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7
                     m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
          </svg>
        </div>
        <h3 class="text-base font-bold text-slate-800 mb-2">このデータを削除しますか?</h3>
        <p id="deleteTargetLabel" class="text-sm text-slate-500 mb-6 leading-relaxed">
          削除すると元に戻せません。
        </p>
        <div class="flex gap-3">
          <button onclick="closeDeleteModal()"
                  class="flex-1 py-2.5 text-sm font-semibold text-slate-600
                         bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors">
            キャンセル
          </button>
          <button id="deleteBtn" onclick="confirmDelete()"
                  class="flex-1 inline-flex items-center justify-center gap-2 py-2.5 text-sm
                         font-semibold text-white bg-red-500 hover:bg-red-600 rounded-xl
                         transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
            <span id="deleteSpinner"
                  class="hidden w-4 h-4 border-2 border-white/30 border-t-white rounded-full spinner"></span>
            削除する
          </button>
        </div>
      </div>
    </div>
  </div>

  <script>
    // 全データを保持する配列
    var allRows = [];
    // 削除対象の行番号
    var deleteTargetRowNum = null;
    // 編集モードかどうか
    var isEditMode = false;

    // ========== 初期化 ==========

    // ページ読み込み時にスプレッドシートのデータを取得
    function loadData() {
      showOverlay(true);
      google.script.run
        .withSuccessHandler(function(rows) {
          allRows = rows || [];
          renderTable(allRows);
          showOverlay(false);
        })
        .withFailureHandler(function() {
          showOverlay(false);
          showToast('データの読み込みに失敗しました', 'error');
        })
        .getAllRows();
    }

    // ========== テーブル描画 ==========

    function renderTable(rows) {
      var tbody  = document.getElementById('tableBody');
      var wrap   = document.getElementById('tableWrap');
      var empty  = document.getElementById('emptyState');
      var count  = document.getElementById('recordCount');

      count.textContent = rows.length + ' 件のアカウント';

      if (rows.length === 0) {
        wrap.classList.add('hidden');
        empty.classList.remove('hidden');
        tbody.innerHTML = '';
        return;
      }

      wrap.classList.remove('hidden');
      empty.classList.add('hidden');

      // 各行のHTMLを組み立て
      tbody.innerHTML = rows.map(function(row, i) {
        var delay = Math.min(i * 30, 300); // 行ごとの遅延(最大300ms)
        return [
          '<tr class="border-b border-slate-50 hover:bg-blue-50/30 transition-colors fade-in-up"',
          '    style="animation-delay:' + delay + 'ms">',
          '  <td class="px-4 py-3 font-semibold text-slate-800">' + esc(row.chromeId) + '</td>',
          '  <td class="px-4 py-3 text-slate-600 text-xs">' + esc(row.address) + '</td>',
          '  <!-- パスワードはマスク表示 -->',
          '  <td class="px-4 py-3 text-slate-300 tracking-widest text-xs">••••••••</td>',
          '  <td class="px-4 py-3 text-slate-600 text-xs">' + esc(row.info) + '</td>',
          '  <td class="px-4 py-3 text-slate-600 text-xs">' + esc(row.recoveryAddress) + '</td>',
          '  <td class="px-4 py-3">',
          '    <span class="inline-flex px-2 py-0.5 bg-slate-100 text-slate-600 rounded-md text-xs font-medium">',
          esc(row.location),
          '    </span>',
          '  </td>',
          '  <td class="px-4 py-3">',
          '    <div class="flex items-center gap-1 justify-end">',
          '      <!-- 編集ボタン -->',
          '      <button onclick="openEditModal(' + row.rowNum + ')"',
          '              class="p-1.5 rounded-lg text-slate-400 hover:text-blue-500 hover:bg-blue-50 transition-colors"',
          '              title="編集">',
          '        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">',
          '          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"',
          '                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5',
          '                   m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>',
          '        </svg>',
          '      </button>',
          '      <!-- 削除ボタン -->',
          '      <button onclick="openDeleteModal(' + row.rowNum + ', \'' + esc(row.chromeId) + '\')"',
          '              class="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 transition-colors"',
          '              title="削除">',
          '        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">',
          '          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"',
          '                d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2',
          '                   0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0',
          '                   00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>',
          '        </svg>',
          '      </button>',
          '    </div>',
          '  </td>',
          '</tr>'
        ].join('');
      }).join('');
    }

    // ========== 検索フィルター ==========

    function filterRows() {
      var q = document.getElementById('searchInput').value.toLowerCase().trim();
      if (!q) {
        renderTable(allRows);
        return;
      }
      var filtered = allRows.filter(function(row) {
        return (row.chromeId      || '').toLowerCase().includes(q) ||
               (row.address       || '').toLowerCase().includes(q) ||
               (row.info          || '').toLowerCase().includes(q) ||
               (row.location      || '').toLowerCase().includes(q) ||
               (row.recoveryAddress || '').toLowerCase().includes(q);
      });
      renderTable(filtered);
    }

    // ========== 追加モーダル ==========

    function openAddModal() {
      isEditMode = false;
      document.getElementById('modalTitle').textContent    = '新規追加';
      document.getElementById('submitBtnText').textContent = '追加する';
      clearForm();
      document.getElementById('formModal').classList.remove('hidden');
      setTimeout(function() { document.getElementById('f_chromeId').focus(); }, 50);
    }

    // ========== 編集モーダル ==========

    function openEditModal(rowNum) {
      // allRows から対象行を検索
      var row = allRows.find(function(r) { return r.rowNum === rowNum; });
      if (!row) { showToast('データが見つかりません', 'error'); return; }

      isEditMode = true;
      document.getElementById('modalTitle').textContent    = '情報を修正';
      document.getElementById('submitBtnText').textContent = '更新する';
      document.getElementById('editRowNum').value          = row.rowNum;
      document.getElementById('f_chromeId').value          = row.chromeId        || '';
      document.getElementById('f_address').value           = row.address         || '';
      document.getElementById('f_password').value          = row.password        || '';
      document.getElementById('f_info').value              = row.info            || '';
      document.getElementById('f_recoveryAddress').value   = row.recoveryAddress || '';
      document.getElementById('f_location').value          = row.location        || '';
      clearValidation();
      document.getElementById('formModal').classList.remove('hidden');
    }

    function closeFormModal() {
      document.getElementById('formModal').classList.add('hidden');
      clearForm();
    }

    function clearForm() {
      ['chromeId','address','password','info','recoveryAddress','location'].forEach(function(id) {
        document.getElementById('f_' + id).value = '';
      });
      document.getElementById('editRowNum').value = '';
      clearValidation();
    }

    // ========== バリデーション ==========

    function clearValidation() {
      document.getElementById('err_chromeId').classList.add('hidden');
      document.getElementById('f_chromeId').classList.remove('border-red-400', 'ring-2', 'ring-red-100');
    }

    function clearFieldError(field) {
      document.getElementById('err_' + field).classList.add('hidden');
      document.getElementById('f_' + field).classList.remove('border-red-400', 'ring-2', 'ring-red-100');
    }

    function showFieldError(field) {
      document.getElementById('err_' + field).classList.remove('hidden');
      var input = document.getElementById('f_' + field);
      input.classList.add('border-red-400', 'ring-2', 'ring-red-100');
      input.focus();
    }

    // ========== フォーム送信 ==========

    function submitForm() {
      var chromeId = document.getElementById('f_chromeId').value.trim();

      // 必須チェック
      if (!chromeId) {
        showFieldError('chromeId');
        return;
      }

      var formData = {
        rowNumber:       document.getElementById('editRowNum').value,
        chromeId:        chromeId,
        address:         document.getElementById('f_address').value.trim(),
        password:        document.getElementById('f_password').value,
        info:            document.getElementById('f_info').value.trim(),
        recoveryAddress: document.getElementById('f_recoveryAddress').value.trim(),
        location:        document.getElementById('f_location').value.trim()
      };

      setSubmitBusy(true);

      var fn = isEditMode ? 'saveEditedRow' : 'addNewRow';
      var msg = isEditMode ? '更新しました' : '追加しました';

      google.script.run
        .withSuccessHandler(function() {
          setSubmitBusy(false);
          closeFormModal();
          showToast(msg, 'success');
          loadData(); // テーブルを再読み込み
        })
        .withFailureHandler(function() {
          setSubmitBusy(false);
          showToast('保存に失敗しました。もう一度お試しください', 'error');
        })
        [fn](formData);
    }

    // ========== 削除モーダル ==========

    function openDeleteModal(rowNum, name) {
      deleteTargetRowNum = rowNum;
      document.getElementById('deleteTargetLabel').textContent =
        '「' + name + '」を削除します。この操作は元に戻せません。';
      document.getElementById('deleteModal').classList.remove('hidden');
    }

    function closeDeleteModal() {
      deleteTargetRowNum = null;
      document.getElementById('deleteModal').classList.add('hidden');
    }

    function confirmDelete() {
      if (!deleteTargetRowNum) return;
      setDeleteBusy(true);
      google.script.run
        .withSuccessHandler(function() {
          setDeleteBusy(false);
          closeDeleteModal();
          showToast('削除しました', 'success');
          loadData();
        })
        .withFailureHandler(function() {
          setDeleteBusy(false);
          showToast('削除に失敗しました', 'error');
        })
        .deleteRow(deleteTargetRowNum);
    }

    // ========== パスワード表示切り替え ==========

    function togglePassword() {
      var input = document.getElementById('f_password');
      input.type = (input.type === 'password') ? 'text' : 'password';
    }

    // ========== UI ヘルパー ==========

    function showOverlay(show) {
      document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none';
    }

    function setSubmitBusy(busy) {
      var btn = document.getElementById('submitBtn');
      btn.disabled = busy;
      document.getElementById('submitSpinner').classList.toggle('hidden', !busy);
    }

    function setDeleteBusy(busy) {
      var btn = document.getElementById('deleteBtn');
      btn.disabled = busy;
      document.getElementById('deleteSpinner').classList.toggle('hidden', !busy);
    }

    var toastTimer;
    function showToast(message, type) {
      clearTimeout(toastTimer);
      var inner = document.getElementById('toastInner');
      var icon  = document.getElementById('toastIcon');
      var msg   = document.getElementById('toastMsg');

      msg.textContent = message;
      // 色とアイコンを種別に応じて切り替え
      if (type === 'success') {
        inner.className = 'flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-white text-sm font-medium fade-in-up bg-emerald-500';
        icon.innerHTML  = '<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>';
      } else {
        inner.className = 'flex items-center gap-2 px-4 py-3 rounded-xl shadow-lg text-white text-sm font-medium fade-in-up bg-red-500';
        icon.innerHTML  = '<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>';
      }

      document.getElementById('toast').classList.remove('hidden');
      toastTimer = setTimeout(function() {
        document.getElementById('toast').classList.add('hidden');
      }, 3000);
    }

    // XSS 対策: 特殊文字をエスケープ
    function esc(str) {
      if (str === null || str === undefined) return '';
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    }

    // ページ表示と同時にデータを読み込む
    loadData();
  </script>
</body>
</html>

YouTube動画

コメント

タイトルとURLをコピーしました