Last active
December 25, 2025 04:59
-
-
Save DaikiSuganuma/c2f6e22fb401a12a1a4e0606274c2366 to your computer and use it in GitHub Desktop.
[セミナー時にリアルタイムでコメントを見える化するシステムをAIで作る](https://blog.dksg.jp/2025/12/ai.html)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <base target="_top"> | |
| <style> | |
| /* 全体の基本スタイル */ | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| background: #f0f2f5; | |
| padding-bottom: 90px; /* 入力エリアの高さに合わせて余白を広げました */ | |
| } | |
| .container { max-width: 800px; margin: 0 auto; padding: 20px; } | |
| /* コメントリストのスタイル */ | |
| #comment-list { list-style: none; padding: 0; } | |
| .comment-card { | |
| background: white; padding: 15px; margin-bottom: 10px; | |
| border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); | |
| animation: slideIn 0.3s ease; | |
| } | |
| .time { font-size: 0.75em; color: #888; margin-bottom: 4px; } | |
| /* ★変更: white-space: pre-wrap によって、改行コードを画面上の改行として表示します */ | |
| .text { | |
| font-size: 1.1em; color: #333; line-height: 1.4; | |
| word-wrap: break-word; | |
| white-space: pre-wrap; | |
| } | |
| @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } | |
| .no-comment { text-align: center; color: #666; margin-top: 20px; } | |
| /* 下部固定の入力エリア */ | |
| .input-area { | |
| position: fixed; bottom: 0; left: 0; width: 100%; | |
| background: #fff; border-top: 1px solid #ddd; | |
| padding: 10px 15px; box-shadow: 0 -2px 10px rgba(0,0,0,0.05); | |
| display: flex; gap: 10px; box-sizing: border-box; | |
| z-index: 100; | |
| align-items: center; /* 垂直方向の中央揃え */ | |
| } | |
| /* ★変更: textarea用のスタイル */ | |
| .input-box { | |
| flex: 1; | |
| padding: 10px; | |
| border: 1px solid #ddd; | |
| border-radius: 10px; /* 角丸を少し控えめに */ | |
| font-size: 16px; | |
| outline: none; | |
| transition: border 0.2s; | |
| resize: none; /* ユーザーによるサイズ変更を禁止(高さ固定のため) */ | |
| height: 50px; /* 高さを固定(約2行分) */ | |
| font-family: inherit; | |
| } | |
| .input-box:focus { border-color: #1a73e8; } | |
| .send-btn { | |
| background: #1a73e8; color: white; border: none; padding: 0 20px; | |
| border-radius: 20px; font-weight: bold; cursor: pointer; | |
| font-size: 14px; white-space: nowrap; | |
| height: 40px; /* ボタンの高さ */ | |
| } | |
| .send-btn:disabled { background: #ccc; cursor: not-allowed; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- コメント表示エリア --> | |
| <div class="container"> | |
| <div id="comment-list"> | |
| <div class="no-comment" id="loading-msg">読み込み中...</div> | |
| </div> | |
| </div> | |
| <!-- 入力フォームエリア --> | |
| <div class="input-area"> | |
| <!-- ★変更: textareaに変更し、maxlengthを設定 --> | |
| <textarea id="comment-input" class="input-box" placeholder="コメントを入力...(140文字まで)" maxlength="140" autocomplete="off"></textarea> | |
| <button id="send-btn" class="send-btn" onclick="submitComment()">送信</button> | |
| </div> | |
| <script> | |
| var currentLastRow = 1; | |
| var isFetching = false; | |
| var isSubmitting = false; | |
| loadNewData(); | |
| setInterval(loadNewData, 3000); | |
| // ★変更: Enterキーでの送信機能を削除しました。 | |
| // textareaでEnterキーを押すと、通常の「改行」として動作します。 | |
| // 送信するには画面上の「送信」ボタンを押してください。 | |
| /** | |
| * コメント送信処理関数 | |
| */ | |
| function submitComment() { | |
| if (isSubmitting) return; | |
| var input = document.getElementById('comment-input'); | |
| var btn = document.getElementById('send-btn'); | |
| var text = input.value; | |
| if (!text.trim()) return; | |
| // --- ロック開始 --- | |
| isSubmitting = true; | |
| btn.disabled = true; | |
| btn.innerText = "送信中..."; | |
| google.script.run | |
| .withSuccessHandler(function() { | |
| input.value = ""; | |
| btn.disabled = false; | |
| btn.innerText = "送信"; | |
| isSubmitting = false; | |
| loadNewData(); | |
| }) | |
| .withFailureHandler(function(err) { | |
| alert("送信に失敗しました"); | |
| btn.disabled = false; | |
| btn.innerText = "送信"; | |
| isSubmitting = false; | |
| }) | |
| .postComment(text); | |
| } | |
| /** | |
| * 新しいデータを取得して表示する関数 | |
| */ | |
| function loadNewData() { | |
| if (isFetching) return; | |
| isFetching = true; | |
| google.script.run | |
| .withSuccessHandler(function(response) { | |
| updateList(response); | |
| isFetching = false; | |
| }) | |
| .withFailureHandler(function(err) { | |
| console.error(err); | |
| isFetching = false; | |
| }) | |
| .getNewData(currentLastRow); | |
| } | |
| /** | |
| * 画面更新関数 | |
| */ | |
| function updateList(response) { | |
| if (!response || !response.data || response.data.length === 0) { | |
| if (currentLastRow === 1 && document.getElementById('loading-msg')) { | |
| document.getElementById('loading-msg').innerText = "まだコメントはありません"; | |
| } | |
| return; | |
| } | |
| currentLastRow = response.lastRow; | |
| var list = document.getElementById('comment-list'); | |
| var msg = document.getElementById('loading-msg'); | |
| if (msg) msg.remove(); | |
| var newItems = response.data; | |
| for (var i = 0; i < newItems.length; i++) { | |
| var timeStr = escapeHtml(newItems[i][0]); | |
| var textStr = escapeHtml(newItems[i][1]); | |
| var div = document.createElement('div'); | |
| div.className = 'comment-card'; | |
| // CSSで white-space: pre-wrap を指定しているため、 | |
| // textStr の中に含まれる改行コード(\n)はそのまま表示されます。 | |
| div.innerHTML = '<div class="time">' + timeStr + '</div>' + | |
| '<div class="text">' + textStr + '</div>'; | |
| list.prepend(div); | |
| } | |
| } | |
| function escapeHtml(str) { | |
| if(typeof str !== 'string') return str; | |
| return str.replace(/[&<>"']/g, function(m) { | |
| return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Webアプリへのアクセス時に最初に実行される関数 | |
| * Index.htmlを表示用テンプレートとして読み込みます。 | |
| */ | |
| function doGet() { | |
| return HtmlService.createTemplateFromFile('Index').evaluate() | |
| .setTitle('リアルタイムコメント') // ブラウザのタブ名を設定 | |
| .addMetaTag('viewport', 'width=device-width, initial-scale=1, maximum-scale=1'); // スマホ表示対応 | |
| } | |
| /** | |
| * 新しいコメントデータのみを取得する関数 | |
| * @param {number} clientLastRow - クライアント(画面側)が現在保持している最後の行番号 | |
| * @return {object} data: 新しい行の配列, lastRow: 現在の最新行番号 | |
| */ | |
| function getNewData(clientLastRow) { | |
| try { | |
| var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; | |
| var sheetLastRow = sheet.getLastRow(); | |
| // シートの行数がクライアントの保持行数以下なら、新しいデータはないので空を返す | |
| // (データ通信量を削減するための重要な判定処理) | |
| if (sheetLastRow <= clientLastRow) { | |
| return { | |
| data: [], | |
| lastRow: clientLastRow | |
| }; | |
| } | |
| // 差分(新しいデータ)の行数を計算 | |
| var numNewRows = sheetLastRow - clientLastRow; | |
| // 新しい範囲を指定してデータを取得 (開始行, 列, 行数, 列数) | |
| var range = sheet.getRange(clientLastRow + 1, 1, numNewRows, 2); | |
| // 日付などを自動変換させず、見た目通りの「文字列」として取得してエラーを防ぐ | |
| var values = range.getDisplayValues(); | |
| return { | |
| data: values, // 新しいデータの配列 | |
| lastRow: sheetLastRow // 現在の最新行数を返す | |
| }; | |
| } catch (e) { | |
| Logger.log(e); // エラーログを残す | |
| return { data: [], lastRow: clientLastRow }; | |
| } | |
| } | |
| /** | |
| * ユーザーからのコメントをスプレッドシートに保存する関数 | |
| * @param {string} text - 投稿されたコメント本文 | |
| */ | |
| function postComment(text) { | |
| // 空文字やスペースのみの場合は無視する | |
| if (!text || text.toString().trim() === "") return; | |
| var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; | |
| // A列に現在日時、B列にコメントを追加 | |
| // appendRowを使うことで、既存データの一番下に追記される | |
| sheet.appendRow([new Date(), text]); | |
| return true; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment