Help us understand the problem. What is going on with this article?

1日でなんちゃって学習記録WEBアプリを作ってみた。(イントロ編)

勉強勉強.......

学生の大半は今日の生活を退屈だと感じているかもですし、
そうでないかもしれません。

かくいう私も初めの方は暇で退屈だと思っていましたが、
今では色んな意味で大忙しです。

そんなある日のこと。
一人の真面目な友人からLINEでこんな依頼を受けました。

この前、塾の先生に
「赤チャート1・2・3・A・B全部時間は測ってやって、
目標時間内にできてるかわかるようにして、
そのデータ頂戴。弱点分析に使うから。」
って言われたんだけど、
紙に書くのめんどいから
なんかアプリ作ってくれん?

何だこりゃって感じの依頼ですがまあ練習がてら作ることにしました。

APPの条件

今回次の条件で作ることになりました。
-自前でサーバー不要
-オンラインで使えればOK
-PC・スマホ両対応
-データがEXCELかそれに類するもので出力可能
-依頼者しか使わないけど、閲覧は外部でも可能

そこで、今回はGASとその周辺のものでWEBアプリを作ることにしました。

とりあえず完成品

SnapCrab_NoName_2020-5-10_21-29-0_No-00.png
SnapCrab_NoName_2020-5-10_21-29-17_No-00.png
SnapCrab_NoName_2020-5-10_21-29-32_No-00.png

スマホ環境ではこんな感じ
SnapCrab_NoName_2020-5-10_22-49-35_No-00.png
SnapCrab_NoName_2020-5-10_22-49-45_No-00.png

大まかな構成

Jquery 1.12.4 
popper.js 1.16.0
主にポップアップなどに利用
jquery.tablesorter/2.31.0
とその拡張のjquery.tablesorter.pager
テーブルのソートとフィルタ
bootstrap/4.3.1
よく出てくるすごく便利なやつ
ボタン装飾とか色々

そしてメインのGASはV8ランタイムは無効にしておきます。

とりあえず使ってみたい人向けセットアップマニュアル

1ソースコード類のDL
https://drive.google.com/open?id=1saVadZkRxdqiWjReFxmrhiwnxSHdpaaB
こちらのURLから2つとものコピーを作成して、
マイドライブに保管しておきます。
2フォルダの作成
このアプリでは問題の画像の保管が可能なので
保存のためのフォルダをGoogleドライブ上に作成し、
そのフォルダを開いたときのURLの一部をコピーします。
該当箇所(xxxxxxxxの部分)(以後folderid)
https://drive.google.com/drive/u/0/folders/xxxxxxxxxxxxxxx
3.設定(教材ファイルの作成)
1でコピーした2つの中の学習記録のコピーを開き、
はじめに、URLの一部をコピーします。
該当箇所(xxxxxxxxの部分)(以後sheetid)
https://docs.google.com/spreadsheets/d/xxxxxxxxxxxxxxxxxxxxxxx/edit#gid=0
”てんぷれーと”シートのコピーを作成します。
(この際チャート1などは消してもらって構いません)
コピーしたら
コピー先のシート名を登録したい教材名を入力します。
そして問題数(193のとこ)を問題数に合わせます。
また。目標時間の基本時間(ベースタイム)を設定します。
これはベースタイム難易度で目標時間が決定され、
時間内にクリアすると一覧で○
できなかった場合は☓が付きます。
また△のスコア判定は1に設定すると無効になり、
それ以上の値を設定することで、
ベースタイム
難易度*△のスコア判定より短い時間の場合
△がつくようになります。

この操作を繰り返すことで複数教材に対応できます。
4.設定(GAS側の設定)
ツールからスクリプトエディタを選択し、
公開の中のウェブアプリケーションとして導入を選択します。
Who has access to the appの項目はOnly myselfとしておきます。
更新を押したあとは、承認を押していきます。
”このアプリは確認されていません”
と出ますが、信頼される方は詳細から安全でないページに移動を押します。
(一応ある程度考慮して作っておりますが自己責任にてお願いします。)
すべて承認するとCurrent web app URL:としてURLが出るのでコピーします(以後BASEURLとします)。
そうするとエディタに戻ってくるので、編集から検索と置き換えを選び、
次のように置き換えてください。

検索 置換 意味
ベースゆーあーるえる BASEURLの末尾に?を加えたもの 自動生成のURLもとになる間違えたら大変
ふぉるだーあいでぃー folderid 画像保存先、ないと困る気がする

ここまでくればゴールまでもう少しです。

5.設定(目次の設定)
3で開かなかった学習記録Viewerのコピーを開き、
4と同様の手順で公開しますが、
Who has access to the appの部分のみ
Anyone, even anonymousを選択しましょう。
公開設定終了後、URLだけコピーしておきます。(以後VBURL)
また以下のように置き換えます。

