プログラミング初心者のための「シンプル家計簿アプリ」開発入門

データを「ブラウザ」に保存できる「シンプルな家計簿アプリ」を作りながら、データの「登録」「追加」「編集」「削除」の機能の作り方について学んでいきましょう。

本来は「JQUERY」などのJavascriptをより簡潔に記述できるライブラリを使う方法が主流ですが、今回は「Javascript」のみを使い外部のライブラリなどは利用しないで作る方法についてお話をしていきたいと思います。

データの扱い方を学ぶことで、

  • TODOアプリ
  • 持ち物管理アプリ
  • 釣果管理アプリ
  • ゴルフスコア管理アプリ
  • 外出履歴記録アプリ

など、さまざまなアプリ開発に応用できますので、プログラムの書き方を身に付けることで、「オリジナルアプリ」の開発にもチャレンジすることができるようになります。

今回のアプリは下記の動画のようなシンプルな作りの「家計簿アプリ」です。

今回作成するのはこのような、

  • データ表示
  • データ登録
  • データ編集
  • データ削除

ができるブラウザで動作する「家計簿アプリ」です。

データの管理方法

家計簿内のデータを管理するために、ブラウザ内にデータを保存することができる「WEB SQL」を利用していますが、「WEB SQL」はHTML5の仕様には含まれていないことと、本来はHTML5の仕様に含まれている「Indexded DB」を利用することが推奨されています。

しかし、「Indexded DB」は、SQLを利用したデータベースアクセスのような使いやすいインターフェースが用意されていないため、今回は「WEB SQL」を利用することにしました。

HTML5の仕様には含まれていませんが、数多くのブラウザが「WEB SQL」をサポートしています。

これまでにSQLを用いてデータベースを利用した経験がある方なら、データベース関連の処理も理解がしやすいのではないかと思います。

データベースの利用経験が無い方は、

→「データベースを理解する!データベースの基本と「SQL」入門」

を先にご参照ください。

  • SELECT
  • INSERT
  • UPDATE
  • DELETE
  • WHERE

などのSQL文の使い方について学んでおきましょう。

データベーステーブル作成

家計簿アプリでは、「登録日・内容・金額」が記録されるようになっていますが、データベースのテーブルではこれらの項目に対応したカラムが作成されています。

データベースのテーブルを作成するプログラムは、

// テーブルが存在しない場合、テーブルを作成
db.transaction(function(trns){
    trns.executeSql('create table if not exists hab (id integer primary key autoincrement,contents text not null,price int not null,registed_at datetime)')
  }
);

のようになっています。

この中の

create table if not exists hab (id integer primary key autoincrement,contents text not null,price int not null,registed_at datetime)

の部分がテーブルを作成するSQL文となっていますが、「create table」がテーブルを作成するSQL文で、「if not exist テーブル名」の部分は、「hab」 という名前のテーブルが無ければテーブルを作成するよう指定しています。

そして、「,(カンマ)」で区切られている、

id integer primary key autoincrement,contents text not null,price int not null,registed_at datetime

の部分に作成するカラムの情報が記述されています。

記述方法は、

カラム名 データ型 オプション

の順序となっていて、例えば先頭の、

id integer primary key autoincrement

の部分は、カラム名が「id」で格納するデータは「int」型(整数)で、「primary key(主キーに設定)」と「autoincrement(値を自動で1ずつ増加)」を指定するという意味になります。

残りの3つのカラムは、

■内容
カラム名:contents
データ型:text
オプション:not null

■金額
カラム名:price
データ型:int
オプション:not null

■登録日付
カラム名:registed_at
データ型:datetime

※「not null」はカラムの内容を「null(値が空)」の状態にできないという制約

という内容となっています。

データ登録

データを登録するためには、下図のように、「登録データ」を入力・設定後、「登録」ボタンをクリックします。

データ登録

「登録」ボタンをクリックすると「registSpending」関数が実行されますが、この関数の中でデータ登録処理を行っています。

/**
 *  データ登録処理
 */ 
