【Vue】漫画の進捗管理ツール作ってみた

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/9df255f5-95aa-9a02-6049-93b8a2f24f9f.png)
![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/d9bdd328-53cc-bd80-c691-e3eb301c34bd.png)
![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/813ff987-d7dc-58dd-2287-f7f6e355fcc3.png)


[あと何コマ？](https://mitaru.github.io/panels/)
**コマ数で漫画の進捗管理するツール**を作りました。
コマ数を入力するとコマが出てきて、
終わったコマをクリックすると塗りつぶされて完了状態になります。
1コマ辺りの作業時間を入力すると、残りの作業時間がわかります。
1コマの作業時間×残りコマ数で残りの作業時間が算出される訳ですね。

#何故作ろうと思ったのか
ページ単位で管理するツールは既にあるが、
**コマ単位で管理するものはなかった**ため。
漫画制作のモチベーションを維持するためにこういうツールがほしかった。
（コマ単位で管理しないとモチベが保たない）
Vue初心者が悶絶しながら作ったものですが、この記事が他の勉強中の方の参考になればと思います。

#ソース全文
```panels.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>あと何コマ？</title>
  <!-- Bootstrap -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
    integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <!-- fontawesome -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.9.0/css/all.min.css"
    integrity="sha256-UzFD2WYH2U1dQpKDjjZK72VtPeWP50NoJjd26rnAdUI=" crossorigin="anonymous" />
  <link rel="stylesheet" href="panels.css">
</head>
<body>
  <header class="d-flex flex-column flex-md-row align-items-center p-3 px-md-4">
    <nav class="mt-2 my-md-0 mr-md-3">
      <a class="px-2 text-white" href="#paneldiv">あと何コマ？</a>
      <a class="px-2 text-white" href="#timediv">あと何分？</a>
    </nav>
  </header>
  <div class="jumbotron jumbotron-fluid">
    <div class="container captionText">
      <p>コマ数で進捗管理するツールです。残りコマ数を入力して、入力完了ボタンを押してください。<br>
        1コマ辺りの作業時間を入力すると、残りの作業時間がわかります。
      </p>
    </div>
  </div>
  <div id="app" class="mb-5">
    <div class="container main py-4 mt-sm-3">
      <article class="text-center pt-3 pb-4" id="paneldiv">
        <div class="alertArea text-center mb-3">
          <strong v-show="alertShow">
            コマ数を入力してください
          </strong>
        </div>
        <h3 v-if="!resultShow">
          あと<input type="number" v-model.number="remainedPanelsInput" min="0" class="panelInput">コマ？
          <button type="button" class="ml-2 btn page-link text-light d-inline-block btn-purple" @click="resultShowFunc"
            v-if="!resultShow">入力完了</button>
        </h3>
        <h3 v-else>
          全<input type="number" v-model.number="remainedPanelsInput" min="0" class="panelInput">コマ
          <button type="button" class="ml-2 btn page-link text-light d-inline-block btn-purple" @click="resultReset"
            v-if="resultShow">リセット</button>
        </h3>
        <div v-show="resultShow">
          全{{ remainedPanelsInput }}コマ－
          済み<input type="number" v-model.number="filledPanels" v-bind:max="remainedPanelsInput" min="0">コマ
          ＝
          あと<span class="resultText">{{ remainedPanelsNumber }}</span>コマ
          <p class="text-muted pt-3">終わったコマをクリックすると、塗りつぶされて完了状態になります。完了状態のコマをクリックすると未完の状態に戻ります。</p>
        </div>
        <section class="row pricing-header px-3 py-3 pt-md-3 pb-md-1 mx-auto text-center" v-show="resultShow">
          <div class="panel" v-for="n in remainedPanelsNumber" @click="panelFinished">
            <div class="panelInner">{{ n }}</div>
          </div>
          <div class="panel filled" v-for="n in filledPanels" @click="panelReturn"></div>
        </section>
      </article>

      <article class="text-center" id="timediv" v-show="resultShow">
        <h3>あと何分？</h3>
        <div>
          1コマ辺りの作業時間<input class="inputPerPanel" type="number" v-model.number="
          perPanel" min="0">分×残り{{ remainedPanelsNumber }}コマ＝
          あと<span class="resultText">{{ remainingTime }}</span>分
          （{{ remainingHour }}時間）
        </div>
      </article>
    </div>
    <!-- ツイートボタン -->
    <div class="contact text-center">
      <a href="https://twitter.com/share" class="twitter-share-button" data-url="https://mitaru.github.io/panels/"
        data-text="進捗どうですか？あと何コマ？" data-size="large" data-hashtags="あと何コマ">
        Tweet
      </a>
    </div>
  </div>
  <footer class="my-1 pt-5 text-muted text-center text-small">
    <ul class="list-inline">
      <li class="list-inline-item">
        <a href="https://twitter.com/SakaiMitaru">
          <i class="fab fa-twitter-square mr-1"></i>SakaiMitaru
        </a>
      </li>
      <li class="list-inline-item"><a href="https://github.com/mitaru/panels.git">
          <i class="fab fa-github"></i>
        </a>
      </li>
    </ul>
  </footer>
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
    integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
    crossorigin="anonymous"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.bundle.min.js"
    integrity="sha384-1CmrxMRARb6aLqgBO7yyAxTOQE2AKb9GfXnEo760AUcUmFx3ibVJJAzGytlQcNXd"
    crossorigin="anonymous"></script>
  <!-- vue.js -->
  <script src="https://cdn.jsdelivr.net/npm/vue"></script>
  <script src="panels.js"></script>
</body>
</html>
```

```panels.css
html {
  font-size: 14px;
}

@media (min-width: 768px) {
  html {
    font-size: 16px;
  }
}

body {
  color: rgb(31, 45, 65);
  background: #F7F7FC;
}

header {
  background: #37384E;
  color: #fff;
}

.jumbotron {
  margin: 0;
  background: #37384E;
  color: #fff;
  border-radius: 0 0 40% 40%;
}

.captionText p {
  margin-bottom: 50px;
}

.text-muted {
  color: #9e9fb4 !important;
  font-size: 14px;
  margin: 0;
}

.main {
  max-width: 2000px;
  background: #F7F7FC;
}

.alertArea {
  height: 20px;
}

.alertArea strong {
  background: rgba(255, 255, 255, 0.6) !important;
  padding: 2px 5px;
  border-radius: 4px;
  color: #37384E;
}

.panelInput {
  min-width: 10vw;
}

.btn-purple {
  color: #fff;
  background: #16C995;
  border: 0;
}

.btn-purple:hover {
  background: #0f926d;
}

.pricing-header {
  max-width: 1000px;
}

section {
  justify-content: center;
  align-items: center;
}

input {
  width: 50px;
  border: 0;
  background: #fff;
  border-radius: 4px;
  padding: 3px;
  margin: 2px;
  color: #766DF4;
  font-weight: bold;
  text-align: center;
}

.resultText {
  background: rgba(118, 109, 244, 0.08) !important;
  color: #766df4 !important;
  font-size: 20px;
  padding: 0 10px;
  border-radius: 4px;
  font-weight: bold;
}

.panel {
  width: 150px;
  height: 100px;
  background: #fff;
  border: 5px solid #333;
  margin: 4px;
  text-align: center;
  line-height: 100px;
  cursor: pointer;
  user-select: none
}

.filled {
  background-color: #fff;
  background-image: radial-gradient(#16C995 14%, transparent 17%), radial-gradient(#16C995 14%, transparent 17%);
  background-position: 0 0, 4px 4px;
  background-size: 8px 8px;
}

footer {
  background: #F7F7FC;
  clear: both;
}

a .fa-github {
  font-size: 30px;
  color: #333;
}

a .fa-github:hover {
  opacity: 0.8;
}

@media screen and (max-width: 480px) {
  .panel {
    width: 100px;
    height: 70px;
    line-height: 70px;
  }
}
```

```panels.js
(function () {
  'use strict';

  new Vue({
    el: '#app',
    data: {
      remainedPanelsInput: 0,
      filledPanels: 0,
      perPanel: 0,
      resultShow: false,
      alertShow: false,
    },
    watch: {
      remainedPanelsInput: {
        handler: function () {
          localStorage.setItem('remainedPanelsInput', JSON.stringify(this.remainedPanelsInput));
        },
        deep: true
      },
      filledPanels: {
        handler: function () {
          localStorage.setItem('filledPanels', JSON.stringify(this.filledPanels));
        },
        deep: true
      },
      perPanel: {
        handler: function () {
          localStorage.setItem('perPanel', JSON.stringify(this.perPanel));
        },
        deep: true
      },
    },
    methods: {
      resultShowFunc: function () {
        if (this.remainedPanelsInput === 0) {
          this.alertShow = true;
        } else {
          this.resultShow = true;
          this.alertShow = false;
        }
      },
      panelFinished: function () {
        this.filledPanels++;
      },
      panelReturn: function () {
        this.filledPanels--;
      },
      resultReset: function () {
        this.remainedPanelsInput = 0;
        this.filledPanels = 0;
        this.perPanel = 0;
        this.resultShow = false;
      },
    },
    computed: {
      remainedPanelsNumber: function () {
        return this.remainedPanelsInput - this.filledPanels;
      },
      remainingTime: function () {
        return this.remainedPanelsNumber * this.perPanel;
      },
      remainingHour: function () {
        return Math.round((this.remainingTime / 60) * 10) / 10;
      },
    },
    mounted: function () {
      this.remainedPanelsInput = JSON.parse(localStorage.getItem('remainedPanelsInput')) || 0;
      this.filledPanels = JSON.parse(localStorage.getItem('filledPanels')) || 0;
      this.perPanel = JSON.parse(localStorage.getItem('perPanel')) || 0;
      if (this.remainedPanelsInput > 0) {
        this.resultShow = true
      }
    },
  })
  // twitter投稿
  !function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');
})();
```

Bootstrap4の[こちらの実例](https://bootstrap-guide.ape.jp/v4/sample/pricing)をもとに作りました。ほぼ原型残ってないです。
何故わざわざテンプレートをもとに作るかと言うと、レスポンシブ対応が楽だからですね。
![10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/9b9dacf7-cff7-7992-91c1-a9ae037e5115.png)
全力で先人に頼っていくスタイル。

#Vue部分
```panels.js
  new Vue({
    el: '#app',
    data: {
      remainedPanelsInput: 0,
      filledPanels: 0,
      perPanel: 0,
      resultShow: false,
      alertShow: false,
    },
// 中略
    computed: {
      remainedPanelsNumber: function () {
        return this.remainedPanelsInput - this.filledPanels;
      },
      remainingTime: function () {
        return this.remainedPanelsNumber * this.perPanel;
      },
      remainingHour: function () {
        return Math.round((this.remainingTime / 60) * 10) / 10;
      },
    },
```
![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/d9bdd328-53cc-bd80-c691-e3eb301c34bd.png)
remainedPanelsInput（全○コマの数）から filledPanels（完了したコマ数）を引いて
remainedPanelsNumber（残りコマ数）を出しています。
あと何分？の部分はperPanel（1コマ辺りの作業時間）と
remainedPanelsNumber（残りコマ数）を掛けて算出しています。
また分単位だけでなく時間単位の表記もあった方が親切だと思ったので
remainingHourで計算しました。
`Math.round((this.remainingTime / 60) * 10) / 10;`と書くことで、
小数点第二位で切り捨てて表示することができます。

```panels.html
<h3 v-if="!resultShow">
  あと<input type="number" v-model.number="remainedPanelsInput" min="0" class="panelInput">コマ？
  <button type="button" class="ml-2 btn page-link text-light d-inline-block btn-purple" @click="resultShowFunc"
    v-if="!resultShow">入力完了</button>
</h3>
<h3 v-else>
  全<input type="number" v-model.number="remainedPanelsInput" min="0" class="panelInput">コマ
  <button type="button" class="ml-2 btn page-link text-light d-inline-block btn-purple" @click="resultReset"
    v-if="resultShow">リセット</button>
</h3>
```
```panels.js
methods: {
  resultShowFunc: function () {
    if (this.remainedPanelsInput === 0) {
      this.alertShow = true;
    } else {
      this.resultShow = true;
      this.alertShow = false;
    }
  },
```

こちらはあと何コマ？部分のコードです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/83e40372-89dd-6ac3-7714-49f4bc8b3651.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/35aa295c-ad04-3965-6bdf-a9484d2a7ec9.png)
`if (this.remainedPanelsInput === 0)`で
あと何コマ？の入力欄が0の場合、入力完了ボタンを押下コマ数を入力してください」とアラートが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/65c2ef5e-0dc6-0fb3-715c-04b245fff92e.png)
1以上の数字が入力されている場合は結果が表示されます。
このような表示の分岐に`v-show`や`v-if`は大変便利です。