検索 置換 意味
びゅわーゆーあーるえる VBURLの末尾に?を加えたもの 自動生成のURLもとになる間違えたら大変
べーすしーとあいでぃー sheetid メインシートとの連携に必須

最後に再度公開を行います。
そしたら3で開いたスプレッドシートに戻り、
indexシートを開きます。
シートの指示に従いA2行目から
ABCの順に、
シート名、教科、問題数、URL(*1)、カウンター(各シートのA2セルを参照するように指定)、ViewerURL(*2)
の順に入力します。
*1*2の形式は
*1はBASEURLの末尾に
*2はVBURLの末尾にそれぞれ

?p=シート名&mode=view

とつけたものになります。

これで完成です。
BASEURLにアクセスすると目次が表示されるはずです。
始めるを押せば、シートの情報をもとにしたテーブルが表示されます。
お疲れさまでした。

各ページのコード

index.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
      <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
  </head>
  <body>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
    <?!=selector()?>
  </body>
</html>

コメント:一番シンプルなページ。
もっとも読み込みが早い

Viewer.html

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/jquery.tablesorter.combined.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/extras/jquery.tablesorter.pager.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<style src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/filter.formatter.min.css"/>
</head>

<body>
<style>
#table1 th {
height:50px;
}
tr{
height:30px;
}
/* rows hidden by filtering (needed for child rows) */
.tablesorter .filtered {
    display: none;
}

/* ajax error row */
.tablesorter .tablesorter-errorRow td {
    text-align: center;
    cursor: pointer;
    background-color: #e6bf99;
}
.modal-dialog-fluid {
  max-width: inherit;
  width: 98%;
  margin-left: 15px;
}
</style>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){ 
$("#table_filter")
.tablesorter({
headers: {0: { sorter: false,filter:false}},
widgets: ["filter"],
})
$("#table1")
.tablesorter({
headers: {0: { sorter: false}},
})
.tablesorterPager({ container: $(".pager")});
});
</script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
})
</script>


<br>
<div class="container-fluid">
<div class="row">
<div class="col-sm col-lg-2 col-xl-3 mx-auto collapse" id="sidebar_base">
 <a href="ベースゆーあーるえるp=index" class="btn btn-outline-success mt-1">目次</a>
   <button data-toggle="collapse" class="btn btn-outline-primary mt-1" data-target="#sidebar_base">サイドバー表示切り替え</button>
   <br>
<div class="card" id="controller">
問題ID(ここの値を変えるとその問題に移動できます)<br><input type="text" id="sel_id" value="" onchange="move();">
<script>
function move(){
var input_id=document.getElementById("sel_id").value;
document.getElementById("sel_id").value=input_id;
alert("値が"+input_id+"に変更されました。\n 問題を変更するには移動ボタンを押してください。");
var url='ベースゆーあーるえるp='+<?=page?>+'&mode=rec&id='+input_id;
document.getElementById("move_id").href=url;
}
</script>
<a id="move_id"class="btn btn-outline-primary mt-1">この問題に移動</a>
</div>

<div class="card" id="discription">
<h1>discription</h1>
タイトルをクリックしてソートできます。
<br>
難易度の上にカーソルを置くと問題の画像が表示されます。
<br>
判定について
<br>
○☓は目標タイムを達成できたかどうかを示しています。<br>
また赤で表示されているのは間違えた問題です。<br>
青は正解した問題です。<br>
クリアタイムなどは○☓をタップORカーソルを乗せることで表示できます。
</div><br>
<div class="card" id="sindex">
<?!=selector()?>
</div>
<br>
<a href='ベースゆーあーるえるp=<?=page?>&mode=filter_view' class="mx-auto btn btn-outline-primary mt-1" >フィルターモードで問題一覧を開く</a>
</div>


<div class="col">
<h1>問題選択画面</h1>
<div id="controller">
<button data-toggle="collapse" class="btn btn-outline-primary mt-1" data-target="#sidebar_base">サイドバー表示切り替え</button>
<a href="ベースゆーあーるえるp=index" class="btn btn-outline-success mt-1">目次に戻る</a>
<br>
<div class="panel panel-default">
  <div class="pager">
    <span class="pagedisplay" value=""><input type="text" class="pagedisplay"></span>
    <br>
    <button type='button' class='first'>&lt;&lt;</button>
    <button type='button' class='prev'>&lt;</button>
    <button type='button' class='next'>&gt;</button>
    <button type='button' class='last'>&gt;&gt;</button>
    <select class="pagesize">
    <option value="10">10</option>
      <option value="50">50</option>
      <option value="100">100</option>
      <option value="200">200</option>
    </select>
  </div>
 </div>
  <br>

</div>
<div class="d-block  d-xl-none">
<?!=recviewer(page)?>
</div>
<div class="d-none d-xl-block">
<?!=recviewer(page,"on")?>
</div>
</div>
</div>
</div>
</body>
</html>

