ツンデレ本管理プロジェクト ツンデレ本を表示するの話

ツンデレ本の現在の機能を書くと

  • ツンデレ本を表示する
  • ツンデレ本を管理する

このうちのツンデレ本を表示する部分です。

がっつりその中の機能は

  • 待機中(積んでる)の全書籍を種別ごとに表示する
  • 現在読書中の書籍をシーンごとに表示する
  • 現在読書中の書籍をAPIにてサービス提供する(Widget表示用) → http://35.78.51.61/archives/2445

その程度です。

相変わらずのお勉強用に以下の技術的ポイントで作っています

  • vue.jsにてフロントを開発 : vue varsion 2.6.14
  • laravel-mix(npm)を使ってみる : laravel-mix 6.0.6
  • laravelにてバックエンドを開発 : laravel version 8.61.0

フロントを作成するにあたり、バックエンドとを考えるとJSON(API)にてデータは取ってきます。

簡単なAPIの提供は

  • api/book/list : ツンデレ本全てを取得する
  • api/reading/list : 読書中書籍情報を取得する

この二つのAPIで構成しています。

laravelのroutesはこんな感じ

api.php

Route::group(['prefix' => 'book', 'as' => 'book'], function() {
    Route::post('list', [BookController::class,'list']);
});
Route::group(['prefix' => 'reading', 'as' => 'reading'], function() {
    Route::GET('list', [ReadingBookController::class, 'list']);
});

book/listは管理画面でも流用していますので以下の機能を想定して実装しています。
データを送るのに引数ではめんどくさかったのでPOSTしています

  • フィルタ
  • ページング
  • 検索など

reading/listは全データ取得をするのでGETにしています

ページの構成

画面表示の構築にはbladeを使っています。

<!DOCTYPE html>
<html lang="ja" >
<head>
    <meta charset="utf-8">
    <title>ツンデレ本</title>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <meta name="API_URL" content="{{ env('API_URL') }}"/>
    <link rel="stylesheet" href="{{ asset('css/app.css') }}" type="text/css">
    <script src="{{ asset('js/app.js') }}" defer></script>
</head>
<body class="antialiased" onload="document.getElementById('app').style.display = 'block'">
    <div id="app" style="display: none">
        <header> ... 略ナビバー ...
        </header>
        <main role="main">
            <div class="album py-5 bg-light">
                <div class="container">
                    <div class="row">
                        <h1 id="comic" class="col-12">読書中</h1>
                        <reading-book-list></reading-book-list>
                    </div>
                    <div class="row">
                        <h1 id="comic" class="col-12">コミック</h1>
                        <book-list :kind_id="2"></book-list>
                    </div>
                    <div class="row">
                        <h1 id="technical" class="col-12">専門書</h1>
                        <book-list :kind_id="1"></book-list>
                    </div>
                    <div class="row">
                        <h1 id="novel" class="col-12">小説</h1>
                        <book-list :kind_id="3"></book-list>
                    </div>
                </div>
            </div>
        </main>
    </div>
</body>
</html>

bladeではなくてstatic htmlでも十分とは思いますが、以下の項目が解消できればstaticでできますね。

  • 環境変数ファイルからの引き継ぎ : env(‘API_URL’)
  • mixでのパッケージを取得 : {{ asset(‘js/app.js’) }}
  • csrf_token …

このページではvueコンポーネントは、以下の内容を使っています

  • reading-book-list : 読書中の一覧を取得
  • book-list : kind_idで取得した内容を取得

この二つを利用しています。

トップページのjs app.jsの記述

TOPページで読み込んでいるJS app.jsの記述ですが、悩みました。
結果はが以下です。
無用な部分多いですが、この辺りはどんどん消していきます。

require('./bootstrap');

import jQuery from 'jquery'
global.jquery = jQuery
global.$ = jQuery
window.$ = window.jQuery = require('jquery')

import Vue from 'vue';

import { BootstrapVue, IconsPlugin } from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
Vue.use(BootstrapVue);
Vue.use(IconsPlugin);

import VModal from 'vue-js-modal'
Vue.use(VModal)

import axios from 'axios'
import VueAxios from 'vue-axios'
Vue.use(VueAxios, axios)

import ReadingBookList from "./components/ReadingBookList.vue";
import BookList from "./components/BookList.vue";

const app = new Vue({
    el: '#app',
    components : {  BookList , ReadingBookList }
});

IconsPluginは動いていないと思いますw

book-listとreading-book-listコンポーネントの読み込み部分を抜粋するとこの辺りです。

import ReadingBookList from "./components/ReadingBookList.vue";
import BookList from "./components/BookList.vue";

