LoginSignup
2
3

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-03

01.png
02.png
03.png

あと何コマ?
コマ数で漫画の進捗管理するツールを作りました。
コマ数を入力するとコマが出てきて、
終わったコマをクリックすると塗りつぶされて完了状態になります。
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のこちらの実例をもとに作りました。ほぼ原型残ってないです。
何故わざわざテンプレートをもとに作るかと言うと、レスポンシブ対応が楽だからですね。
10.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
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
image.png
if (this.remainedPanelsInput === 0)
あと何コマ?の入力欄が0の場合、入力完了ボタンを押下コマ数を入力してください」とアラートが表示されます。
image.png
1以上の数字が入力されている場合は結果が表示されます。
このような表示の分岐にv-showv-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
image.png
こちらはコマ部分です。
白いコマはremainedPanelsNumber(残りコマ数)分、
ドットのコマはfilledPanels(完了したコマ数)分表示されます。
v-for="n in remainedPanelsNumber"と書けば
remainedPanelsNumberの数だけコマを複製してくれます。楽ちんです。
jsで作ろうとしたらコマの中にコマ数を表示するのも大変そうですが、
Vueなら{{ n }}と書くだけですみます。おお助かる助かる。
またpanelFinishedpanelReturnのクリックイベントで
パネルの完了状態を変更しています。

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
  }
},

ローカルストレージでremainedPanelsInputfilledPanels
perPanelの数を保存しています。
監視している変数の変更をトリガーにして勝手に働いてくれる
監視プロパティくんは便利やでホンマ。
途中保存がうまく行かなかったんですが、
mounted部分をnew Vue内で一番最後に配置したら
ちゃんと保存されるようになりました。どうして?(無知)

デザイン部分

Bootstrap5のとあるテーマを大いに参考にさせていただきました。
見た目もできるだけ可愛くしたかったんです。
image.png

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

border-radiusだけでdivの下部分を丸くできるものなんですね。
今回調べて初めて知りました。他にも色んな表現ができるみたいです。
歪んだ円まで作れるなんてすごい!
参考:今さら聞けない!? CSSのborder-radiusで様々な角丸に挑戦!

image.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でドット柄(水玉模様)を作成 - ホームページのパーツ作成で好きなドットを作ろう!
他にもradial-gradientでストライプやチェック柄まで作れるみたいです。実際スゴイ!
参考:CSSグラデーションで作った背景パターンのサンプル

感想

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

課題・問題点

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

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

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

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3