コメント:一番苦戦した(主にTablesorter関連)
激重な気がするので、最適化したほうがいい気もする。

record.html

<!DOCTYPE html>
<html>

<head>
  <base target="_top">
  <link href="https://fonts.googleapis.com/css2?family=Sacramento&display=swap" rel="stylesheet">
  <!---timer fonts--->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/jquery.tablesorter.combined.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/extras/jquery.tablesorter.pager.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<style src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/filter.formatter.min.css"/>
  <script>
    //upload用スクリプト
    // Prevent forms from submitting.
    function preventFormSubmit() {
      var forms = document.querySelectorAll('form');
      for (var i = 0; i < forms.length; i++) {
        forms[i].addEventListener('submit', function (event) {
          event.preventDefault();
        });
      }
    }
    window.addEventListener('load', preventFormSubmit);

    function handleFormSubmit(formObject) {
      google.script.run.withSuccessHandler(updateUrl).processForm(formObject);
    }
    function updateUrl(url) {
      var div = document.getElementById('output');
      div.innerHTML = '<a href="' + url + '">この画像を送信しました。</a>';
    }
  </script>

</head>


<body>
<style>
#table1 th {
height:50px;
}
tr{
height:30px;
}
/* rows hidden by filtering (needed for child rows) */
.tablesorter .filtered {
    display: none;
}