```panels.html
<div class="panel" v-for="n in remainedPanelsNumber" @click="panelFinished">
  <div class="panelInner">{{ n }}</div>
</div>
<div class="panel filled" v-for="n in filledPanels" @click="panelReturn"></div>
```

```panels.js
methods: {
  // 中略
  panelFinished: function () {
    this.filledPanels++;
  },
  panelReturn: function () {
    this.filledPanels--;
  },
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/34cb4930-40f6-d1b1-365e-e8c4f1301c26.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/b84ca9a2-4c38-0508-59b7-dc73a86fd1e9.png)
こちらはコマ部分です。
白いコマはremainedPanelsNumber（残りコマ数）分、
ドットのコマはfilledPanels（完了したコマ数）分表示されます。
`v-for="n in remainedPanelsNumber"`と書けば
**remainedPanelsNumberの数だけコマを複製してくれます。**楽ちんです。
jsで作ろうとしたらコマの中にコマ数を表示するのも大変そうですが、
Vueなら`{{ n }}`と書くだけですみます。おお助かる助かる。
また`panelFinished`と`panelReturn`のクリックイベントで
パネルの完了状態を変更しています。

```panels.js
watch: {
  remainedPanelsInput: {
    handler: function () {
      localStorage.setItem('remainedPanelsInput', JSON.stringify(this.remainedPanelsInput));
    },
    deep: true
  },
  filledPanels: {
    handler: function () {
      localStorage.setItem('filledPanels', JSON.stringify(this.filledPanels));
    },
    deep: true
  },
  perPanel: {
    handler: function () {
      localStorage.setItem('perPanel', JSON.stringify(this.perPanel));
    },
    deep: true
  },
},
// 中略
mounted: function () {
  this.remainedPanelsInput = JSON.parse(localStorage.getItem('remainedPanelsInput')) || 0;
  this.filledPanels = JSON.parse(localStorage.getItem('filledPanels')) || 0;
  this.perPanel = JSON.parse(localStorage.getItem('perPanel')) || 0;
  if (this.remainedPanelsInput > 0) {
    this.resultShow = true
  }
},
```
ローカルストレージで`remainedPanelsInput`、`filledPanels`、
`perPanel`の数を保存しています。
**監視している変数の変更をトリガーにして勝手に働いてくれる**
監視プロパティくんは便利やでホンマ。
途中保存がうまく行かなかったんですが、
`mounted`部分を`new Vue`内で一番最後に配置したら
ちゃんと保存されるようになりました。どうして？（無知）

#デザイン部分
Bootstrap5のとあるテーマを大いに参考にさせていただきました。
見た目もできるだけ可愛くしたかったんです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/d6e2f5c9-4f8d-3d37-ed9b-e03967916912.png)

```panels.css
.jumbotron {
  margin: 0;
  background: #37384E;
  color: #fff;
  border-radius: 0 0 40% 40%;
}
```
`border-radius`だけでdivの下部分を丸くできるものなんですね。
今回調べて初めて知りました。他にも色んな表現ができるみたいです。
歪んだ円まで作れるなんてすごい！
参考：[今さら聞けない!? CSSのborder-radiusで様々な角丸に挑戦！](https://www.webcreatorbox.com/tech/border-radius)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605100/b84ca9a2-4c38-0508-59b7-dc73a86fd1e9.png)

```panels.css
.filled {
  background-color: #fff;
  background-image: radial-gradient(#16C995 14%, transparent 17%), radial-gradient(#16C995 14%, transparent 17%);
  background-position: 0 0, 4px 4px;
  background-size: 8px 8px;
}
```
なんと`radial-gradient`を使えば、CSSだけで漫画のトーンみたいなドットの背景が作れます。
[CSSでドット柄（水玉模様）を作成 - ホームページのパーツ作成](https://webparts.cman.jp/box/dot/)で好きなドットを作ろう！
他にも`radial-gradient`でストライプやチェック柄まで作れるみたいです。実際スゴイ！
参考：[CSSグラデーションで作った背景パターンのサンプル](https://www.nxworld.net/tips/css-gradient-pattern.html)

#感想
 - 比較的思った通りに作れた
 - 見た目もいい感じになったと思う

#課題・問題点
 - ページ単位の管理機能も作りたかったがややこしすぎてヤコになった
 - 今までの知識の延長線上だなと思うのでもっとレベルアップしたい
 - BootstrapVue使おうとして挫折した

ここでも全文載せてますが、GitHubでもコードを公開しております。
アドバイスいただけたら嬉しいです。
https://github.com/mitaru/panels

次は何を作ろうかなー。ちょっとネタ切れしてきました。