function registSpending(){
    // 入力データのチェック
    if( chkRegistData('regist_date','regist_contents', 'regist_price') ){
        db.transaction(
            function(trans){
                // INSERT文を実行
                trans.executeSql('INSERT INTO hab (contents, price, registed_at) VALUES (?,?,?);', [getElmId('regist_contents').value, getElmId('regist_price').value, getElmId('regist_date').value + " 00:00:01"], 
                    function(){
                        // 表示データを更新
                        execDisplayDesignatedPeriodData();
                        
                        //登録データ(内容・金額)の入力ボックスの値を初期化
                        getElmId('regist_contents').value = ""
                        getElmId('regist_price').value = "";
                        
                        alert('データを追加しました。');
                    }, 
                    // エラー処理
                    function(){
                        alert('データが追加できません。');
                    }
                );
            }
        );
    }
}

データが正しいかをチェックするために「chkRegistData」関数でデータのチェック処理を行っています。

/**
 *  登録データのチェック
 */ 
function chkRegistData(date_id, contents_id, price_id){
    var correctFlg = true;
    
    // 日付の書式チェック
    if ( getElmId(date_id).value.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/g) === null){
        alert('登録日付が正しくありません。');
        correctFlg = false;
    }
    
    // 内容のチェック
    if ( getElmId(contents_id).value.length === 0 ){
        alert('内容を入力してください。');
        correctFlg = false;
    }
    
    // 価格のチェック
    if ( getElmId($).value.length === 0 ){
        alert('価格を入力してください。');
        correctFlg = false;
    } else {
        // 価格の書式チェック
        if ( getElmId(price_id).value.match(/^[0-9]*$/g) === null ){
            alert('価格が正しくありません。');
            correctFlg = false;
        }  
    }
    return correctFlg;
}

「getElmId」関数は、「document.getElementByID」メソッドの「別名(アライアス)」メソッドです。

/**
 *  ID名からDOMを取得
 */
function getElmId(id){
    return document.getElementById(id);
}

多用しているメソッドで記述が長いメソッドは、このように「別名メソッド」を作ることで、プログラムをわかりやすく簡潔に記述することができるようになります。

メソッドの内容は単純に「document.getElementById」メソッドで取得したDOMを返しているだけです。

「日付」のチェック処理では、正規表現を利用して「4桁の数字-2桁の数字-2桁の数字」という書式になっているかをチェックしています。

「正規表現」は文字列があるパターンとマッチしているかを調べるための機能で、「match」メソッドを利用することで、正規表現を元に文字列のチェックを行うことができます。

→「String.prototype.match() - JavaScript | MDN」

「match」メソッドの戻り値が「null」の場合は、パターンにマッチしていないため、エラーメッセージをアラートで表示しています。

「chkRegistData」関数のチェック処理でエラーが存在する場合は「false」が返り、データの登録処理は行いません。

エラーが存在しなければ、「executeSql」メソッドを実行しINSERT文を実行します。

executeSql(INSERT文 , プレースホルダに割り当てるデータ配列, 成功時の処理, 失敗時の処理);

INSERT文は、

INSERT INTO hab (contents, price, registed_at) VALUES (?,?,?);

のようになっていますが「VALUES (?,?,?)」の部分の「?」の部分がプレースホルダです。ここに割り当てるデータを、

[getElmId('regist_contents').value, getElmId('regist_price').value, getElmId('regist_date').value + " 00:00:01"]

の部分で、入力された「登録日付・内容・金額」を設定しています。

登録データには「時間」は含まれていないため、「0時0分1秒」を登録時間に設定しています。

データ登録が成功すると、

// 表示データを更新
execDisplayDesignatedPeriodData();

//登録データ(内容・金額)の入力ボックスの値を初期化
getElmId('regist_contents').value = ""
getElmId('regist_price').value = "";

alert('データを追加しました。');

のように「最新データの表示」→「登録データの入力ボックスの値を初期化」→「アラートの表示」を行っています。

データ編集

登録したデータを編集するには「編集」ボタンをクリックします。

データ編集

「編集」ボタンをクリックすると、「onClickEditBtn」関数が実行され、「編集画面」が表示されます。

/**
 *  「編集ボタン」クリック時の処理
 */