/* ajax error row */
.tablesorter .tablesorter-errorRow td {
    text-align: center;
    cursor: pointer;
    background-color: #e6bf99;
}
</style>
<script>
$(document).ready(function(){ 
$("#table1")
.tablesorter({
headers: {0: { sorter: false,filter:false}},
widgets: ["filter"],
})
.tablesorterPager({ container: $(".pager")});
});
</script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
})
</script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
    integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
    crossorigin="anonymous"></script>

  <div class="container-fluid" id="base_frame">
    <div class="row">

      <div class="col">
        <!-- mainframe--->
        <h1>記録画面</h1>
        <div class="mx-auto card"  id="timer_card">
          <div class="card text-center" ><span id="timer"  style="font-size:75px; font-family: 'Sacramento', cursive;">00:00.000</span></div>
          <br>
          <div class="d-flex" id="timer_button">
            <button data-toggle="collapse" data-target=".timercon" id="start" class="flex-fill btn btn-outline-primary mt-1 timercon show">start</button>
            <button data-toggle="collapse" data-target=".timercon" id="stop" class="flex-fill btn btn-outline-danger mt-1 timercon collapse">stop</button>
            <button  id="reset" class="flex-fill btn btn-outline-warning mt-1 timercon show">reset</button>
            <button id="reset"  type="button" onclick="record(1)"
              class="flex-fill btn btn-outline-success mt-1 timercon show">この結果を正解として記録</button>
            <button id="reset"  type="button" onclick="record(2)"
              class="flex-fill btn btn-outline-danger mt-1 timercon show">この結果を不正解として記録</button>
          </div>
          <script>
            function record(ans) {
              var rec_time = document.getElementById("timer").textContent;
              var datas = [[document.getElementById("type").value, document.getElementById("diff").value, document.getElementById("memo").value]];
              var id = document.getElementById("now_id").value;
              google.script.run.rec(<?= page ?>, rec_time, id, datas, ans);
              alert("送信完了");
            }
          </script>
        <script>
            (function () {
                'use strict';
                var timer = document.getElementById('timer');
                var start = document.getElementById('start');
                var stop = document.getElementById('stop');
                var reset = document.getElementById('reset');
                sessionStorage.setItem('savetime', '0');
                sessionStorage.setItem('lock', 'yes');
                var startTime;
                var elapsedTime = 0;
                var timerId;
                var timeToadd = 0;
                function updateTimetText() {
                    var m = Math.floor(elapsedTime / 60000);
                    var s = Math.floor(elapsedTime % 60000 / 1000);
                    var ms = elapsedTime % 1000;
                    m = ('0' + m).slice(-2);
                    s = ('0' + s).slice(-2);
                    ms = ('0' + ms).slice(-3);


                    timer.textContent = m + ':' + s + '.' + ms;
                }
                function countUp() {
                    timerId = setTimeout(function () {
                        elapsedTime = Date.now() - startTime + timeToadd;
                        updateTimetText()
                        countUp();
                    }, 10);
                }
                start.addEventListener('click', function () {
                if(sessionStorage.lock==="yes"){
                    startTime = Date.now();
                    timeToadd=Number(sessionStorage.savetime);
                    console.log(startTime,timeToadd);
                    sessionStorage.setItem('lock', 'no');
                    countUp();
                    }
                });
                stop.addEventListener('click', function () {
                if(sessionStorage.lock==="yes"){
                }else{
                    clearTimeout(timerId);
                    timeToadd += Date.now() - startTime;
                    sessionStorage.setItem('savetime', timeToadd);
                    sessionStorage.setItem('lock', 'yes');
                    }
                });
                reset.addEventListener('click', function () {
                clearTimeout(timerId);
                    elapsedTime = 0;
                    timeToadd = 0;
                    sessionStorage.setItem('savetime', '0');
                    updateTimetText();
                    sessionStorage.setItem('lock', 'yes');
                });
            })();
        </script>
        </div>
        <br>
        <div class="mx-auto card" name="page_con">
          <?!=cre_con(page,id)?>
        </div>

        <div class="row" name="setting">
          <div class="d-flex" id="db">
            <div class="flex-fill" id="gas">
              <h3>問題情報・変更</h3>
              <?!=rec_st(page,id)?>
              <button type="button" onclick="info_save();" class="btn btn-outline-primary mt-1">保存</button>
              <script>
                function info_save() {
                  var datas = [[document.getElementById("type").value, document.getElementById("diff").value, document.getElementById("memo").value]];
                  var id = document.getElementById("now_id").value;
                  google.script.run.info_saving(<?= page ?>, id, datas);
                  alert("多分記録に成功しました。");
                }
              </script>
              <div id="upload">
                <h3>問題の写真の保存</h3><br>
                <form id="myForm" onsubmit="handleFormSubmit(this)">
                  <input name="myFile" type="file" accept="image/jpeg" />
                  <?!='<input name="page" type="text" style="display:none;" value="'+page+'"/>'?>
                  <?!='<input name="id" type="text" style="display:none;" value="'+id+'"/>'?>
                  <button class="btn btn-outline-primary mt-1" type="submit">保存</button>
                </form>
              </div>
            </div>
          </div>
        </div>
      </div>
          <div class="mx-auto col-sm col-lg-3 col-xl-4 collapse" id="sidebar">
        <h2 clas="mx-auto">Side Bar</h2>
        <button data-toggle="collapse" class="flex-fill mx-auto btn btn-outline-primary mt-1"
          data-target="#sidebar">サイドバー表示切り替え</button>
        <div class="mx-auto card" name="control">
          問題ID
          <br>(ここの値を変えるとその問題に移動できます)
          <br>(記録してから移動をしてください)
          <input type="text" id="sel_id" value="<?!=id?>" onchange="move();">
          <script>
            function move() {
              var input_id = document.getElementById("sel_id").value;
              document.getElementById("sel_id").value = input_id;
              alert("値が" + input_id + "に変更されました。\n 問題を変更するには移動ボタンを押してください。");
              var url = 'ベースゆーあーるえるp=' +<?= page ?> +'&mode=rec&id=' + input_id;
              document.getElementById("move_id").href = url;
            }
          </script>
          <a id="move_id" class="btn btn-outline-success mt-1">この問題に移動</a>
          <br>
        </div>
        <button data-toggle="collapse" class="mx-auto btn btn-outline-primary mt-1" data-target="#question_img">この問題の画像を表示する。</button>
          <br>
          <div id="question_img"class="collapse flex-fill">
          <h3>問題画像</h3>
          <img src="<?!=image_url(page,id)?>" width="300">
          </div>
          <br>
        <button data-toggle="modal" class="mx-auto btn btn-outline-primary mt-1" data-target="#modal1">問題一覧を開く</button>
        <button data-toggle="modal" class="mx-auto btn btn-outline-primary mt-1" data-target="#modal_cmd">時間調整ユーティリティを開く</button>
        <!---motal content list question--->
        <div class="modal fade" id="modal1" tabindex="-1"
        role="dialog" aria-labelledby="label1" aria-hidden="true">
        <div class="modal-dialog modal-dialog-scrollable modal-dialog-centered modal-xl" role="document">
        <div class="modal-content">
        <div class="modal-header">
        <h5 class="modal-title" id="label1">問題一覧(一部機能が制限されます。)</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
        <span aria-hidden="true">&times;</span>
        </button>
        </div>
        <div class="modal-body">
        <?!=recviewer(page)?>
        </div>
        <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
        </div>
        </div>
        </div>
        <!---end list--->
        <!---motal content list question--->
        <div class="modal fade" id="modal_cmd" tabindex="-1"
        role="dialog" aria-labelledby="label1" aria-hidden="true">
        <div class="modal-dialog modal-dialog-scrollable modal-dialog-centered modal-xl" role="document">
        <div class="modal-content">
        <div class="modal-header">
        <h5 class="modal-title" id="label1">時間調整用ユーティリティ</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
        <span aria-hidden="true">&times;</span>
        </button>
        </div>
        <div class="modal-body">
        <h2>経過時間を変更します。</h2>
        <div class="card">
        <p>分:何桁でも可、秒:0~59まで、ミリ秒:0~999まで</p>
        <input id="min_set"type="text"><input id="sec_set"type="text"><input id="msec_set"type="text">ミリ秒
        <br><button onclick="set_rectime()" class="btn btn-outline-warning mt-1">実行</button>
        <br> <button  id="reset" class="flex-fill btn btn-outline-warning mt-1 timercon show">タイマー強制reset</button>
        <script>
        function set_rectime(){
        var min=Number(document.getElementById("min_set").value)*60000;
        var sec=Number(document.getElementById("sec_set").value)*1000;
        var msec=Number(document.getElementById("msec_set").value);
        var time=min+sec+msec;
        sessionStorage.setItem('savetime', time);
        alert("おそらく設定に成功しました。\nスタートでその時間から始まります。")
        }
        </script>
        </div>
        </div>
        <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        </div>
        </div>
        </div>
        </div>
        <!---end list--->

      </div>
    </div>

  </div>
