react-signature-canvasで手書きメモ管理アプリを作ってみた
こんにちは、CX事業本部の若槻です。
最近、react-signature-canvas
というReactアプリ向けに手書き文字キャンバスを実装できるパッケージを見つけました。
面白そうなパッケージだったので、今回はこのreact-signature-canvas
を使って手書きメモ管理アプリを作ってみました。
アウトプット
次のような手書きメモを管理できるWebアプリを作ります。画面上部のキャンバスでメモを手書きして登録し、画面中央のテーブルで登録済みメモ一覧を参照できます。
手書きメモを登録する手順は次のようになります。
- キャンバスに手書きでメモを描く。
- キャンバスに何か描かれると登録ボタンが有効になる。
- 登録ボタンをクリックするとキャンバスに描かれたメモがテーブルに登録される。
構築は次のような構成でAWS上に行います。
- バックエンド:API Gateway + Lambda(TypeScript) + DynmoDB
- フロントエンド:React + material-table + TypeScript + CloudFront + Amazon S3
ソースコードはGitHubに上げてあります。
やってみた
環境
% sw_vers ProductName: Mac OS X ProductVersion: 10.15.7 BuildVersion: 19H2 % node -v v12.14.0 % npm -v 6.13.4 % cdk --version 1.68.0 (build a6a3f46)
バックエンド作成
AWS CDKプロジェクト新規作成
% mkdir handwritten-memo-app-backend % cd handwritten-memo-app-backend % cdk init app --language=typescript
パッケージのインストール
$ npm install @aws-cdk/[email protected] @aws-cdk/[email protected] @aws-cdk/[email protected] @aws-cdk/[email protected] uuid4
インストールするAWS CDKライブラリのバージョンを1.68.0
と指定しているのは、指定しない場合に@aws-cdk/core
は1.68.0
がインストールされるのに対して機能ごとのパッケージは1.69.0
がインストールされてしまい、両者のバージョンに齟齬が生じてエラーとなったためです。
Lambdaのコードの作成
メモの一括取得(GET)、作成(POST)、更新(PUT)の3つのメソッドに対応するLambdaを作成します。
$ mkdir lib/lambda $ touch lib/lambda/get-item.ts lib/lambda/post-item.ts lib/lambda/update-item.ts
メモ取得APIのLambda
const AWS = require("aws-sdk"); const TABLE_NAME = process.env.TABLE_NAME || ""; const db = new AWS.DynamoDB.DocumentClient(); export const handler = async (): Promise<any> => { const params = { TableName: TABLE_NAME, ConsistentRead: true, }; try { const response = await db.scan(params).promise(); return { statusCode: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type", }, body: JSON.stringify(response.Items), }; } catch (dbError) { return { statusCode: 500, body: JSON.stringify(dbError) }; } };
作成されたメモデータをすぐに取得してフロント側のテーブルに反映できるように、オプションConsistentRead: true
として強力な整合性のある読み込みを行うようにしています。
メモ作成APIのLambda
const AWS = require("aws-sdk"); const TABLE_NAME = process.env.TABLE_NAME || ""; const db = new AWS.DynamoDB.DocumentClient(); const uuid4 = require("uuid4"); export const handler = async (event: any = {}): Promise<any> => { var body = JSON.parse(event.body); const formatDate = (date: Date | any, format: string) => { format = format.replace(/yyyy/g, date.getFullYear()); format = format.replace(/MM/g, ("0" + (date.getMonth() + 1)).slice(-2)); format = format.replace(/dd/g, ("0" + date.getDate()).slice(-2)); format = format.replace(/HH/g, ("0" + date.getHours()).slice(-2)); format = format.replace(/mm/g, ("0" + date.getMinutes()).slice(-2)); format = format.replace(/ss/g, ("0" + date.getSeconds()).slice(-2)); return format; }; const params = { TableName: TABLE_NAME, Item: { imageData: body.imageData, createdAt: formatDate( new Date( Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000 ), "yyyy/MM/ddTHH:mm:ss" ), memoId: uuid4(), }, }; try { await db.put(params).promise(); return { statusCode: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type", }, }; } catch (dbError) { return { statusCode: 500, body: JSON.stringify(dbError) }; } };
メモ更新APIのLambda
const AWS = require("aws-sdk"); const TABLE_NAME = process.env.TABLE_NAME || ""; const db = new AWS.DynamoDB.DocumentClient(); export const handler = async (event: any = {}): Promise<any> => { const formatDate = (date: Date | any, format: string) => { format = format.replace(/yyyy/g, date.getFullYear()); format = format.replace(/MM/g, ("0" + (date.getMonth() + 1)).slice(-2)); format = format.replace(/dd/g, ("0" + date.getDate()).slice(-2)); format = format.replace(/HH/g, ("0" + date.getHours()).slice(-2)); format = format.replace(/mm/g, ("0" + date.getMinutes()).slice(-2)); format = format.replace(/ss/g, ("0" + date.getSeconds()).slice(-2)); return format; }; const key = { memoId: "", }; if (event.pathParameters && event.pathParameters.memoId) { key.memoId = event.pathParameters.memoId; } const params = { TableName: TABLE_NAME, Key: key, UpdateExpression: "set #status = :status, doneAt = :doneAt", ExpressionAttributeNames: { "#status": "status", }, ExpressionAttributeValues: { ":status": "completed", ":doneAt": formatDate( new Date( Date.now() + (new Date().getTimezoneOffset() + 9 * 60) * 60 * 1000 ), "yyyy/MM/ddTHH:mm:ss" ), }, }; try { await db.update(params).promise(); return { statusCode: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type", }, }; } catch (dbError) { return { statusCode: 500, body: JSON.stringify(dbError) }; } };
メモの更新はステータスstatus
を完了completed
に変更する処理のみ行うようにしています。
AWS CDKのデプロイ用コードの作成
デプロイ用のコードを書きます。
import * as cdk from "@aws-cdk/core"; import { Table, AttributeType } from "@aws-cdk/aws-dynamodb"; import { Runtime } from "@aws-cdk/aws-lambda"; import { RestApi, LambdaIntegration, IResource, MockIntegration, PassthroughBehavior, } from "@aws-cdk/aws-apigateway"; import { NodejsFunction } from "@aws-cdk/aws-lambda-nodejs"; export class HandwrittenMemoAppBackendStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const memoTable = new Table(this, "items", { partitionKey: { name: "memoId", type: AttributeType.STRING, }, tableName: "memo_table", }); const getItemLambda = new NodejsFunction(this, "getItemsFunction", { entry: "lib/lambda/get-item.ts", runtime: Runtime.NODEJS_12_X, environment: { TABLE_NAME: memoTable.tableName, }, }); memoTable.grantReadData(getItemLambda); const postItemLambda = new NodejsFunction(this, "postItemsFunction", { entry: "lib/lambda/post-item.ts", runtime: Runtime.NODEJS_12_X, environment: { TABLE_NAME: memoTable.tableName, }, }); memoTable.grantReadWriteData(postItemLambda); const updateItemLambda = new NodejsFunction(this, "completeItemsFunction", { entry: "lib/lambda/update-item.ts", runtime: Runtime.NODEJS_12_X, environment: { TABLE_NAME: memoTable.tableName, }, }); memoTable.grantReadWriteData(updateItemLambda); const api = new RestApi(this, "itemsApi", { restApiName: "Items Service", }); const items = api.root.addResource("items"); const getItemIntegration = new LambdaIntegration(getItemLambda); items.addMethod("GET", getItemIntegration); addCorsOptions(items); const item = api.root.addResource("item"); const postItemIntegration = new LambdaIntegration(postItemLambda); item.addMethod("POST", postItemIntegration); addCorsOptions(item); const memoId = item.addResource("{memoId}"); const updateItemIntegration = new LambdaIntegration(updateItemLambda); memoId.addMethod("PUT", updateItemIntegration); addCorsOptions(memoId); } } export function addCorsOptions(apiResource: IResource) { apiResource.addMethod( "OPTIONS", new MockIntegration({ integrationResponses: [ { statusCode: "200", responseParameters: { "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", "method.response.header.Access-Control-Allow-Origin": "'*'", "method.response.header.Access-Control-Allow-Credentials": "'false'", "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,PUT,POST,DELETE'", }, }, ], passthroughBehavior: PassthroughBehavior.NEVER, requestTemplates: { "application/json": '{"statusCode": 200}', }, }), { methodResponses: [ { statusCode: "200", responseParameters: { "method.response.header.Access-Control-Allow-Headers": true, "method.response.header.Access-Control-Allow-Methods": true, "method.response.header.Access-Control-Allow-Credentials": true, "method.response.header.Access-Control-Allow-Origin": true, }, }, ], } ); } const app = new cdk.App(); new HandwrittenMemoAppBackendStack(app, "HandwrittenMemoAppBackendStack"); app.synth();
Lambdaの定義で@aws-cdk/aws-lambda-nodejs
を使用することにより、cdk deploy
実行時にTypeScriptのコードのトランスパイルとバンドルを自動で行えるようにしています。
AWS CDKでのデプロイ
% cdk deploy
デプロイに成功すると、作成されたAPIエンドポイントhttps://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/
が次のように表示されるので控えておきます。
✅ HandwrittenMemoAppBackendStack Outputs: HandwrittenMemoAppBackendStack.itemsApiEndpointXXXXXXX = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/
これでバックエンドの作成ができました。
フロントエンド作成
AWS CDKプロジェクト新規作成
% mkdir handwritten-memo-app-frontend % cd handwritten-memo-app-frontend % cdk init app --language=typescript
AWS CDK用のパッケージインストール
% npm install @aws-cdk/[email protected] @aws-cdk/[email protected] @aws-cdk/[email protected] % npm install --save-dev dotenv-cli
ここでdotenv-cli
をインストールしてReactアプリがAPIエンドポイントの情報を環境変数として読み込めるようにしています。
AWS CDKのデプロイ用コードの作成
デプロイ用のコードを書きます。
import * as cdk from "@aws-cdk/core"; import * as cloudfront from "@aws-cdk/aws-cloudfront"; import * as s3 from "@aws-cdk/aws-s3"; import * as s3deploy from "@aws-cdk/aws-s3-deployment"; import * as iam from "@aws-cdk/aws-iam"; export class HandwrittenMemoAppFrontendStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const websiteBucket = new s3.Bucket(this, "WebsiteBucket", { websiteErrorDocument: "index.html", websiteIndexDocument: "index.html", }); const websiteIdentity = new cloudfront.OriginAccessIdentity( this, "WebsiteIdentity" ); const webSiteBucketPolicyStatement = new iam.PolicyStatement({ actions: ["s3:GetObject"], effect: iam.Effect.ALLOW, principals: [websiteIdentity.grantPrincipal], resources: [`${websiteBucket.bucketArn}/*`], }); websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement); const websiteDistribution = new cloudfront.CloudFrontWebDistribution( this, "WebsiteDistribution", { errorConfigurations: [ { errorCachingMinTtl: 300, errorCode: 403, responseCode: 200, responsePagePath: "/index.html", }, { errorCachingMinTtl: 300, errorCode: 404, responseCode: 200, responsePagePath: "/index.html", }, ], originConfigs: [ { s3OriginSource: { s3BucketSource: websiteBucket, originAccessIdentity: websiteIdentity, }, behaviors: [ { isDefaultBehavior: true, }, ], }, ], priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL, } ); new s3deploy.BucketDeployment(this, "WebsiteDeploy", { sources: [s3deploy.Source.asset("./web/build")], destinationBucket: websiteBucket, distribution: websiteDistribution, distributionPaths: ["/*"], }); } }
Reactアプリ新規作成
% npx create-react-app web --typescript
Reactアプリ用のパッケージインストール
% npm --prefix web install react-dom @types/react-dom react-signature-canvas @types/react-signature-canvas @material-ui/core material-table axios % npm --prefix web install --save-dev @types/react
TypeScriptでreact-signature-canvas
を使用する場合は型定義として@types/react-signature-canvas
も合わせてインストールします。
.env
の作成
$ touch web/.env
.env
ファイルに環境変数を記載してdotenv
で読み込めるようにします。API_ENDPOINT_MEMOS
は先程バックエンドのデプロイ時に控えたAPIエンドポイントのURLを指定します。
SKIP_PREFLIGHT_CHECK=true API_ENDPOINT_MEMOS=https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/
ここでSKIP_PREFLIGHT_CHECK=true
はルートとアプリの両ディレクトリにインストールされたライブラリのバージョンに差異がある場合に競合エラーを回避するためのフラグとして指定しています。
buildスクリプトの更新
web/package.json
のbuildスクリプトを更新して、ビルド時にdotenv
で.env
から環境変数を取り込むようにします。
{ //〜〜〜 "scripts": { "start": "react-scripts start", "build": "dotenv -e .env react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, }
フォントを導入
web/public/index.html
の<head>
タグ内ににフォントのCDNのURLを追加します。Reactでmaterial-table(Material-UI)を使用する際は追加するようにしましょう。
<!DOCTYPE html> <html lang="en"> <head> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP&subset=japanese" /> <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
Webアプリの枠組みのテンプレートとなるコンポーネントを作成
% touch web/src/GenericTemplate.tsx
import React from "react"; import clsx from "clsx"; import { createMuiTheme } from "@material-ui/core/styles"; import * as colors from "@material-ui/core/colors"; import { makeStyles, createStyles, Theme } from "@material-ui/core/styles"; import { ThemeProvider } from "@material-ui/styles"; import CssBaseline from "@material-ui/core/CssBaseline"; import AppBar from "@material-ui/core/AppBar"; import Toolbar from "@material-ui/core/Toolbar"; import Typography from "@material-ui/core/Typography"; import Container from "@material-ui/core/Container"; const theme = createMuiTheme({ typography: { fontFamily: [ "Noto Sans JP", "Lato", "游ゴシック Medium", "游ゴシック体", "Yu Gothic Medium", "YuGothic", "ヒラギノ角ゴ ProN", "Hiragino Kaku Gothic ProN", "メイリオ", "Meiryo", "MS Pゴシック", "MS PGothic", "sans-serif", ].join(","), }, palette: { primary: { main: colors.blue[800] }, }, }); const useStyles = makeStyles((theme: Theme) => createStyles({ root: { display: "flex", }, toolbar: { paddingRight: 24, }, appBar: { zIndex: theme.zIndex.drawer + 1, transition: theme.transitions.create(["width", "margin"], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), }, title: { flexGrow: 1, }, pageTitle: { marginBottom: theme.spacing(1), }, appBarSpacer: theme.mixins.toolbar, content: { flexGrow: 1, height: "100vh", overflow: "auto", }, container: { paddingTop: theme.spacing(4), paddingBottom: theme.spacing(4), }, paper: { padding: theme.spacing(2), display: "flex", overflow: "auto", flexDirection: "column", }, link: { textDecoration: "none", color: theme.palette.text.secondary, }, }) ); export interface GenericTemplateProps { children: React.ReactNode; title: string; } const GenericTemplate: React.FC<GenericTemplateProps> = ({ children, title, }) => { const classes = useStyles(); return ( <ThemeProvider theme={theme}> <div className={classes.root}> <CssBaseline /> <AppBar position="absolute" className={clsx(classes.appBar)}> <Toolbar className={classes.toolbar}> <Typography component="h1" variant="h6" color="inherit" noWrap className={classes.title} > 手書きメモ </Typography> </Toolbar> </AppBar> <main className={classes.content}> <div className={classes.appBarSpacer} /> <Container maxWidth="lg" className={classes.container}> <Typography component="h2" variant="h5" color="inherit" noWrap className={classes.pageTitle} > {title} </Typography> {children} </Container> </main> </div> </ThemeProvider> ); }; export default GenericTemplate;
Webアプリのコンテンツ(手書きキャンバス、テーブル)となるコンポーネントを作成
% touch web/src/MemoPage.tsx
import React, { useRef, useState, useCallback, useEffect } from "react"; import GenericTemplate from "./GenericTemplate"; import MaterialTable from "material-table"; import SignatureCanvas from "react-signature-canvas"; import ReactSignatureCanvas from "react-signature-canvas"; import pointGroupArray from "react-signature-canvas"; import Axios from "axios"; import Paper from "@material-ui/core/Paper"; import { makeStyles } from "@material-ui/core/styles"; import Button from "@material-ui/core/Button"; const API_ENDPOINT_MEMOS = process.env.REACT_APP_API_ENDPOINT_MEMOS!; export interface Memo { imageId: string; createdAt: string; imageData: string; status: string; doneAt: string; } const useStyles = makeStyles((theme) => ({ paper: { padding: theme.spacing(2), marginBottom: theme.spacing(2), display: "flex", alignItems: "center", }, button: { marginLeft: theme.spacing(10), height: 60, }, })); const MemoPage: React.FC = () => { const classes = useStyles(); const canvasRef = useRef<ReactSignatureCanvas | null>(); const [image, setImage] = useState<string>(); const useGetMemoList = () => { const [isCompleted, setIsCompleted] = useState(false); const [data, setData] = useState<Memo[]>([]); const getData = useCallback(async () => { setIsCompleted(false); const response = await Axios.get(API_ENDPOINT_MEMOS + "items"); setData(response.data); setIsCompleted(true); }, []); return { getData, data, isCompleted }; }; const getMemoList = useGetMemoList(); const createTableData = () => { const data = getMemoList.data.map((d) => Object.assign({}, d, { imageData: <img src={`${d.imageData}`} alt="text" />, }) ); return data; }; const createMemo = async () => { await Axios.post( API_ENDPOINT_MEMOS + "item", { imageData: image }, { headers: { "Content-Type": "application/json", }, } ); }; const updateMemo = async (data: Memo) => { await Axios.put(API_ENDPOINT_MEMOS + "item/" + (data as any).memoId, { headers: { "Content-Type": "application/json", }, }); }; useEffect(() => { if (!getMemoList.isCompleted) { getMemoList.getData(); } }, [getMemoList]); return ( <GenericTemplate title=""> <Paper className={classes.paper}> <SignatureCanvas ref={(ref) => { canvasRef.current = ref; }} minWidth={2} maxWidth={2} penColor="white" backgroundColor="black" canvasProps={{ width: 400, height: 80, className: "sigCanvas", }} onEnd={() => { setImage((canvasRef.current as pointGroupArray).toDataURL()); }} /> <Button className={classes.button} variant="contained" color="primary" disabled={image === undefined} onClick={() => { createMemo(); setImage(undefined); (canvasRef.current as pointGroupArray).clear(); getMemoList.getData(); }} > 登録 </Button> <Button className={classes.button} variant="contained" color="primary" disabled={image === undefined} onClick={() => { setImage(undefined); (canvasRef.current as pointGroupArray).clear(); }} > クリア </Button> </Paper> <MaterialTable columns={[ { title: "メモ画像", field: "imageData" }, { title: "登録日", field: "createdAt", defaultSort: "desc" }, { title: "ステータス", field: "status" }, { title: "完了日", field: "doneAt" }, ]} data={createTableData()} options={{ search: false, toolbar: false, }} localization={{ header: { actions: "" }, }} actions={[ { icon: () => ( <Button variant="contained" color="primary"> 完了 </Button> ), onClick: (_, data) => { updateMemo(data as any); getMemoList.getData(); }, }, ]} /> </GenericTemplate> ); }; export default MemoPage;
手書きキャンバスの実装
メモを手書きするキャンバスはSignatureCanvas
コンポーネントとして実装し、onEnd
プロパティを使用してキャンバス上での手書きストロークが終了するたびに、キャンバスの画像データをBase64形式で取得するようにしています。
<SignatureCanvas ref={(ref) => { canvasRef.current = ref; }} minWidth={2} maxWidth={2} penColor="white" backgroundColor="black" canvasProps={{ width: 400, height: 80, className: "sigCanvas", }} onEnd={() => { setImage((canvasRef.current as pointGroupArray).toDataURL()); }} />
登録処理の実装
キャンバス右横に配置した登録
ボタンをクリックすると、キャンパスに描かれた手書きメモがcreateMemo()
によりテーブルに登録されます。またキャンバスがclear()
によりクリアされます。
<Button className={classes.button} variant="contained" color="primary" disabled={image === undefined} onClick={() => { createMemo(); setImage(undefined); (canvasRef.current as pointGroupArray).clear(); getMemoList.getData(); }} > 登録 </Button>
登録
ボタンをクリック時の様子
クリア処理の実装
キャンバス右横に配置したクリア
ボタンをクリックすると、キャンバスがclear()
によりクリアされます。
<Button className={classes.button} variant="contained" color="primary" disabled={image === undefined} onClick={() => { setImage(undefined); (canvasRef.current as pointGroupArray).clear(); }} > クリア </Button>
クリア
ボタンをクリック時の様子
完了処理の実装
MaterialTable
コンポーネントでは、actions
プロパティを使用して、メモごとの完了
ボタンをクリックするとupdateMemo()
により更新APIへのリクエストが行われ該当のメモのステータスを完了completed
に変更するようにしています。
actions={[ { icon: () => ( <Button variant="contained" color="primary"> 完了 </Button> ), onClick: (_, data) => { updateMemo(data as any); getMemoList.getData(); }, }, ]}
完了
ボタンをクリック時の様子
App.tsx
の更新
web/src/App.tsx
ファイルを次の内容で更新します。
import React from 'react'; import './App.css'; import MemoPage from "./MemoPage"; const App: React.FC = () => { return ( <MemoPage/> ); } export default App;
AWS CDKでのデプロイ
tsconfig.json
のexclude
にReactアプリのディレクトリを追加します。
{ // "exclude": ["cdk.out", "web"] }
Reactアプリのビルドとデプロイを実行します。
% npm --prefix web run build % cdk deploy
デプロイが成功したらCloudFrontコンソールでWebアプリのアクセスURLxxxxxxxxx.cloudfront.net
を確認します。
xxxxxxxxx.cloudfront.net
でアクセスできました。
おわりに
react-signature-canvas
を使って手書きメモ管理アプリを作ってみました。
Webアプリなのでタブレットでもスマートフォンでも使えますし、日常の買い物用の手書きメモなどの用途に使えそうだなと思います。
参考
- React + TensorFlow.jsで手書き数字認識アプリを作ってみた - Qiita
- React + Material-UIの画面にmaterial-tableを導入してみた | Developers.IO
- React + Material-UIで管理画面を作成してみた | Developers.IO
- base64形式にして画像を表示できるようにする React - Qiita
- AWS CDKでReactアプリをデプロイしてみた | Developers.IO
- react-signature-canvas - npm
- DefinitelyTyped/react-signature-canvas-tests.tsx at master · DefinitelyTyped/DefinitelyTyped
- DefinitelyTyped/index.d.ts at master · DefinitelyTyped/DefinitelyTyped
- JavaScript:undefined値の判定: Architect Note
- 4 ways to center a component in Material-UI | by Tsubasa Kondo | Medium
- reactjs - react-signature-canvas clears initial user input - Stack Overflow
- ステップ 3: 項目を作成、読み込み、更新、削除する - Amazon DynamoDB
- チュートリアル: Lambda プロキシ統合を使用して Hello World REST API をビルドする - Amazon API Gateway
- [Node.js]AWS Lambda上で日本現在時刻を取得する
- String.prototype.slice() - JavaScript | MDN
- Date.prototype.toLocaleString() - JavaScript | MDN
- uuid4 - npm
- ts - Could not find a declaration file for module 'module-name'. '/path/to/module-name.js' implicitly has an 'any' type - Stack Overflow
- JavaScript 日付を指定した書式の文字列にフォーマットする
- Amazon DynamoDBの更新で「Attribute name is a reserved keyword」エラー - キリウ君が読まないノート
- AWS CDK Developer GuideのチュートリアルをTypeScriptでやってみた | Developers.IO
- [AWS CDK超入門] DynamoDB + Lambda + API GatewayでAPIを作ってみた | Developers.IO
- AWS CDKでLambda Function用のTypeScriptのバンドルを簡単に行う | Developers.IO
- AWSCDKでLambda(Nodejs)をTypeScriptのままデプロイする - Qiita
- API Gateway の Lambda プロキシ統合のCORS対応をまとめてみる | Developers.IO
- material-tableでActionsを使用してレコードごとのボタンを実装してみた | Developers.IO
- curlコマンドでPOSTする - Qiita
- Node.js|AWS SDKでDynamoDBを操作(挿入, 取得, 更新, 削除) - わくわくBank
- amazon web services - AWS Lambda - Getting path parameters using Node.js - Stack Overflow
- node.jsでファイル出力 - Qiita
- 【TypeScript】【React】Could not find a declaration file for module ‘react’.の対処について - Qiita
- 【いまさらですが】package.jsonのdependenciesとdevDependencies - Qiita
- Reactにおける環境変数を設定について、ようやく理解したので原因と共にまとめてみる_100DaysOfCodeチャレンジ38日目(Day_38:#100DaysOfCode) - Qiita
- npmでパッケージの特定のバージョンをインストールする - Qiita
- axios、async/awaitを使ったHTTPリクエスト(Web APIを実行) - Qiita
- Reactの環境変数をdotenv-cliで切り替えてみた | Developers.IO
- 【React】useEffectの第2引数って? - Qiita
- AWS CDK の Argument of type 'this' is not assignable to parameter of type 'Construct'. エラーの対応方法 - Qiita
- DynamoDBの強力な整合性のある読み込みでの料金 | Developers.IO
- Class: AWS.DynamoDB.DocumentClient — AWS SDK for JavaScript
以上