function onClickEditBtn(event){
    // データ識別用id属性の値を取得
    var edit_id = event.target.parentNode.parentNode.getAttribute("id");
    
    if ( result !== false ){
        // id値のtd要素を取得
        var td = getElmId(edit_id).children;
        
        // テーブル要素を取得
        var table = getElmId('hab_tb');

        // テーブルヘッダー作成
        table.innerHTML = "<tr><th>日付</th><th>内容</th><th colspan='3'>金額</th></tr>";

        // テーブル行を作成
        var displayData = document.createElement('tr');

        // データ識別用id属性を設定
        displayData.setAttribute("id", edit_id);

        // 編集用HTMLコード作成
        displayData.innerHTML = '<td><input type="date" id="edit_date" value="' + td[0].textContent.split(" ")[0] + '"></td><td><input type="text" id="edit_contents" value="' + td[1].textContent + '"></td><td class="price"><input type="number" id="edit_price" value="' + td[2].outerText + '"></td><td></td><td><button id="edit_exec_btn">編集実行</button></td>';
        
        //編集用HTMLコードをテーブルに設定
        table.appendChild(displayData);

        // 「編集実行」ボタンにイベントリスナーを設定
        getElmId('edit_exec_btn').addEventListener('click', onClickEditExecBtn, false);
    }
}

どのデータを編集するのかを特定するために、

// データ識別用id属性の値を取得
var edit_id = event.target.parentNode.parentNode.getAttribute("id");

の部分でtr要素のid属性を取得しています。

データ編集画面

「parentNode」メソッドは指定要素のDOMツリーの親要素を取得するメソッドです。

ボタン要素から数えると、「tr要素」は「2つ上」の親要素であるため、「parentNode」メソッドを2回実行し「tr要素」を取得しています。

「getAttribute("id")」を実行することで、id属性の値を取得することができますので、編集画面用の「tr要素」を生成し、取得したid属性を設定します。

下記のような編集画面が作成されますので、内容を編集して「編集実行」ボタンをクリックすると、「onClickEditExecBtn」メソッドが実行されます。

データ編集実行

「onClickEditExecBtn」メソッドではUPDATE文を実行して、データベースのデータを更新しています。

/**
 *  データ更新処理
 */
function onClickEditExecBtn(event){
    if ( chkRegistData('edit_date', 'edit_contents', 'edit_price') ){
        // データ識別用id属性の値を取得
        var edit_id = event.target.parentNode.parentNode.getAttribute("id");
        
        // 登録用「年月日」時間を作成
        var regist_date = getElmId('edit_date').value + ' 00:00:01';

        db.transaction(function(tx) {
            // UPDATE文を実行
            tx.executeSql("update hab set contents = '" + getElmId('edit_contents').value + "', price = '" + getElmId('edit_price').value + "', registed_at = '" + regist_date + "' where id='" + edit_id + "'", [],
                function () {
                    //今月のデータを表示
                    displayThisMonthData();
                    alert('データを更新しました。');
                },
                // エラー処理
                function () {
                    alert('データが更新できません。');
                }
            )}
        );
    }
}

データ削除

「削除」ボタンをクリックすると、「onClickDelBtn」メソッドを実行します。

データ削除

「onClickDelBtn」メソッドではDELETE文を実行して、データベースのデータを削除しています。

/**
 *  「削除ボタン」クリック時の処理
 */
function onClickDelBtn(event){

    // データ削除の確認ダイアログ表示
    if( confirm("データを削除しますか?") ){
    
        // データ識別用id属性の値を取得
        var del_id = event.target.parentNode.parentNode.getAttribute("id");

        db.transaction(function(tx) {
            // DELETE文を実行
            tx.executeSql("delete from hab where id='" + del_id + "'", [],
                function () {
                    //今月のデータを表示
                    displayThisMonthData();
                },
                // エラー処理
                function () {
                    alert('データ削除失敗');
                }
            )}
        );
    }
}

データ削除に関してはこれまでの処理とあまり違いはありませんが、「confirm」メソッドを使用し、データを削除してよいかを事前に確認する処理が追加されています。

どんどん便利に!?追加機能の作成にチャレンジしてみよう

今回作成した「家計簿アプリ」には、他にも

  • 合計金額計算
  • カテゴリ設定
  • 収支グラフ表示
  • 残金額管理
  • データのインポート・エクスポート

など、さまざまな追加機能を作成していくことで、より便利で使いやすいアプリにしていくこともできます。

どのような機能があると便利なのかを考えながらぜひ「オリジナル機能の作成」にチャレンジしてみてはいかがでしょうか。