</body>

</html>

コメント:タイマー重複起動防止策が思い浮かばなかったのでSessionStorageを利用した。
タイマーのあらぶりを抑えられなかった。

edit.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
      <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
 <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  </head>
  <body>
   <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
   <h1>編集画面</h1>
<div id="database">
    <?!=edit_show(page,id)?>

    <script>
    function remove(j){
      var id="#gas"
      $(id).remove();
       google.script.run.withSuccessHandler(reload).remove_rec(<?=page?>,<?=id?>,j);
       }
   function reload(data){
   const element=document.getElementById('database');

   element.insertAdjacentHTML('afterbegin',data);
   }
    </script>
     <script>
function info_save(){
var datas=[[document.getElementById("type").value,document.getElementById("diff").value,document.getElementById("memo").value]];
var id= <?=id?>;
google.script.run.info_saving(<?=page?>,id,datas);
alert("多分記録に成功しました。");
}

    </script>
    </div>
  <h2>記録は一度に1つしか消せません</h2>  
  <?!=cre_ed_con(page,id)?>
  </body>
</html>


コメント:デザインが地味・UIが雑

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/jquery.tablesorter.combined.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.0/js/extras/jquery.tablesorter.pager.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<style src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/filter.formatter.min.css"/>
</head>

<body>
<style>
#table1 th {
height:50px;
}
tr{
height:30px;
}
/* rows hidden by filtering (needed for child rows) */
.tablesorter .filtered {
    display: none;
}

/* ajax error row */
.tablesorter .tablesorter-errorRow td {
    text-align: center;
    cursor: pointer;
    background-color: #e6bf99;
}
.modal-dialog-fluid {
  max-width: inherit;
  width: 98%;
  margin-left: 15px;
}
</style>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){ 
$("#table_filter")
.tablesorter({
headers: {0: { sorter: false,filter:false}},
widgets: ["filter"],
})
$("#table1")
.tablesorter({
headers: {0: { sorter: false}},
})
.tablesorterPager({ container: $(".pager")});
});
</script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
})
</script>


<br>
<div class="container-fluid">
<div class="row">
<div class="col-sm col-lg-2 col-xl-3 mx-auto collapse" id="sidebar_base">
 <a href="ベースゆーあーるえるp=index" class="btn btn-outline-success mt-1">目次</a>
   <button data-toggle="collapse" class="btn btn-outline-primary mt-1" data-target="#sidebar_base">サイドバー表示切り替え</button>
   <br>
<div class="card" id="controller">
問題ID(ここの値を変えるとその問題に移動できます)<br><input type="text" id="sel_id" value="" onchange="move();">
<script>
function move(){
var input_id=document.getElementById("sel_id").value;
document.getElementById("sel_id").value=input_id;
alert("値が"+input_id+"に変更されました。\n 問題を変更するには移動ボタンを押してください。");
var url='ベースゆーあーるえるp='+<?=page?>+'&mode=rec&id='+input_id;
document.getElementById("move_id").href=url;
}
</script>
<a id="move_id"class="btn btn-outline-primary mt-1">この問題に移動</a>
</div>

<div class="card" id="discription">
<h1>discription</h1>
タイトルをクリックしてソートできます。
<br>
難易度の上にカーソルを置くと問題の画像が表示されます。
<br>
判定について
<br>
○☓は目標タイムを達成できたかどうかを示しています。<br>
また赤で表示されているのは間違えた問題です。<br>
青は正解した問題です。<br>
クリアタイムなどは○☓をタップORカーソルを乗せることで表示できます。
</div><br>
<div class="card" id="sindex">
<?!=selector()?>
</div>
<br>
<a href='ベースゆーあーるえるp=<?=page?>&mode=view' class="mx-auto btn btn-outline-primary mt-1" >通常モードで問題一覧を開く</a>
</div>


