CDKで、ログイン無し、youtube動画+メモ+コメント(ユーザ名付き・返信可)を作ってみた。
AWS構成
S3 + cloudfront
lambda
dynamoDB + apigateway
作成したCDKファイルは4つだけ、cdk deployでデプロイ完了!
lib/stack.ts # AWSリソース生成 (CDK)
src/index.html # フロントエンド (HTML)
src/app.js # フロントエンド (ブラウザ用JS)
lambda/comment.js # Lambda関数 (サーバーサイド)
lib/stack.ts # AWSリソース生成 (CDK)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { RemovalPolicy } from 'aws-cdk-lib'; import { Bucket } from 'aws-cdk-lib/aws-s3'; import { OriginAccessIdentity, CloudFrontWebDistribution } from 'aws-cdk-lib/aws-cloudfront'; import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; import { Runtime, Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; import { RestApi, LambdaIntegration, Cors } from 'aws-cdk-lib/aws-apigateway'; import * as path from 'path'; export class Youtube2Stack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // --- S3 バケットと CloudFront --- const websiteBucket = new Bucket(this, 'Youtube2WebsiteBucket', { // デモ用設定。本番では慎重に設定する removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); const originAccessIdentity = new OriginAccessIdentity(this, 'WebsiteOAI'); websiteBucket.grantRead(originAccessIdentity); const distribution = new CloudFrontWebDistribution(this, 'Youtube2Distribution', { originConfigs: [ { s3OriginSource: { s3BucketSource: websiteBucket, originAccessIdentity }, behaviors: [{ isDefaultBehavior: true }] } ] }); // --- S3 に静的ファイルをデプロイ --- new BucketDeployment(this, 'DeployWebsite', { sources: [Source.asset(path.join(__dirname, '..', 'src'))], destinationBucket: websiteBucket, distribution, retainOnDelete: false }); // --- DynamoDB テーブル(コメント格納用) --- const commentTable = new Table(this, 'CommentTable', { partitionKey: { name: 'videoId', type: AttributeType.STRING }, sortKey: { name: 'commentId', type: AttributeType.STRING }, tableName: 'Youtube2Comments', billingMode: BillingMode.PAY_PER_REQUEST, removalPolicy: RemovalPolicy.DESTROY, // デモ用 }); // --- Lambda 関数(comments.js) --- const commentsLambda = new LambdaFunction(this, 'CommentsHandler', { runtime: Runtime.NODEJS_16_X, code: Code.fromAsset(path.join(__dirname, '..', 'lambda')), handler: 'comments.main', // comments.js の exports.main を指定 environment: { TABLE_NAME: commentTable.tableName } }); // DynamoDB への読み書き権限付与 commentTable.grantReadWriteData(commentsLambda); // --- API Gateway で Lambda を公開 --- const api = new RestApi(this, 'Youtube2Api', { restApiName: 'youtube2-api', defaultCorsPreflightOptions: { // ここに CloudFront のドメインを文字列配列で指定 allowOrigins: Cors.ALL_ORIGINS, allowMethods: Cors.ALL_METHODS } }); const commentsResource = api.root.addResource('comments'); const commentsIntegration = new LambdaIntegration(commentsLambda); // GET /comments (コメント一覧取得) commentsResource.addMethod('GET', commentsIntegration); // POST /comments (コメント投稿) commentsResource.addMethod('POST', commentsIntegration); // --- 出力: CloudFront のURLを表示 --- new cdk.CfnOutput(this, 'CloudFrontURL', { value: distribution.distributionDomainName, description: 'Access your site here' }); } } |
src/index.html # フロントエンド (HTML)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8" /> <title>YouTube + メモ + コメント + ユーザ名</title> <style> /* 全体のスタイル */ body { margin: 0; padding: 20px; font-family: sans-serif; background-color: #f9f9f9; } h1, h2, h3 { margin-top: 0; } /* 上段コンテナ: 左に動画、右にメモ */ #topContainer { display: flex; gap: 20px; margin-bottom: 20px; } /* 動画・メモのパネル */ #videoContainer, #memoContainer { flex: 1; background-color: #fff; padding: 16px; box-shadow: 0 2px 5px rgba(0,0,0,0.15); box-sizing: border-box; } iframe { width: 100%; aspect-ratio: 16 / 9; /* 16:9 で縦横比を固定 */ height: auto; } /* コメント欄は下に */ #commentsSection { background-color: #fff; padding: 16px; box-shadow: 0 2px 5px rgba(0,0,0,0.15); max-width: 100%; box-sizing: border-box; } /* ユーザ名入力欄 */ #usernameContainer { margin-bottom: 10px; } input#usernameInput { padding: 6px; font-size: 14px; width: 200px; } /* コメント一覧 */ #commentsList > div { margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 8px; } #commentsList button { margin: 4px 0; } .replyChild { margin-left: 20px; } /* 投稿エリア */ #commentTextarea { width: 100%; box-sizing: border-box; } button#postCommentBtn { padding: 6px 12px; margin-top: 6px; } </style> </head> <body> <h1>動画+メモ+コメント(ユーザ名付き)</h1> <!-- 上段: 左に動画、右にメモ --> <div id="topContainer"> <!-- 動画選択と埋め込み --> <div id="videoContainer"> <h2>動画</h2> <select id="videoSelect"> <option value="uvrUXazq59Y">Linuxのパッケージ管理</option> <option value="F5yEEFYDF-c">CIDRとは?IPアドレス解説</option> <option value="eVaw5MpeRT8">QRコードの仕組み</option> </select> <div style="margin-top: 10px;"> <iframe id="youtubePlayer" src="https://www.youtube.com/embed/uvrUXazq59Y" allowfullscreen ></iframe> </div> </div> <!-- メモ欄 --> <div id="memoContainer"> <h2>メモ</h2> <textarea id="notesTextarea" rows="10" style="width:100%;"></textarea><br/> <p id="saveStatus">(入力すると自動保存されます)</p> </div> </div> <!-- 下段: コメント --> <div id="commentsSection"> <h2>コメント一覧</h2> <div id="commentsList"></div> <!-- ユーザ名入力欄 --> <div id="usernameContainer"> <label for="usernameInput">ユーザ名(ニックネーム): </label> <input id="usernameInput" type="text" placeholder="例) Taro" /> </div> <h3>新規コメント投稿</h3> <textarea id="commentTextarea" rows="3"></textarea><br/> <button id="postCommentBtn">コメント投稿</button> </div> <!-- メインのスクリプト --> <script src="app.js"></script> </body> </html> |
src/app.js # フロントエンド (ブラウザ用JS)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
/******************************************************* * ユーザID & ユーザ名の管理 *******************************************************/ // ローカルストレージから userId を読み取り。なければランダム発行 let userId = localStorage.getItem('youtube2UserId'); if (!userId) { userId = 'user-' + Math.random().toString(36).substring(2, 8); localStorage.setItem('youtube2UserId', userId); } // ユーザ名 (ニックネーム) も localStorage で管理 let userName = localStorage.getItem('youtube2UserName') || ""; /******************************************************* * 動画リスト *******************************************************/ const videoList = [ { id: "xxxxx", title: "aaaa" }, { id: "yyyyy", title: "bbbb" }, { id: "zzzzz", title: "cccc" }, ]; /******************************************************* * API Gateway のエンドポイント (CORS対応済み) * CDK デプロイ後のURLに書き換えてください *******************************************************/ const apiBaseUrl = "https://xxxxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod"; /******************************************************* * DOM 要素の取得 *******************************************************/ const usernameInput = document.getElementById('usernameInput'); const videoSelect = document.getElementById('videoSelect'); const youtubePlayer = document.getElementById('youtubePlayer'); const notesTextarea = document.getElementById('notesTextarea'); const saveStatus = document.getElementById('saveStatus'); const commentsList = document.getElementById('commentsList'); const commentTextarea = document.getElementById('commentTextarea'); const postCommentBtn = document.getElementById('postCommentBtn'); /******************************************************* * 現在の動画ID *******************************************************/ let currentVideoId = videoList[0].id; // 初期値 /******************************************************* * ページ読み込み時の初期化 *******************************************************/ window.addEventListener('load', () => { // ユーザ名を入力欄に反映 usernameInput.value = userName; // 動画セレクトの初期値を設定 videoSelect.value = currentVideoId; // 初回ロード loadVideo(currentVideoId); }); /******************************************************* * ユーザ名の入力が変わったら保存 *******************************************************/ usernameInput.addEventListener('input', () => { userName = usernameInput.value; localStorage.setItem('youtube2UserName', userName); }); /******************************************************* * 動画切り替え *******************************************************/ function loadVideo(videoId) { currentVideoId = videoId; youtubePlayer.src = `https://www.youtube.com/embed/${videoId}`; loadNote(); loadComments(); } /******************************************************* * メモを自動保存・読み込み *******************************************************/ // メモ読み込み function loadNote() { const memoKey = `youtube2_memo_{currentVideoId}_${userId}`; const savedMemo = localStorage.getItem(memoKey); if (savedMemo) { notesTextarea.value = savedMemo; saveStatus.textContent = "(メモを読み込みました)"; } else { notesTextarea.value = ""; saveStatus.textContent = "(まだメモがありません)"; } } // メモを入力するたびに保存 notesTextarea.addEventListener('input', () => { const memoKey = `youtube2_memo_${currentVideoId}_${userId}`; localStorage.setItem(memoKey, notesTextarea.value); saveStatus.textContent = "(メモを自動保存しました)"; }); /******************************************************* * コメント一覧を取得 *******************************************************/ async function loadComments() { commentsList.innerHTML = "コメントを読み込み中..."; try { const url = `${apiBaseUrl}/comments?videoId=${currentVideoId}`; const res = await fetch(url); if (!res.ok) { throw new Error("コメント取得に失敗"); } let data = await res.json(); // ★ ここで createdAt 昇順にソート (古い順) data.sort((a, b) => { return new Date(a.createdAt) - new Date(b.createdAt); }); renderComments(data); } catch (error) { console.error(error); commentsList.innerHTML = "コメントの取得に失敗しました。"; } } /******************************************************* * コメント一覧の描画 *******************************************************/ function renderComments(comments) { commentsList.innerHTML = ""; const parentComments = comments.filter(c => !c.parentCommentId); const replies = comments.filter(c => c.parentCommentId); function createCommentElement(comment, indent = 0) { const div = document.createElement('div'); div.style.marginLeft = indent + "px"; // コメント投稿者名を表示 (userName があれば表示、なければ userId) const displayName = comment.userName && comment.userName.trim() !== "" ? comment.userName : comment.userId; div.innerHTML = ` <p> <strong>${displayName}</strong> : ${comment.text}<br/> <small>${comment.createdAt}</small> </p> `; // 返信ボタン const replyButton = document.createElement('button'); replyButton.textContent = "返信する"; replyButton.addEventListener('click', () => { const replyText = prompt("返信を入力してください:"); if (replyText) { postComment(replyText, comment.commentId); } }); div.appendChild(replyButton); // 子コメントを探して再帰的に表示 const childReplies = replies.filter(r => r.parentCommentId === comment.commentId); childReplies.forEach(child => { const childElem = createCommentElement(child, indent + 20); div.appendChild(childElem); }); return div; } parentComments.forEach(pc => { commentsList.appendChild(createCommentElement(pc)); }); if (parentComments.length === 0) { commentsList.innerHTML = "<p>コメントはまだありません。</p>"; } } /******************************************************* * コメント投稿 *******************************************************/ async function postComment(text, parentCommentId = null) { // userName と userId を送信 const body = { videoId: currentVideoId, text, parentCommentId, userId, userName // ← ニックネームを追加 }; try { const res = await fetch(`${apiBaseUrl}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (!res.ok) throw new Error("コメント投稿に失敗"); // 成功したら一覧を再読み込み await loadComments(); console.log("コメント投稿成功"); } catch (error) { console.error(error); alert("コメント投稿に失敗しました。"); } } /******************************************************* * イベントリスナー *******************************************************/ // 動画選択プルダウンで切り替え videoSelect.addEventListener('change', (e) => { loadVideo(e.target.value); }); // コメント投稿ボタン postCommentBtn.addEventListener('click', () => { const text = commentTextarea.value.trim(); if (!text) return; postComment(text); commentTextarea.value = ""; }); |
lambda/comment.js # Lambda関数 (サーバーサイド)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
// youtube2/lambda/comments.js const AWS = require('aws-sdk'); const dynamoDB = new AWS.DynamoDB.DocumentClient(); const { TABLE_NAME } = process.env; const { v4: uuidv4 } = require('uuid'); exports.main = async (event) => { try { const method = event.httpMethod; let body; try { body = event.body ? JSON.parse(event.body) : {}; } catch (err) { body = {}; } if (method === 'GET') { // GET /comments?videoId=xxx でコメント一覧取得 const videoId = event.queryStringParameters?.videoId; if (!videoId) { return { statusCode: 400, body: JSON.stringify({ message: 'videoId is required' }), }; } // videoId で DynamoDB を query const result = await dynamoDB.query({ TableName: TABLE_NAME, KeyConditionExpression: 'videoId = :v', ExpressionAttributeValues: { ':v': videoId } }).promise(); return { statusCode: 200, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true" // 必要に応じて }, body: JSON.stringify(result.Items) }; /* POST の処理例 */ } else if (method === 'POST') { // body: { videoId, text, parentCommentId, userId, userName } const { videoId, text, parentCommentId, userId, userName } = body; if (!videoId || !text || !userId) { return { statusCode: 400, headers: { "Access-Control-Allow-Origin": "*" }, body: JSON.stringify({ message: 'videoId, text, and userId are required' }) }; } const commentId = uuidv4(); const now = new Date().toISOString(); const item = { videoId, commentId, text, parentCommentId: parentCommentId || null, userId, userName: userName || null, // ここで保存 createdAt: now }; await dynamoDB.put({ TableName: TABLE_NAME, Item: item }).promise(); return { statusCode: 200, headers: { "Access-Control-Allow-Origin": "*" }, body: JSON.stringify(item) }; } else { return { statusCode: 405, body: JSON.stringify({ message: 'Method Not Allowed' }), }; } } catch (e) { console.error(e); return { statusCode: 500, body: JSON.stringify({ message: 'Internal Server Error', error: e.toString() }), }; } }; |