const app = new Vue({
    el: '#app',
    components : {  BookList , ReadingBookList }
});

vue.use使った例とか、componentsで読み込むとか… 色々試行錯誤しまいたが… 知らん!
バージョンとかいろいろ違うんでしょうね;;

laravel-mixのwebpack.mix.jsの出来上がりは以下の感じです。
vue()の定義が最初に悩みました。

const mix = require('laravel-mix');
const tailwind = require('mix-tailwindcss');
require('laravel-mix-purgecss')
... 略 ...
mix.js('resources/js/app.js', 'public/js')
    .vue()
    .sass('resources/sass/app.scss', 'public/css')
    .tailwind()
    .sourceMaps();

ツンデレ本一覧を表示する。

実際にページを読み込むとツンデレ一覧画面が表示されます

ポイントとしては

  • ツンデレ本一覧が表示できる様にする
  • 書籍種別(コミックなど)を指定して一覧を切り替えられる
  • 書籍の一覧はAPI通信結果を表示します

ツンデレ本の一覧を表示するには以下の様にページから呼び出しています

                        <book-list :kind_id="2"></book-list>

book-listのコンポーネントにkind_id=2(2:コミック)を指定して表示しています。
book-listでは表示タイミングでkind_idに指定した種別をAPI通信で一覧を持ってきます

book-listの定義だけを抜粋するとapp.jsに定義しています。

import BookList from "./components/BookList.vue";

const app = new Vue({
    el: '#app',
    components : {  BookList , ReadingBookList }
});

割り当てられるBookList.vueはこんな感じにしています。

<template>
    <div class="properties-row d-flex justify-content-center">
        <book v-for="book in books" :book="book"></book>
    </div>
</template>
<script>
    import axios from "axios";
    import Book from "./elements/Book.vue";

    const API_URL = document.getElementsByName('API_URL')[0].content;
    /**
     * 書籍一覧を表示するコンポーネント
     * 書籍種別を指定します
     */
    export default {
        name: 'BookList',
        props: [
            'kind_id',
        ],
        data () {
            return {
                books : [],
            }
        },
        components: {
            Book
        },
        methods: {
            /**
             * 書籍一覧をリセット
             * @returns {Promise<void>}
             */
            async initBooks() {
                const data = {
                    filter:{
                        kind : this.kind_id,
                    },
                };
                const url = API_URL + 'api/book/list'
                await axios.post(url,data).then(x => {
                    this.books = x.data.books;
                })
            },

        },
        mounted() {
            this.initBooks()
        }
    }
</script>

BookListの定義しているのにタグになるとbook-listになるのかというと… キャメルとかケバブとか… めんどくさいので略! この辺りが詳しいのかな?

BookList.vueを読み込むと、kind_id(props)に書籍種別(2)が定義されます。
このコンポーネントをmountedするとinitBooksメソッドが走ります
initBooksではapi(api/book/list)から書籍一覧をbooks(data)に入れます
画面が表示され templateのv-forが解析されbooksの内容をbookコンポーネントに引き継いでいます。
bookコンポーネント(Book.vue)の定義は、importとcomponentsで定義しています。

Book.vueではただ表示しているだけなので説明は省きます

<template>
    <div class="property-thumbnail">
        <div class="card mb-3">
            <div class="card-header truncate" >{{ book.title }}</div>
            <div class="card-body d-flex justify-content-center">
                <a :href="book.url" target="_blank">
                    <img :src="book.img_src"  alt=""/>
                </a>
            </div>
        </div>
    </div>
</template>
<script>
    /**
     * 本のジャケットをタイトル商品ページを表示するエレメント
     */
    export default {
        name: 'Book',
        props: [
            'book'
        ],
    }
</script>

ただ、表示しているだけですね。

読書中書籍一覧を表示する

読書中書籍一覧を表示するには、api/reading/listから引っ張ってきますが、htmlから呼び出しはこんな感じです

                        <reading-book-list></reading-book-list>

コンポーネントの割り当てはapp.jsにて

import ReadingBookList from "./components/ReadingBookList.vue";
import BookList from "./components/BookList.vue";

const app = new Vue({
    el: '#app',
    components : {  BookList , ReadingBookList }
});

として、ReadingBookListをreading-book-listに引っ張っています。

このコンポーネントでは呼び出し元からのプロパティ引き継ぎがありません。
普通にAPIとの通信はコンポーネントのmountedにて行います。
一覧の変数 readingBooksの初期化はinitReadingBookメソッドにてやっております。

自体のソースはReadingBookList.vue