<div class="col">
<h1>問題選択画面</h1>
<div id="controller">
<button data-toggle="collapse" class="btn btn-outline-primary mt-1" data-target="#sidebar_base">サイドバー表示切り替え</button>
<a href="ベースゆーあーるえるp=index" class="btn btn-outline-success mt-1">目次に戻る</a>
<br>
<div class="panel panel-default">
  <div class="pager">
    <span class="pagedisplay" value=""><input type="text" class="pagedisplay"></span>
    <br>
    <button type='button' class='first'>&lt;&lt;</button>
    <button type='button' class='prev'>&lt;</button>
    <button type='button' class='next'>&gt;</button>
    <button type='button' class='last'>&gt;&gt;</button>
    <select class="pagesize">
    <option value="10">10</option>
      <option value="50">50</option>
      <option value="100">100</option>
      <option value="200">200</option>
    </select>
  </div>
 </div>
  <br>

</div>
<?!=recviewer(page,"on")?>

</div>
</div>
</div>
</body>
</html>

コメント:ほとんどViewerと同じ
常時フィルターが使えるが、スマホだとスクロールしないとテーブル全体は見れない。

サーバーエンドのコード.gs

var set=SpreadsheetApp.getActiveSpreadsheet().getSheetByName('setting');
var base="ベースゆーあーるえる";
function selector(){
  var insh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName('index');
  var indexrange = insh.getRange(1,1,set.getRange("A1").getValue(),5).getValues();
  var index_table="<tr><th width=100>"+indexrange[0][0]+"</th><th width=30>"+indexrange[0][1]+"</th><th width=30>"+indexrange[0][2]+'</th><th width=30>選択画面へ</a></th><th width=30>'+indexrange[0][4]+'</th></tr>';
  for (var i =1;i<indexrange.length;i++){
    index_table=index_table+"<tr><td>"+indexrange[i][0]+"</td><td>"+indexrange[i][1]+"</td><td>"+indexrange[i][2]+'</td><td><a href="'+indexrange[i][3]+'" class="btn btn-outline-primary mt-1">始める</a></td><td>'+indexrange[i][4]+'</td></tr>';
  }
  var result="<div><h1>目次</h1><table class='table table-striped' border=1 >"+index_table+"</table></div>";
  return result;
}

function recviewer(sheet,filter){
    if(!filter){
    filter = "off";
  }
var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
  var rating=Number(data.getRange("B3").getValue())
  var count=data.getRange("B2").getValue();
  var max_try=data.getRange("G2").getValue();
  var rec_data=data.getRange(6, 1, count+1, 5+max_try).getValues();
  var memo=data.getRange(6, 3, count+1,1).getNotes()
  var record_tb="<tr><th width=10>この問題からスタートする</th><th width=10>"+rec_data[0][0]+"</th><th width=30 class='filter-select filter-onlyAvail'>"+rec_data[0][1]+"</th><th width=10 class='filter-select filter-onlyAvail'>"+rec_data[0][2]+"</th><th width=30>"+rec_data[0][3]+"</th><th width=10 class='filter-select filter-onlyAvail'>"+rec_data[0][4]+"</th>";
      if (max_try>0){
        for (var j =5;j<5+max_try;j++){
      record_tb=record_tb+"<th width=30 class='filter-select filter-onlyAvail'>"+rec_data[0][j]+"</th>";
      }
      }
  record_tb=record_tb+"</tr></thead><tbody>";
  for (var i =1;i<rec_data.length;i++){
    if (memo===null){
    var image_code="画像なし";
    }else{
    var image_code="<a href='"+memo[i]+"'><img src='"+memo[i]+"'height='400'></a>";
    }
    record_tb=record_tb+"<tr><td>"+'<a href="'+base+'p='+sheet+'&mode=rec&id='+rec_data[i][0]+'"class="btn btn-outline-primary mt-1">始める</a><br><a href="'+base+'p='+sheet+'&mode=edit&id='+rec_data[i][0]+'"class="btn btn-outline-danger mt-1">edit</a></td>'+"<td>"+rec_data[i][0]+" </td><td>"+rec_data[i][1]+'</td><td data-toggle="tooltip"data-html="true"data-delay="1200"title="'+image_code+'">'+rec_data[i][2]+"</td><td>"+rec_data[i][3]+"</td><td>"+rec_data[i][4]+"</td>";
    if (max_try>0){
    for (var j =5;j<5+max_try;j++){
      var d_form=exchange(rec_data[i][j],",");
      if (d_form[0] === ""){
        record_tb=record_tb+'<td data-toggle="tooltip"data-html="true"  title="データなし">-</td>';
      }else{

      var time=d_form[0];
      var time_check=d_form[1];
      var score=d_form[2];
     if (score==="不正解"){
        var scolor="text-danger"
        }else{
        var scolor="text-info"
        }
        var goal=Number(rec_data[i][2])*rating;
        record_tb=record_tb+'<td class='+scolor+' data-toggle="tooltip"data-html="true"title="時間|'+time+"<br>正誤|"+score+'<br>目標タイム|'+goal+'分">'+time_check+"</td>";
      }    
      }
  }
  record_tb=record_tb+"</tr>";    
}
  if(filter==="on"){
  var result='<div class="table-responsive"><h1>'+sheet+'</h1><table id="table_filter" class="table text-nowrap table-striped table-hover table-dark" border=1 ><thead>'+record_tb+"</tbody></table></div>";
  }else{
  var result='<div class="table-responsive"><h1>'+sheet+'</h1><table id="table1" class="table table-striped table-hover table-dark" border=1 ><thead>'+record_tb+"</tbody></table></div>";

  }
    return result;
}