今回作成した家計簿アプリのサンプルコードは下記のようになります。

<!DOCTYPE html>
<html lang="ja">
<head>
   <meta charset="UTF-8">
   <title>シンプル家計簿</title>
   <link rel="stylesheet" href="./household_account_book.css">
</head>
<body>
    <header>
        <h1>シンプル家計簿</h1>
    </header>
    <!-- 登録項目:日時,内容,金額 -->
    <div id="regist_spending">
        <h2>データ登録</h2>
        <table id="regist_data_tb">
            <tr><td>登録日付</td><td><input type="date" id="regist_date"></td></tr>
            <tr><td>登録内容</td><td><input type="text" id="regist_contents" required></td></tr>
            <tr><td>金額</td><td><input type="number" id="regist_price" min="1"></td></tr>
            <tr><td colspan="2"><button id="regist_spending_btn">登録</button></td></tr>
        </table>
    </div>
    <main>
        <input type="date" id="start_date">〜<input type="date" id="last_date"><button id="display_btn">指定期間表示</button>
        <table id="hab_tb">
            <tr>
                <th>日付</th>
                <th>内容</th>
                <th>金額</th>
            </tr>
        </table>
    </main>

    <script>
        var date = new Date();
        
        // 「年月日」を配列で取得 ( [0]:年, [1]:月, [2]:日 )
        var dateArray = getTSDate(date);
        
        // DB情報を設定
        var dbName = 'household_account_book';                 // DB名
        var dbVersion = '1.0';                                 // DBバージョン
        var dbDescription = 'household_account_book_database'; // DB説明文
        var dbSize = 65536;                                    // DBサイズ
        var db = openDatabase(dbName, dbVersion, dbDescription, dbSize);

        // テーブルが存在しない場合、テーブルを作成
        db.transaction(function(trns){
            trns.executeSql('create table if not exists hab (id integer primary key autoincrement,contents text not null,price int not null,registed_at datetime)')
          }
        );

        // 登録日付に「本日の年月日」を設定
        document.getElementById("regist_date").value = dateArray[0] + "-" + dateArray[1] + "-" + dateArray[2]

        // 今月のデータを表示
        displayThisMonthData();

        /**
         *  今月のデータを表示
         */ 
        function displayThisMonthData(){
           var date = new Date();
           
           // 今月の最終日の年月日を配列で取得
           var lastDateArray = getTSDate(new Date(getLastDay(date.getFullYear(),date.getMonth())));
           
           var YM = lastDateArray[0] + "-" + lastDateArray[1];
           var startDate = YM + "-" + "01 00:00:00";
           var lastDate = YM + "-" + lastDateArray[2] + " 23:59:59";

           // 今月の「初日」と「最終日」をUIに表示
           getElmId('start_date').value = YM + "-01";
           getElmId('last_date').value = YM + "-" + lastDateArray[2];
           
           // 今月のデータを表示
           displaySpendingData(startDate, lastDate);
        }
        
        /**
         *  指定期間のデータを表示
         */ 
        
        function displaySpendingData(startDate, lastDate){
            db.transaction(function (tx) {
                // SELECT文を実行
                tx.executeSql("select id, contents, price, registed_at from hab WHERE registed_at BETWEEN '" + startDate + "' AND '" + lastDate + "' order by registed_at ASC" , [],
                    function (tx, results) {
                        // テーブルを取得
                        var table = getElmId('hab_tb');
                        
                        // テーブルヘッダー生成
                        table.innerHTML = "<tr><th>日付</th><th>内容</th><th colspan='3'>金額</th></tr>";

                        for (i = 0; i < results.rows.length; i++){                   
                            var displayData = document.createElement('tr');
                            
                            // テーブル識別用id属性付加
                            displayData.setAttribute("id", results.rows.item(i).id);
                            
                            // テーブルデータ生成
                            displayData.innerHTML = '<td>' + results.rows.item(i).registed_at.split(' ')[0] + '</td><td>' + results.rows.item(i).contents + '</td><td class="price">' + results.rows.item(i).price + '</td><td><button class="delete_btn">削除</button></td><td><button class="edit_btn">編集</button></td>';
                            
                            // テーブルデータ追加
                            table.appendChild(displayData);
                        }

                        //「削除」ボタンにイベントリスナーを設定
                        var del_btns = getElmCls('delete_btn');
                        for(let btn of del_btns) {
                            btn.addEventListener('click', onClickDelBtn, false);
                        }

                        //「編集」ボタンにイベントリスナーを設定
                        var edit_btns = getElmCls('edit_btn');
                        for(let btn of edit_btns) {
                            btn.addEventListener('click', onClickEditBtn, false);
                        }
                   },
                   // エラー処理
                   function(transaction, error){
                       alert("データが取得できません。");
                   })
               }
           )
        }
        
        /**
         *  データ登録処理
         */ 
        function registSpending(){
            // 入力データのチェック
            if( chkRegistData('regist_date','regist_contents', 'regist_price') ){
                db.transaction(
                    function(trans){
                        // INSERT文を実行
                        trans.executeSql('INSERT INTO hab (contents, price, registed_at) VALUES (?,?,?);', [getElmId('regist_contents').value, getElmId('regist_price').value, getElmId('regist_date').value + " 00:00:01"], 
                            function(){
                                // 表示データを更新
                                execDisplayDesignatedPeriodData();
                                
                                //登録データ(内容・金額)の入力ボックスの値を初期化
                                getElmId('regist_contents').value = ""
                                getElmId('regist_price').value = "";
                                
                                alert('データを追加しました。');
                            }, 
                            // エラー処理
                            function(){
                                alert('データが追加できません。');
                            }
                        );
                    }
                );
            }
        }

        /**
         *  登録データのチェック
         */ 
        function chkRegistData(date_id, contents_id, price_id){
            var correctFlg = true;
            
            // 日付の書式チェック
            if ( getElmId(date_id).value.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/g) === null){
                alert('登録日付が正しくありません。');
                correctFlg = false;
            }
            
            // 内容のチェック
            if ( getElmId(contents_id).value.length === 0 ){
                alert('内容を入力してください。');
                correctFlg = false;
            }
            
            // 価格のチェック
            if ( getElmId(price_id).value.length === 0 ){
                alert('価格を入力してください。');
                correctFlg = false;
            } else {
                // 価格の書式チェック
                if ( getElmId(price_id).value.match(/^[0-9]*$/g) === null ){
                    alert('価格が正しくありません。');
                    correctFlg = false;
                }  
            }
            return correctFlg;
        }
        
        /**
         *  「削除ボタン」クリック時の処理
         */
        function onClickDelBtn(event){
        
            // データ削除の確認ダイアログ表示
            if( confirm("データを削除しますか?") ){
            
                // データ識別用id属性の値を取得
                var del_id = event.target.parentNode.parentNode.getAttribute("id");

                db.transaction(function(tx) {
                    // DELETE文を実行
                    tx.executeSql("delete from hab where id='" + del_id + "'", [],
                        function () {
                            //今月のデータを表示
                            displayThisMonthData();
                        },
                        // エラー処理
                        function () {
                            alert('データ削除失敗');
                        }
                    )}
                );
            }
        }
        
        /**
         *  「編集ボタン」クリック時の処理
         */
        function onClickEditBtn(event){
            // データ識別用id属性の値を取得
            var edit_id = event.target.parentNode.parentNode.getAttribute("id");

            // id値のtd要素を取得
            var td = getElmId(edit_id).children;
            
            // テーブル要素を取得
            var table = getElmId('hab_tb');

            // テーブルヘッダー作成
            table.innerHTML = "<tr><th>日付</th><th>内容</th><th colspan='3'>金額</th></tr>";

            // テーブル行を作成
            var displayData = document.createElement('tr');

            // データ識別用id属性を設定
            displayData.setAttribute("id", edit_id);

            // 編集用HTMLコード作成
            displayData.innerHTML = '<td><input type="date" id="edit_date" value="' + td[0].textContent.split(" ")[0] + '"></td><td><input type="text" id="edit_contents" value="' + td[1].textContent + '"></td><td class="price"><input type="number" id="edit_price" value="' + td[2].outerText + '"></td><td></td><td><button id="edit_exec_btn">編集実行</button></td>';
            
            //編集用HTMLコードをテーブルに設定
            table.appendChild(displayData);

            // 「編集実行」ボタンにイベントリスナーを設定
            getElmId('edit_exec_btn').addEventListener('click', onClickEditExecBtn, false);
            
        }
        
        /**
         *  データ更新処理
         */
        function onClickEditExecBtn(event){
            if ( chkRegistData('edit_date', 'edit_contents', 'edit_price') ){
                // データ識別用id属性の値を取得
                var edit_id = event.target.parentNode.parentNode.getAttribute("id");
                
                // 登録用「年月日」時間を作成
                var regist_date = getElmId('edit_date').value + ' 00:00:01';

                db.transaction(function(tx) {
                    // UPDATE文を実行
                    tx.executeSql("update hab set contents = '" + getElmId('edit_contents').value + "', price = '" + getElmId('edit_price').value + "', registed_at = '" + regist_date + "' where id='" + edit_id + "'", [],
                        function () {
                            //今月のデータを表示
                            displayThisMonthData();
                            alert('データを更新しました。');
                        },
                        // エラー処理
                        function () {
                            alert('データが更新できません。');
                        }
                    )}
                );
            }
        }

        /**
         *  指定期間の「表示」ボタンクリック時の処理
         */ 
        function onClickDisplayDesignatedPeriodDataBtn(){         
            execDisplayDesignatedPeriodData();
        }
        
        /**
         *  指定期間のデータを表示
         */ 
        function execDisplayDesignatedPeriodData(){
            // 表示開始時刻データ作成
            var startDate = getElmId('start_date').value + " 00:00:00";
            
            // 表示終了時刻データ作成
            var lastDate = getElmId('last_date').value + " 23:59:59";
            
            // 指定期間のチェック処理
            if( chkDesignatedPeriod(startDate, lastDate) === true ) {
                displaySpendingData(startDate,lastDate);
            } else {
                alert("開始日付は終了日付より前の日付を選択してください。");
            }
        }
        
        /**
         *  「指定期間」の入力値チェック
         */ 
        function chkDesignatedPeriod(startDate, lastDate) { 
            var startYmd = getElmId('start_date').value.split('-'); 
            var lastYmd = getElmId('last_date').value.split('-'); 

            startTP = Number(new Date(startYmd[0],startYmd[1] - 1,startYmd[2], 0, 0, 0, 0));
            lastTP = Number(new Date(lastYmd[0],lastYmd[1] - 1,lastYmd[2], 23, 59, 59, 0));
            
            if( startTP < lastTP ){
                return true;
            } else {
                return false;
            }
        }

        /**
         *  指定月の最終日を取得
         */ 
        function getLastDay(year, month){
            var end_days = [31,28,31,30,31,30,31,31,30,31,30,31];
            
            // 閏年の確認
            if (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)) {
                end_days[1] = 29;
            }

            return Number(new Date(year, month, end_days[month], 23, 59, 59));
        }
        
        /**
         *  タイムスタンプから年月日文字列を取得
         */       
        function getTSDate(date){
            var dateArray = new Array();
            dateArray.push(zero_pad(date.getFullYear(),4));
            dateArray.push(zero_pad(date.getMonth() + 1,2));
            dateArray.push(zero_pad(date.getDate(),2));
            return dateArray;
        }
        
        /**
         *  ID名からDOMを取得
         */
        function getElmId(id){
            return document.getElementById(id);
        }
        
        /**
         *  Class名からDOMを取得
         */
        function getElmCls(contents){
            return document.getElementsByClassName(contents);
        }
        
        /**
         *  0詰処理
         */
        function zero_pad(val, show_length){
            var str_val = String(val);
            if( str_val.length < show_length ){
                var zero = ""; 
                for(var i = 0; i < ( show_length - str_val.length ); i++){
                    zero += "0";
                }
                str_val = zero + str_val;
            }
            return str_val;
        };
        
        // 「登録」ボタンにイベントリスナーを設定
        getElmId("regist_spending_btn").addEventListener('click', registSpending, false);
        
        // 「指定期間表示」ボタンにイベントリスナーを設定
        getElmId("display_btn").addEventListener('click', onClickDisplayDesignatedPeriodDataBtn, false);

    </script>
</body>
</html>

解説した以外にも「日付の処理」などさまざまな関数が作られていることがわかりますね。

実際の動作は下記リンクから確認することができます。

→「シンプル家計簿アプリ」※Google Chromeで動作確認済

HOMEへ