<template>
    <div class="properties-row d-flex justify-content-center">
        <div class="col-4" style="padding:0;" v-for="readingBook in readingBooks">
            <div class="card ">
                <div class="card-header" >{{ readingBook.scene.title }}</div>
                <div class="card-body text-primary text-center d-flex justify-content-center">
                    <reading-book class="col-12" :book="readingBook.book"></reading-book>
                </div>
            </div>
        </div>
    </div>
</template>
<script>
import axios from "axios";
import ReadingBook from "./elements/ReadingBook";

const API_URL = document.getElementsByName('API_URL')[0].content;
/**
 * 読書中の書籍一覧コンポーネント
 */
export default {
    name: 'ReadingBookList',
    components:{
        ReadingBook
    },
    data () {
        return {
            readingBooks : [],
        }
    },
    methods: {
        /**
         * 読書中書籍一覧を初期化
         * @returns {Promise<void>}
         */
        async initReadingBook() {
            const url = API_URL + 'api/reading/list'
            await axios.get(url).then(x => {
                this.readingBooks = x.data.readings;
            })
        },
    },
    mounted() {
        this.initReadingBook()
    }
}
</script>

読書中の書籍情報はreading-bookコンポーネントにbookとしてバインドしています

<reading-book class="col-12" :book="readingBook.book"></reading-book>

reading-bookコンポーネントのvueファイル

<template>
    <div class="property-thumbnail d-flex justify-content-center">
        <div class="card mb-3" style="border:none">
            <div class="card-body d-flex justify-content-center" style="padding-top: 0px;padding-bottom: 0px">
                <img v-if="book !== null" :src="book.img_src"  alt="book.title" style="height: 250px;min-height: 250px;"/>
                <div v-else class="d-flex justify-content-center align-items-center border col-12" style="height: 250px;min-height: 250px;">
                    なし
                </div>
            </div>
            <div class="card-body truncate" style="height: 1em;">
                <div v-if="book !== null" class="truncate"  >{{ book.title }}</div>
            </div>
        </div>
    </div>
</template>
<script>
    export default {
        name: 'ReadingBookComponent',
        props: [
            'book'
        ],
    }
</script>

書籍の表示はツンデレ本と同じコンポーネント(Book.vue)を使っています。

ポイントとしては、apiのURL指定です、今回はlaravelの環境設定ファイル .envの内容をhtml metaに入れています

    <meta name="API_URL" content="{{ env('API_URL') }}"/>

最初、vue内で呼び出してたらnpm コンパイルだと環境設定変えても引き継がれないのですね。
MIX_API_URLとか…
このためにbladeになっているので、ここだけは要検討箇所です

metaからのjacasciprtへの引き継ぎは

const API_URL = document.getElementsByName('API_URL')[0].content;

っす、各コンポーネントで呼び出しているのでその部分は検討です

問題と解消

読み込み完了までデザインが崩れる

結構悩んだのですが、javascriptの読み込み完了(defer)までデザインが崩れるのが問題でしたが、以下の記述で解消しています

<body class="antialiased" onload="document.getElementById('app').style.display = 'block'">
... 略 ...
    <div id="app" style="display: none">

こんなのjavascriptの基礎でしょうが、onload走るまで非表示にしていますw 超ダサいですw

ってかjavascriptファイルがデカすぎます… たいして使っていないのに2MBですから…

タイトルが長いと折り返しでダサい

タイトルというのは微妙に長い場合があります。1行で丸め込もうと検証して、filtersを指定を検討しましたが、全角半角の文字数で諦めました…
今回はtailwindcssというのを使いました

https://tailwindcss.com/

使い方は…公式通りでOKでしょう。
出来上がりのpackage.jsonはこんな感じ

        "laravel-mix": "^6.0.6",
        "tailwindcss": "^2.2.15",

webpack.mix.jsにこんな感じで追加

const tailwind = require('mix-tailwindcss');

mix.js('resources/js/app.js', 'public/js')
    .vue()
    .sass('resources/sass/app.scss', 'public/css')
    .tailwind()
    .sourceMaps();

tailwindは、タイトルが折り返し時に…

            <div class="card-body truncate" style="height: 1em;">
                <div v-if="book !== null" class="truncate"  >{{ book.title }}</div>
            </div>

class=”truncate”を書くと丸め込んでくれます

終いに

今回は、ツンデレ本一覧の部分を簡単にまとめてみました。

初めてvue.jsを利用したのですが、めっちゃ大変でしたがフロントとバックエンドのAPI(JSON)と疎結合できるので楽といえば楽ですね。それこそstatic htmlページでも良いわけです。(npmは欲しいですが)

通信をAPIにまとめれば、バックエンドのLaravel側の試験も超楽です

次回は一番悩んだ管理画面の解説(抜粋)ですね。