function rec_st(sheet,id) {
  var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
  var count=data.getRange("B2").getValue();
  var db=data.getRange(6, 1, count+1, 5).getValues();
  var current=db[id];
  var id_data="問題id<input type='text' id='now_id' value='"+current[0]+"'>";
  var type="問題タイプ<input type='text' id='type' value='"+current[1]+"'>";
  var difficult="難易度<input type='text' id='diff' value='"+current[2]+"'>";
  var memo="メモ<input type='text' id='memo' value='"+current[3]+"'>";
  var tried="挑戦回数<input type='text' id='tried' value='"+current[4]+"'>";
  var result="<ul>"+id_data+"</ul><ul>"+type+"</ul><ul>"+difficult+"</ul><ul>"+memo+"</ul><ul>"+tried+"</ul>";
  return result;

}

function edit_show(sheet,id) {

var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
  var count=data.getRange("B2").getValue();
  var max_try=data.getRange("G2").getValue();
  var db=data.getRange(6, 1, count+1, 5+max_try).getValues();
  var current=db[id];
  var id_data="問題id:"+current[0];
  var type="問題タイプ<input type='text' id='type' value='"+current[1]+"'>";
  var difficult="難易度<input type='text' id='diff' value='"+current[2]+"'>";
  var memo="メモ<input type='text' id='memo' value='"+current[3]+"'>";
  var tried="挑戦回数:"+current[4];
var records=edit_record(sheet,id);
    var result="<div id='gas'><ul>"+id_data+"</ul><ul>"+type+"</ul><ul>"+difficult+"</ul><ul>"+memo+"</ul><ul>"+tried+"</ul><button type='button' onclick='info_save();'class='btn btn-outline-primary mt-1'>保存</button><h1>記録</h1>"+records+"</div>";
  return result;

}

function edit_record(sheet,id){
  var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
 var max_try=data.getRange("G2").getValue();
  var count=data.getRange("B2").getValue();
  var db=data.getRange(6, 1, count+1, 5+max_try).getValues();
  var current=db[id];
  var records=""
  if (current[4]>0){
  for (var j =5;j<5+current[4];j++){
      var d_form=exchange(current[j],",");
      var time=d_form[0];
      var time_check=d_form[1];
      var score=d_form[2];
    var func="remove("+j+");";
    records=records+'<ul>time:'+d_form[0]+'|rating:'+d_form[1]+'|score:'+d_form[2]+'<button type="button" id="r" class="btn btn-outline-danger mt-1" onclick='+func+'>削除</button></ul>';
      }
  }
return records 
}

function remove_rec(sheet,id,del_pos){
var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
  data.getRange(Number(id)+6,Number(del_pos)+1).deleteCells(SpreadsheetApp.Dimension.COLUMNS);
  return edit_show(sheet,id)
}


function exchange(time,type){
  // 分割する数値
  var beforeTime = time;
  // 数値を文字列に変換して、一文字ずつ分割
  var beforeTimeArr = String(beforeTime).split(type);
  return beforeTimeArr
}

function rec(sheet,time,id,datas,ans){

var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheet);
  var rectime=time.replace(".",":")
 var max_try=data.getRange("G2").getValue();
  var count=data.getRange("B2").getValue();
  var db=data.getRange(6, 1, count+1, 5+max_try).getValues();
  var current=db[id];
 data.getRange(6+Number(id),2,1,3).setValues(datas);
 var db_sel=data.getRange(6+Number(id),6+Number(current[4]));
  var diff=current[2];
  var rating=Number(data.getRange("B3").getValue());
  var beforeTimeArr=exchange(rectime,":");
  var beforeper=exchange(current[1],",");
  var mstime=Number(beforeTimeArr[0])*60000+Number(beforeTimeArr[1])*1000+Number(beforeTimeArr[2]);
  var per=Number(data.getRange("D3").getValue());
  if (mstime<Number(rating*diff*60000)){
    var result=time+",○";
  }else if(mstime<Number(rating*diff*60000*per)){
    var result=time+",△";
  }else{
    var result=time+",☓";
       }
  if (ans===2){
    result=result+",不正解";
  }else{
   result=result+",正解";
  }
   db_sel.setValue(result);
  }

function cre_con(page,id){
  var bid=Number(id)-1;
  var nid=Number(id)+1;
  var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(page);
  var count=data.getRange("B2").getValue();
var bp=base+"p="+page+"&mode=rec&id="+bid;
var np=base+"p="+page+"&mode=rec&id="+nid;
  var lp=base+"p="+page+"&mode=view";
if (bid<=0){
bp=base+"p="+page+"&mode=rec&id="+id;
}
  if (nid>count){
np=base+"p="+page+"&mode=rec&id="+id;
}
var result='<div class="d-flex" name="con_cody"><a href="'+bp+'"class="flex-fill btn btn-outline-primary mt-1">前</a><a href="'+lp+'"class="flex-fill btn btn-outline-warning mt-1">一覧に戻る</a><a href="'+np+'"class="flex-fill btn btn-outline-info mt-1">次</a><button data-toggle="collapse" class="flex-fill btn btn-outline-primary mt-1" data-target="#sidebar">サイドバー表示切り替え</button></div>';
return result
}

function cre_ed_con(page,id){
  var bid=Number(id)-1;
  var nid=Number(id)+1;
  var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(page);
  var count=data.getRange("B2").getValue();
var bp=base+"p="+page+"&mode=edit&id="+bid;
var np=base+"p="+page+"&mode=edit&id="+nid;
  var lp=base+"p="+page+"&mode=view";
if (bid<=0){
bp=base+"p="+page+"&mode=edit&id="+id;
}
  if (nid>count){
np=base+"p="+page+"&mode=edit&id="+id;
}
var result='<a href="'+bp+'"class="btn btn-outline-primary mt-1">前</a><a href="'+lp+'"class="btn btn-outline-warning mt-1">一覧に戻る</a><a href="'+np+'"class="btn btn-outline-info mt-1">次</a>';
return result
}

function info_saving(page,id,datas){
var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(page);
data.getRange(6+Number(id),2,1,3).setValues(datas);  
}

function doGet(e) {
  var page=e.parameter["p"];
  if (page==="index"||!page){
    var htmlOutput = HtmlService.createTemplateFromFile("index");
    htmlOutput.page=page;
    htmlOutput=htmlOutput.evaluate()
    htmlOutput
    .setTitle('学習手帳')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    return htmlOutput;
  }
  var mode=e.parameter["mode"];
  switch(true){
    case mode==="view": 
      var htmlOutput = HtmlService.createTemplateFromFile("Viewer");
      htmlOutput.page=page;
      htmlOutput.mode=mode;
      htmlOutput=htmlOutput.evaluate();
      htmlOutput
      .setTitle('学習手帳')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      return htmlOutput;
      break
    case mode==="rec":
      var htmlOutput = HtmlService.createTemplateFromFile("record");
      htmlOutput.page=page;
      htmlOutput.mode=mode;
      htmlOutput.id=e.parameter["id"];
      htmlOutput=htmlOutput.evaluate();
      htmlOutput
      .setTitle('学習手帳')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      return htmlOutput;
      break  
     case mode==="edit":
      var htmlOutput = HtmlService.createTemplateFromFile("edit");
      htmlOutput.page=page;
      htmlOutput.mode=mode;
      htmlOutput.id=e.parameter["id"];
      htmlOutput=htmlOutput.evaluate();
      htmlOutput
      .setTitle('学習手帳')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      return htmlOutput;
      break
      case mode==="filter_view": 
      var htmlOutput = HtmlService.createTemplateFromFile("Viewer_filter");
      htmlOutput.page=page;
      htmlOutput.mode=mode;
      htmlOutput=htmlOutput.evaluate();
      htmlOutput
      .setTitle('学習手帳')
      .addMetaTag('viewport', 'width=device-width, initial-scale=1')
      return htmlOutput;
      break
  }
}
//ファイルアップロードは画像ファイルなどはV8ランタイムで実行できません。無効にすると実行できます。
function processForm(formObject) {
    if(typeof(formObject.myFile) != "undefined" && formObject.myFile.getBytes().length > 0){
    var page=formObject.page;
      var id=formObject.id;
    var filename=page+"_"+id+".png";
    var formBlob = formObject.myFile.getAs("image/png").setName(filename);
    var folder = DriveApp.getFolderById('ふぉるだーあいでぃー');
    var driveFile = folder.createFile(formBlob);
    driveFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
    var drive_id=driveFile.getId();
    var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(page);
    data.getRange(6+Number(id),3).setNote("https://drive.google.com/uc?id="+drive_id);
  }else{
    var formBlob = null;
    var image = {};
  }
  return driveFile.getUrl();
}

function image_url(page,id){
 var data=SpreadsheetApp.getActiveSpreadsheet().getSheetByName(page);
  var url=data.getRange(6+Number(id),3).getNote();
  return url
}

function tester(){
recviewer("チャート1")
}

コメント:メイン基板
表生成から、コントローラーの生成、画像の保存までこなす働き者
処理速度のために大半のセル取得が、配列である。

終わりに、、というか次回に続く

今回はイントロとなってしまいましたが次回、コードで特に悩んだとこなど書いていきます。

haraday
気ままに欲しい物を作る。 ほぼ趣味でプログラミングしている人です。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした