Vue + Vue Router + Vuex + Laravel 7.20.0で写真共有アプリを作ろう

最新版のLarabel 7.20.0で作っていきます。
![202007191203.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643170/c0c2fcfe-348d-0780-b022-5bfc2d9ad52e.png)

簡潔に書いてきます。[チュートリアル](https://www.hypertextcandy.com/vue-laravel-tutorial-introduction)ではDockerで環境構築してAWS S3 に画像をアップロードしていますが、ここではXamppで環境構築しLocalに画像をアップロードしていく点が違います。

##作業環境
- OS：Windows 10 HOME ver.2004
- Node：12.18.2
- npm：6.14.6
- Vue.js：
- Vue Router：
- Vuex：
- PHP：7.4.6
- Laravel：7.20.0

##[(1) イントロダクション](https://www.hypertextcandy.com/vue-laravel-tutorial-introduction)
説明のみでコードはないので、エラーもありませんでした。

##[(2) アプリケーションの設計](https://www.hypertextcandy.com/vue-laravel-tutorial-application-design/)
説明のみでコードはないので、エラーもありませんでした。

##[(3) SPA開発環境とVue Router](https://www.hypertextcandy.com/vue-laravel-tutorial-setting-up-spa-project/)
###Laravel プロジェクトを作成する
####Docker で開発環境構築
環境構築はXampp で行いました。Laravelのインストールとログインに利用するコントローラーもインストールします。
参考：https://readouble.com/laravel/7.x/ja/authentication.html

```terminal:Terminal
$ cd C:\xampp\htdocs
$ composer create-project laravel/laravel vuesplash --prefer-dist
$ composer require laravel/ui
$ php artisan ui vue --auth
```

今回フロントはVueで作成するので、以下のフォルダとファイルを削除します。

- views/auth
- views/layouts
- views/home.blade.php



###Laravel の初期設定
####app.php

```config/app.php
70行目　'timezone' => 'Asia/Tokyo',
83行目　'locale' => 'ja',
```

####.env
サーバーはMySQLを利用します。

```terminal:.env
APP_NAME=Vuesplash

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=vuesplash　//データベース名
DB_USERNAME=root //ユーザー名
DB_PASSWORD= //パスワード
```

####EditorConfig
.editorconfig ファイルが存在しないので飛ばします。

###フロントエンドの準備
####JavaScript
パッケージとVue.js を追加でインストールします。

```terminal:Terminal
$ npm install
$ npm install -D vue
```

####Laravel Mix
ここは少し変わります。
参考：https://readouble.com/laravel/7.x/ja/mix.html

```webpack.mix.js
const mix = require('laravel-mix');

mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .version();

mix.browserSync({
    files: [
      "resources/views/**/*.blade.php",
      "public/**/*.*"
    ],
    proxy: 'http://127.0.0.1:8000'
});

```

コマンドで`php artisan serve`を実行してLaravelを起動して、別のコマンドで`npm run watch`を実行するとブラウザ自動的に立ち上がり、`http://localhost:3000/`にて画面が表示されます。
![202007172101.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643170/5e380d04-781f-1686-e870-ae8903145169.png)
###画面（HTML）を返す
####ルーティング

```routes/web.php
<?php

// APIのURL以外のリクエストに対してはindexテンプレートを返す
// 画面遷移はフロントエンドのVueRouterが制御する
Route::get('/{any?}', fn() => view('index'))->where('any', '.+');
```

####テンプレート

```resources/views/index.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ config('app.name') }}</title>

  <!-- Scripts -->
  <script src="{{ mix('js/app.js') }}" defer></script>

  <!-- Fonts -->
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Merriweather|Roboto:400">
  <link rel="stylesheet" href="https://unpkg.com/ionicons@4.2.2/dist/css/ionicons.min.css">

  <!-- Styles -->
  <link rel="stylesheet" href="https://hypertext-candy.s3-ap-northeast-1.amazonaws.com/posts/vue-laravel-tutorial/app.css">
</head>
<body>
  <div id="app"></div>
</body>
</html>
```

####JavaScript

```resources/js/app.js
import Vue from 'vue'

new Vue({
  el: '#app',
  template: '<h1>Hello world</h1>'
})
```

####フロントエンドのビルド
コマンドで`npm run watch`を2回実行すると、下記の画面が表示されます。
![202007172109.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643170/0a6ba6ea-ea48-773f-a930-a0e33b35172c.png)

###Vue Router
####インストール

```terminal:Terminal
$ npm install -D vue-router
```

####ルートコンポーネント

```resources/js/App.vue
<template>
  <div>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
  </div>
</template>

```

###ページコンポーネント

```resources/js/pages/PhotoList.vue
<template>
  <h1>Photo List</h1>
</template>
```

```resources/js/pages/Login.vue
<template>
  <h1>Login</h1>
</template>
```

####ルーティング

```resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router
```

```config/app.php
import Vue from 'vue'
// ルーティングの定義をインポートする
import router from './router'
// ルートコンポーネントをインポートする
import App from './App.vue'

new Vue({
  el: '#app',
  router, // ルーティングの定義を読み込む
  components: { App }, // ルートコンポーネントの使用を宣言する
  template: '<App />' // ルートコンポーネントを描画する
})

```

####history モード

```resources/js/router.js
const router = new VueRouter({
  mode: 'history', // ★ 追加
  routes
})
```

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-3

##[(4) 認証API](https://www.hypertextcandy.com/vue-laravel-tutorial-authentication/)
###API 用のルート

```app/Providers/RouteServiceProvider.php
protected function mapApiRoutes()
{
    Route::prefix('api')
         ->middleware('web') // ★ 'api' → 'web' に変更
         ->namespace($this->namespace)
         ->group(base_path('routes/api.php'));
}
```

###テストの準備
####インメモリの SQLite を用いる
config/database.php の connections に以下の接続情報を追加します。

```config/database.php
'sqlite_testing' => [
    'driver' => 'sqlite',
    'database' => ':memory:',
    'prefix' => '',
],
```

phpunit.xml に DB 接続の設定を追記します。ここは少し変わります。

```phpunit.xml
<php>
    <server name="APP_ENV" value="testing"/>
    <server name="DB_CONNECTION" value="sqlite_testing"/> <!-- ★ 追加 -->
    <!-- 以下略 -->
```

###会員登録 API
####テストコード

```terminal:Terminal
$ php artisan make:test RegisterApiTest
```

```tests/Feature/RegisterApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class RegisterApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_新しいユーザーを作成して返却する()
    {
        $data = [
            'name' => 'vuesplash user',
            'email' => 'dummy@email.com',
            'password' => 'test1234',
            'password_confirmation' => 'test1234',
        ];

        $response = $this->json('POST', route('register'), $data);

        $user = User::first();
        $this->assertEquals($data['name'], $user->name);

        $response
            ->assertStatus(201)
            ->assertJson(['name' => $user->name]);
    }
}

```
####ルート定義
routes/api.php に下記を追記します。

```routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

// 会員登録
Route::post('/register', 'Auth\RegisterController@register')->name('register');
```

####コントローラー

```app/Http/Controllers/Auth/RegisterController.php
use Illuminate\Http\Request; // ★ 追加

class RegisterController extends Controller
{
    /* 中略 */

    // ★ メソッド追加
    protected function registered(Request $request, $user)
    {
        return $user;
    }
}
```

####テスト実施

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Warning: TTY mode is not supported on Windows platform.
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

Example (Tests\Unit\Example)
 ✔ Basic test

Example (Tests\Feature\Example)
 ✔ Basic test

Register Api (Tests\Feature\RegisterApi)
 ✔ Should 新しいユーザーを作成して返却する

Time: 12.17 seconds, Memory: 24.00 MB

OK (3 tests, 5 assertions)

```

###ログイン API
####テストコード

```terminal:Terminal
$ php artisan make:test LoginApiTest
```

```tests/Feature/LoginApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class LoginApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_登録済みのユーザーを認証して返却する()
    {
        $response = $this->json('POST', route('login'), [
            'email' => $this->user->email,
            'password' => 'password',
        ]);

        $response
            ->assertStatus(200)
            ->assertJson(['name' => $this->user->name]);

        $this->assertAuthenticatedAs($this->user);
    }
}
```

####ルート定義
routes/api.php に下記を追記します。

```routes/api.php
// ログイン
Route::post('/login', 'Auth\LoginController@login')->name('login');
```

####コントローラー

```app/Http/Controllers/Auth/LoginController.php
use Illuminate\Http\Request; // ★ 追加

class LoginController extends Controller
{
    /* 中略 */

    // ★ メソッド追加
    protected function authenticated(Request $request, $user)
    {
        return $user;
    }
}
```

####テスト実施

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Login Api (Tests\Feature\LoginApi)
 ✔ Should 登録済みのユーザーを認証して返却する
```

###ログアウト API
####テストコード

```terminal:Terminal
$ php artisan make:test LogoutApiTest
```

```tests/Feature/LogoutApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class LogoutApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_認証済みのユーザーをログアウトさせる()
    {
        $response = $this->actingAs($this->user)
                         ->json('POST', route('logout'));

        $response->assertStatus(200);
        $this->assertGuest();
    }
}
```

####ルート定義
routes/api.php に下記を追記します。

```routes/api.php
// ログアウト
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');
```

####コントローラー
LoginController.php に下記のメソッドを追記します。

```app/Http/Controllers/Auth/LoginController.php
protected function loggedOut(Request $request)
{
    // セッションを再生成する
    $request->session()->regenerate();

    return response()->json();
}
```

####テスト実施

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Logout Api (Tests\Feature\LogoutApi)
 ✔ Should 認証済みのユーザーをログアウトさせる
```

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-4

##[(5) 認証ページ](https://www.hypertextcandy.com/vue-laravel-tutorial-authentication-part-2/)
###ヘッダーとフッター
####ヘッダーコンポーネント

```resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div class="navbar__item">
        <button class="button">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span class="navbar__item">
        username
      </span>
      <div class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
  </nav>
</template>
```

####フッターコンポーネント

```resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button class="button button--link">Logout</button>
    <RouterLink class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>
```

####App.vue

```resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'

export default {
  components: {
    Navbar,
    Footer
  }
}
</script>
```

ブラウザでヘッダーとフッターが表示されます。
![202007180944.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643170/1ac95f2b-adbd-112a-b1bf-8659eda1ca93.png)
###タブ機能を実装する
####タブ UI を追加する

```resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">Login Form</div>
    <div class="panel" v-show="tab === 2">Register Form</div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      tab: 1
    }
  }
}
</script>
```

ブラウザで「Login Form」と「Register Form」がタブで切り替わることを確認できます。
![202007180949.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643170/6581774f-3315-c459-dfc4-0ca09393b735.png)
####選択状態のスタイルを変える

```resources/js/pages/Login.vue
<ul class="tab">
  <li
    class="tab__item"
    :class="{'tab__item--active': tab === 1 }"
    @click="tab = 1"
  >Login</li>
  <li
    class="tab__item"
    :class="{'tab__item--active': tab === 2 }"
    @click="tab = 2"
  >Register</li>
</ul>
```

###フォームを実装する
最終的に以下のようになります。

```resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 1 }"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 2 }"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">
      <form class="form" @submit.prevent="login">
        <label for="login-email">Email</label>
        <input type="text" class="form__item" id="login-email" v-model="loginForm.email">
        <label for="login-password">Password</label>
        <input type="password" class="form__item" id="login-password" v-model="loginForm.password">
        <div class="form__button">
          <button type="submit" class="button button--inverse">login</button>
        </div>
      </form>
    </div>
    <div class="panel" v-show="tab === 2">
      <form class="form" @submit.prevent="register">
        <label for="username">Name</label>
        <input type="text" class="form__item" id="username" v-model="registerForm.name">
        <label for="email">Email</label>
        <input type="text" class="form__item" id="email" v-model="registerForm.email">
        <label for="password">Password</label>
        <input type="password" class="form__item" id="password" v-model="registerForm.password">
        <label for="password-confirmation">Password (confirm)</label>
        <input type="password" class="form__item" id="password-confirmation" v-model="registerForm.password_confirmation">
        <div class="form__button">
          <button type="submit" class="button button--inverse">register</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      tab: 1,
      loginForm: {
        email: '',
        password: ''
      },
      registerForm: {
        name: '',
        email: '',
        password: '',
        password_confirmation: ''
      }
    }
  },
  methods: {
    login () {
      console.log(this.loginForm)
    },
    register () {
      console.log(this.registerForm)
    }
  }
}
</script>

```

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-5

##[(6) 認証機能とVuex](https://www.hypertextcandy.com/vue-laravel-tutorial-authentication-part-3/)
###Vuex の導入
####インストール

```terminal:Terminal
$ npm install --save-dev vuex
```

####ストアの作成と読み込み

```resources/js/store/auth.js
const state = {}

const getters = {}

const mutations = {}

const actions = {}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}
```

```resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth
  }
})

export default store
```

```resources/js/app.js
import Vue from 'vue'
import router from './router'
import store from './store' // ★　追加
import App from './App.vue'

new Vue({
  el: '#app',
  router,
  store, // ★　追加
  components: { App },
  template: '<App />'
})
```

###CSRF 対策
####CSRF 対策の実装
#####クッキーを取り出す関数

```resources/js/util.js
/**
 * クッキーの値を取得する
 * @param {String} searchKey 検索するキー
 * @returns {String} キーに対応する値
 */
export function getCookieValue (searchKey) {
  if (typeof searchKey === 'undefined') {
    return ''
  }

  let val = ''

  document.cookie.split(';').forEach(cookie => {
    const [key, value] = cookie.split('=')
    if (key === searchKey) {
      return val = value
    }
  })

  return val
}
```

#####Axios の設定

```resources/js/bootstrap.js
import { getCookieValue } from './util'

window.axios = require('axios')

// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

window.axios.interceptors.request.use(config => {
  // クッキーからトークンを取り出してヘッダーに添付する
  config.headers['X-XSRF-TOKEN'] = getCookieValue('XSRF-TOKEN')

  return config
})
```

app.js の先頭行に以下の記述を追加します。

```resources/js/app.js
import './bootstrap'
```

###会員登録
####マイグレーション

```terminal:Terminal
$ php artisan migrate
```

####ストアの実装

```resources/js/store/auth.js
const state = {
  user: null
}

const getters = {}

const mutations = {
  setUser (state, user) {
    state.user = user
  }
}

const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

```

####コンポーネントの実装
ログインページを表す Login.vue の register メソッドを以下の通り編集します。

```resources/js/pages/Login.vue
async register () {
  // authストアのresigterアクションを呼び出す
  await this.$store.dispatch('auth/register', this.registerForm)

  // トップページに移動する
  this.$router.push('/')
}
```

###ログイン
####ストアの実装

```resources/js/store/auth.js
const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  },
  async login (context, data) {
    const response = await axios.post('/api/login', data)
    context.commit('setUser', response.data)
  }
}
```

####コンポーネントの実装

```resources/js/pages/Login.vue
async login () {
  // authストアのloginアクションを呼び出す
  await this.$store.dispatch('auth/login', this.loginForm)

  // トップページに移動する
  this.$router.push('/')
},
```

###ログアウト
####ストアの実装

```resources/js/store/auth.js
const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  },
  async login (context, data) {
    const response = await axios.post('/api/login', data)
    context.commit('setUser', response.data)
  },
  async logout (context) {
    const response = await axios.post('/api/logout')
    context.commit('setUser', null)
  }
}
```

####コンポーネントの実装

```resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button class="button button--link" @click="logout">Logout</button>
    <RouterLink class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
export default {
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')

      this.$router.push('/login')
    }
  }
}
</script>

```

ここまでは以下より確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-6

##[(7) 認証機能とVuex Part.2](https://www.hypertextcandy.com/vue-laravel-tutorial-authentication-part-4/)
###ステートの値による要素の出し分け
####ゲッターを追加する

```resources/js/store/auth.js
const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}
```

####ナビゲーションバー

```resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div v-if="isLogin" class="navbar__item">
        <button class="button">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span v-if="isLogin" class="navbar__item">
        username
      </span>
      <div v-else class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    },
    username () {
      return this.$store.getters['auth/username']
    }
  }
}
</script>
```

####フッター

```resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button v-if="isLogin" class="button button--link" @click="logout">
      Logout
    </button>
    <RouterLink v-else class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
export default {
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')

      this.$router.push('/login')
    }
  }
}
</script>

```

###認証状態を維持する
####ユーザー取得 API

```terminal:Terminal
$ php artisan make:test UserApiTest
```

```tests/Feature/UserApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_ログイン中のユーザーを返却する()
    {
        $response = $this->actingAs($this->user)->json('GET', route('user'));

        $response
            ->assertStatus(200)
            ->assertJson([
                'name' => $this->user->name,
            ]);
    }

    /**
     * @test
     */
    public function should_ログインされていない場合は空文字を返却する()
    {
        $response = $this->json('GET', route('user'));

        $response->assertStatus(200);
        $this->assertEquals("", $response->content());
    }
}
```

####実装

```routes/api.php
// ログインユーザー
Route::get('/user', fn() => Auth::user())->name('user');
```

```terminal:Terminal
$ php artisan test --testdox

#実行結果
User Api (Tests\Feature\UserApi)
 ✔ Should ログイン中のユーザーを返却する
 ✔ Should ログインされていない場合は空文字を返却する
```

###起動時にログインチェック
####ストアにアクションを追加

```resources/js/store/auth.js
const actions = {
  async register (context, data) {/* 中略 */},
  async login (context, data) {/* 中略 */},
  async logout (context) {/* 中略 */},
  async currentUser (context) {
    const response = await axios.get('/api/user')
    const user = response.data || null
    context.commit('setUser', user)
  }
}
```

####ログインチェックしてからアプリを生成する

```resources/js/app.js
import './bootstrap'
import Vue from 'vue'
import router from './router'
import store from './store'
import App from './App.vue'

const createApp = async () => {
  await store.dispatch('auth/currentUser')

  new Vue({
    el: '#app',
    router,
    store,
    components: { App },
    template: '<App />'
  })
}

createApp()
```

####ミドルウェア

```app/Http/Middleware/RedirectIfAuthenticated.php
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->check()) {
        return redirect()->route('user'); // ★ 変更
    }

    return $next($request);
}
```

###ナビゲーションガード
####ルート定義にナビゲーションガードを追加

```resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'

import store from './store' // ★　追加

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {  // ★　追加
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  }
]

const router = new VueRouter({
  mode: 'history',
  routes
})

export default router

```

ここまでは以下より確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-7

##[(8) エラーハンドリング](https://www.hypertextcandy.com/vue-laravel-tutorial-error-handling/)
最終的なコードは以下のようになります。

###システムエラーページ

```resources/js/pages/errors/System.vue
<template>
  <p>システムエラーが発生しました。</p>
</template>
```

```resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'
import SystemError from './pages/errors/System.vue'

import store from './store'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  },
  {
    path: '/500',
    component: SystemError
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  mode: 'history',
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router
```

###レスポンスコード定義

```resources/js/util.js
/**
 * クッキーの値を取得する
 * @param {String} searchKey 検索するキー
 * @returns {String} キーに対応する値
 */
export function getCookieValue (searchKey) {
  if (typeof searchKey === 'undefined') {
    return ''
  }

  let val = ''

  document.cookie.split(';').forEach(cookie => {
    const [key, value] = cookie.split('=')
    if (key === searchKey) {
      return val = value
    }
  })

  return val
}

export const OK = 200
export const CREATED = 201
export const INTERNAL_SERVER_ERROR = 500
export const UNPROCESSABLE_ENTITY = 422

```

###error ストア

```resources/js/store/error.js
const state = {
  code: null
}

const mutations = {
  setCode (state, code) {
    state.code = code
  }
}

export default {
  namespaced: true,
  state,
  mutations
}
```

```resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'
import error from './error'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error
  }
})

export default store

```

###auth ストア

```resources/js/store/auth.js
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'

const state = {
  user: null,
  apiStatus: null,
  loginErrorMessages: null,
  registerErrorMessages: null
}

const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}

const mutations = {
  setUser (state, user) {
    state.user = user
  },
  setApiStatus (state, status) {
    state.apiStatus = status
  },
  setLoginErrorMessages (state, messages) {
    state.loginErrorMessages = messages
  },
  setRegisterErrorMessages (state, messages) {
    state.registerErrorMessages = messages
  }
}

const actions = {
  // 会員登録
  async register (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/register', data)

    if (response.status === CREATED) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setRegisterErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログイン
  async login (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/login', data)

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setLoginErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログアウト
  async logout (context) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/logout')

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', null)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  },

  // ログインユーザーチェック
  async currentUser (context) {
    context.commit('setApiStatus', null)
    const response = await axios.get('/api/user')
    const user = response.data || null

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', user)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

```

###ページコンポーネント

```resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 1 }"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 2 }"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">
      <form class="form" @submit.prevent="login">
        <div v-if="loginErrors" class="errors">
          <ul v-if="loginErrors.email">
            <li v-for="msg in loginErrors.email" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="loginErrors.password">
            <li v-for="msg in loginErrors.password" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <label for="login-email">Email</label>
        <input type="text" class="form__item" id="login-email" v-model="loginForm.email">
        <label for="login-password">Password</label>
        <input type="password" class="form__item" id="login-password" v-model="loginForm.password">
        <div class="form__button">
          <button type="submit" class="button button--inverse">login</button>
        </div>
      </form>
    </div>
    <div class="panel" v-show="tab === 2">
      <form class="form" @submit.prevent="register">
        <div v-if="registerErrors" class="errors">
          <ul v-if="registerErrors.name">
            <li v-for="msg in registerErrors.name" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="registerErrors.email">
            <li v-for="msg in registerErrors.email" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="registerErrors.password">
            <li v-for="msg in registerErrors.password" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <label for="username">Name</label>
        <input type="text" class="form__item" id="username" v-model="registerForm.name">
        <label for="email">Email</label>
        <input type="text" class="form__item" id="email" v-model="registerForm.email">
        <label for="password">Password</label>
        <input type="password" class="form__item" id="password" v-model="registerForm.password">
        <label for="password-confirmation">Password (confirm)</label>
        <input type="password" class="form__item" id="password-confirmation" v-model="registerForm.password_confirmation">
        <div class="form__button">
          <button type="submit" class="button button--inverse">register</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  data () {
    return {
      tab: 1,
      loginForm: {
        email: '',
        password: ''
      },
      registerForm: {
        name: '',
        email: '',
        password: '',
        password_confirmation: ''
      }
    }
  },
  computed: mapState({
    apiStatus: state => state.auth.apiStatus,
    loginErrors: state => state.auth.loginErrorMessages,
    registerErrors: state => state.auth.registerErrorMessages
  }),
  methods: {
    async login () {
      // authストアのloginアクションを呼び出す
      await this.$store.dispatch('auth/login', this.loginForm)
      if (this.apiStatus) {
        // トップページに移動する
        this.$router.push('/')
      }
    },
    async register () {
      // authストアのresigterアクションを呼び出す
      await this.$store.dispatch('auth/register', this.registerForm)
      if (this.apiStatus) {
        // トップページに移動する
        this.$router.push('/')
      }
    },
    clearError () {
      this.$store.commit('auth/setLoginErrorMessages', null)
      this.$store.commit('auth/setRegisterErrorMessages', null)
    }
  },
  created () {
    this.clearError()
  }
}
</script>

```

###ルートコンポーネント

```resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { INTERNAL_SERVER_ERROR } from './util'
export default {
  components: {
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

```

###bootstrap

```resources/js/bootstrap.js
import { getCookieValue } from './util'

window.axios = require('axios')

// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

window.axios.interceptors.request.use(config => {
  // クッキーからトークンを取り出してヘッダーに添付する
  config.headers['X-XSRF-TOKEN'] = getCookieValue('XSRF-TOKEN')

  return config
})

window.axios.interceptors.response.use(
  response => response,
  error => error.response || error
)
```

###ログアウト

```resources/jscomponents/Footer.vue
<template>
  <footer class="footer">
    <button v-if="isLogin" class="button button--link" @click="logout">
      Logout
    </button>
    <RouterLink v-else class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
import { mapState, mapGetters } from 'vuex'
export default {
  computed: {
    ...mapState({
      apiStatus: state => state.auth.apiStatus
    }),
    ...mapGetters({
      isLogin: 'auth/check'
    })
  },
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')
      if (this.apiStatus) {
        this.$router.push('/login')
      }
    }
  }
}
</script>
```

ここまでは下記にまとめてあります。
https://github.com/neneta0921/vuesplash/tree/ch-8

##[(9) 写真投稿API](https://www.hypertextcandy.com/vue-laravel-tutorial-submit-photo/)
###AWS S3
AWS S3 は利用せずにLocalにファイルを保管します。
###テストコード

```terminal:Terminal
$ php artisan make:test PhotoSubmitApiTest
```

```tests/Feature/PhotoSubmitApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class PhotoSubmitApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_ファイルをアップロードできる()
    {
        // S3ではなくテスト用のストレージを使用する
        // → storage/framework/testing
        Storage::fake('s3');

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                // ダミーファイルを作成して送信している
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが201(CREATED)であること
        $response->assertStatus(201);

        $photo = Photo::first();

        // 写真のIDが12桁のランダムな文字列であること
        $this->assertRegExp('/^[0-9a-zA-Z-_]{12}$/', $photo->id);

        // DBに挿入されたファイル名のファイルがストレージに保存されていること
        Storage::cloud()->assertExists($photo->filename);
    }

    /**
     * @test
     */
    public function should_データベースエラーの場合はファイルを保存しない()
    {
        // 乱暴だがこれでDBエラーを起こす
        Schema::drop('photos');

        Storage::fake('s3');

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが500(INTERNAL SERVER ERROR)であること
        $response->assertStatus(500);

        // ストレージにファイルが保存されていないこと
        $this->assertEquals(0, count(Storage::cloud()->files()));
    }

    /**
     * @test
     */
    public function should_ファイル保存エラーの場合はDBへの挿入はしない()
    {
        // ストレージをモックして保存時にエラーを起こさせる
        Storage::shouldReceive('cloud')
            ->once()
            ->andReturnNull();

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが500(INTERNAL SERVER ERROR)であること
        $response->assertStatus(500);

        // データベースに何も挿入されていないこと
        $this->assertEmpty(Photo::all());
    }
}
```

###API の実装
###.env
AWS S3 は利用しないので設定はしません。
####マイグレーション

```terminal:Terminal
$ php artisan make:migration create_photos_table --create=photos
$ php artisan make:migration create_likes_table --create=likes
$ php artisan make:migration create_comments_table --create=comments
```

以下はチュートリアルとは少し異なります。
参考：https://readouble.com/laravel/7.x/ja/migrations.html

#####photos テーブル

```XXXX_XX_XX_XXXXXX_create_photos_table.php
    public function up()
    {
        Schema::create('photos', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained();
            $table->string('filename');
            $table->timestamps();
        });
    }
```

#####likes テーブル

```XXXX_XX_XX_XXXXXX_create_likes_table.php
    public function up()
    {
        Schema::create('likes', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }
```

#####comments テーブル

```XXXX_XX_XX_XXXXXX_create_comments_table.php
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->text('content');
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }
```

#####マイグレーション実行

```terminal:Terminal
$ php artisan migrate
```

####モデル
#####Photo

```terminal:Terminal
$ php artisan make:model Photo
```

```app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }
}
```

#####User

```app/User.php
/**
 * リレーションシップ - photosテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function photos()
{
    return $this->hasMany('App\Photo');
}
```

####ルーティング

```routes/api.php
// 写真投稿
Route::post('/photos', 'PhotoController@create')->name('photo.create');
```

####フォームリクエスト

```terminal:Terminal
$ php artisan make:request StorePhoto
```

```app/Http/Requests/StorePhoto.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePhoto extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'photo' => 'required|file|mimes:jpg,jpeg,png,gif'
        ];
    }
}
```

####コントローラー

```terminal:Terminal
$ php artisan make:controller PhotoController
```

Localに保存するので、チュートリアルとは少し違います。

```app/Http/Controllers/PhotoController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePhoto;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class PhotoController extends Controller
{
    public function __construct()
    {
        // 認証が必要
        $this->middleware('auth');
    }

    /**
     * 写真投稿
     * @param StorePhoto $request
     * @return \Illuminate\Http\Response
     */
    public function create(StorePhoto $request)
    {
        // 投稿写真の拡張子を取得する
        $extension = $request->photo->extension();

        $photo = new Photo();

        // インスタンス生成時に割り振られたランダムなID値と
        // 本来の拡張子を組み合わせてファイル名とする
        $photo->filename = $photo->id . '.' . $extension;

        // storage/app/public配下に保存する
        //Storage::putFileAs('photos', $photo->filename, 'public');
        $request->photo->storeAs('photos', $photo->filename, 'public');

        // データベースエラー時にファイル削除を行うため
        // トランザクションを利用する
        DB::beginTransaction();

        try {
            Auth::user()->photos()->save($photo);
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();
            // DBとの不整合を避けるためアップロードしたファイルを削除
            Storage::disk()->delete($photo->filename);
            throw $exception;
        }

        // リソースの新規作成なので
        // レスポンスコードは201(CREATED)を返却する
        return response($photo, 201);
    }
}

```

####テストの実行

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Photo Submit Api (Tests\Feature\PhotoSubmitApi)
 ✘ Should ファイルをアップロードできる
   │
   │ Expected status code 201 but received 405.
   │ Failed asserting that 201 is identical to 405.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:42
   │

 ✘ Should データベースエラーの場合はファイルを保存しない
   │
   │ Expected status code 500 but received 405.
   │ Failed asserting that 500 is identical to 405.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:69
   │

 ✘ Should ファイル保存エラーの場合はDBへの挿入はしない
   │
   │ Expected status code 500 but received 422.
   │ Failed asserting that 500 is identical to 422.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:91
   │

FAILURES!
```

今回はエラーが出るようにテストしているので、これでOKです。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-10

##[(10) 写真投稿フォーム](https://www.hypertextcandy.com/vue-laravel-tutorial-submit-photo-part-2)
最終的に以下のようになります。
###フォームコンポーネント

```resources/js/components/PhotoForm.vue
<template>
  <div v-show="value" class="photo-form">
    <h2 class="title">Submit a photo</h2>
    <div v-show="loading" class="panel">
      <Loader>Sending your photo...</Loader>
    </div>
    <form v-show="! loading" class="form" @submit.prevent="submit">
      <div class="errors" v-if="errors">
        <ul v-if="errors.photo">
          <li v-for="msg in errors.photo" :key="msg">{{ msg }}</li>
        </ul>
      </div>
      <input class="form__item" type="file" @change="onFileChange">
      <output class="form__output" v-if="preview">
        <img :src="preview" alt="">
      </output>
      <div class="form__button">
        <button type="submit" class="button button--inverse">submit</button>
      </div>
    </form>
  </div>
</template>

<script>
import { CREATED, UNPROCESSABLE_ENTITY } from '../util'
import Loader from './Loader.vue'
export default {
  components: {
    Loader
  },
  props: {
    value: {
      type: Boolean,
      required: true
    }
  },
  data () {
    return {
      loading: false,
      preview: null,
      photo: null,
      errors: null
    }
  },
  methods: {
    // フォームでファイルが選択されたら実行される
    onFileChange (event) {
      // 何も選択されていなかったら処理中断
      if (event.target.files.length === 0) {
        this.reset()
        return false
      }
      // ファイルが画像ではなかったら処理中断
      if (! event.target.files[0].type.match('image.*')) {
        this.reset()
        return false
      }
      // FileReaderクラスのインスタンスを取得
      const reader = new FileReader()
      // ファイルを読み込み終わったタイミングで実行する処理
      reader.onload = e => {
        // previewに読み込み結果（データURL）を代入する
        // previewに値が入ると<output>につけたv-ifがtrueと判定される
        // また<output>内部の<img>のsrc属性はpreviewの値を参照しているので
        // 結果として画像が表示される
        this.preview = e.target.result
      }
      // ファイルを読み込む
      // 読み込まれたファイルはデータURL形式で受け取れる（上記onload参照）
      reader.readAsDataURL(event.target.files[0])
      this.photo = event.target.files[0]
    },
    // 入力欄の値とプレビュー表示をクリアするメソッド
    reset () {
      this.preview = ''
      this.photo = null
      this.$el.querySelector('input[type="file"]').value = null
    },
    async submit () {
      this.loading = true
      const formData = new FormData()
      formData.append('photo', this.photo)
      const response = await axios.post('/api/photos', formData)
      this.loading = false
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.errors = response.data.errors
        return false
      }
      this.reset()
      this.$emit('input', false)
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      // メッセージ登録
      this.$store.commit('message/setContent', {
        content: '写真が投稿されました！',
        timeout: 6000
      })
      this.$router.push(`/photos/${response.data.id}`)
    }
  }
}
</script>

```

###ナビゲーションバーコンポーネント

```resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div v-if="isLogin" class="navbar__item">
        <button class="button" @click="showForm = ! showForm">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span v-if="isLogin" class="navbar__item">
        {{ username }}
      </span>
      <div v-else class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
    <PhotoForm v-model="showForm" />
  </nav>
</template>

<script>
import PhotoForm from './PhotoForm.vue'
export default {
  components: {
    PhotoForm
  },
  data () {
    return {
      showForm: false
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    },
    username () {
      return this.$store.getters['auth/username']
    }
  }
}
</script>

```

###投稿完了後のページ遷移
####遷移先ページ作成

```resources/js/pages/PhotoDetail.vue
<template>
  <h1>Photo Detail</h1>
</template>
```

```resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import PhotoDetail from './pages/PhotoDetail.vue'
import Login from './pages/Login.vue'
import SystemError from './pages/errors/System.vue'

import store from './store'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/photos/:id',
    component: PhotoDetail,
    props: true
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  },
  {
    path: '/500',
    component: SystemError
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  mode: 'history',
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router

```

###ローディング
####ローダーコンポーネント作成

```resources/js/components/Loader.vue
<template>
  <div class="loader">
    <p class="loading__text">
      <slot>Loading...</slot>
    </p>
    <div class="loader__item loader__item--heart"><div></div></div>
  </div>
</template>
```

###サクセスメッセージ
####メッセージストア作成

```resources/js/store/message.js
const state = {
  content: ''
}

const mutations = {
  setContent (state, { content, timeout }) {
    state.content = content

    if (typeof timeout === 'undefined') {
      timeout = 3000
    }

    setTimeout(() => (state.content = ''), timeout)
  }
}

export default {
  namespaced: true,
  state,
  mutations
}
```

```resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'
import error from './error'
import message from './message'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error,
    message
  }
})

export default store

```

####メッセージコンポーネント

```resources/js/components/Message.vue
<template>
  <div class="message" v-show="message">
    {{ message }}
  </div>
</template>

<script>
  import { mapState } from 'vuex'

  export default {
    computed: {
      ...mapState({
        message: state => state.message.content
      })
    }
  }
</script>
```

####ルートコンポーネント

```resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <Message /> <!-- ★ 追加 -->
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Message from './components/Message.vue' // ★ 追加
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { INTERNAL_SERVER_ERROR } from './util'
export default {
  components: {
    Message, // ★ 追加
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

```

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-10

##[(11) 写真一覧取得API](https://www.hypertextcandy.com/vue-laravel-tutorial-list-photos/)
###テストコード
####ファクトリ

```terminal:Terminal
$ php artisan make:factory PhotoFactory
```

```database/factories/PhotoFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use Faker\Generator as Faker;
use Illuminate\Support\Str;

$factory->define(App\Photo::class, function (Faker $faker) {
    return [
        'id' => Str::random(12),
        'user_id' => fn() => factory(App\User::class)->create()->id,
        'filename' => Str::random(12) . '.jpg',
        'created_at' => $faker->dateTime(),
        'updated_at' => $faker->dateTime(),
    ];
});
```

####テストケース

```terminal:Terminal
$ php artisan make:test PhotoListApiTest
```

```tests/Feature/PhotoListApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

class PhotoListApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        // 5つの写真データを生成する
        factory(Photo::class, 5)->create();

        $response = $this->json('GET', route('photo.index'));

        // 生成した写真データを作成日降順で取得
        $photos = Photo::with(['owner'])->orderBy('created_at', 'desc')->get();

        // data項目の期待値
        $expected_data = $photos->map(function ($photo) {
            return [
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
            ];
        })
        ->all();

        $response->assertStatus(200)
            // レスポンスJSONのdata項目に含まれる要素が5つであること
            ->assertJsonCount(5, 'data')
            // レスポンスJSONのdata項目が期待値と合致すること
            ->assertJsonFragment([
                "data" => $expected_data,
            ]);
    }
}
```

###API の実装
####ルーティング

```routes/api.php
// 写真一覧
Route::get('/photos', 'PhotoController@index')->name('photo.index');
```

####Photo モデル

```app/Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 */
public function owner()
{
    return $this->belongsTo('App\User', 'user_id', 'id', 'users');
}

/**
 * アクセサ - url
 * @return string
 */
public function getUrlAttribute()
{
    return Storage::cloud()->url($this->attributes['filename']);
}

/** JSONに含める属性 */
protected $appends = [
    'url',
];
```

####User モデル

```app/User.php
protected $visible = [
    'name',
];
```

####コントローラー
最終的に以下のようになります。クラウドを利用していないので、チュートリアルとは少し異なります。

```app/Http/Controllers/Auth/PhotoController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePhoto;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class PhotoController extends Controller
{
    public function __construct()
    {
        // 認証が必要
        $this->middleware('auth')->except(['index', 'download']);
    }

    /**
     * 写真投稿
     * @param StorePhoto $request
     * @return \Illuminate\Http\Response
     */
    public function create(StorePhoto $request)
    {
        // 投稿写真の拡張子を取得する
        $extension = $request->photo->extension();

        $photo = new Photo();

        // インスタンス生成時に割り振られたランダムなID値と
        // 本来の拡張子を組み合わせてファイル名とする
        $photo->filename = $photo->id . '.' . $extension;

        // storage/app/public配下に保存する
        //Storage::putFileAs('photos', $photo->filename, 'public');
        $request->photo->storeAs('photos', $photo->filename, 'public');

        // データベースエラー時にファイル削除を行うため
        // トランザクションを利用する
        DB::beginTransaction();

        try {
            Auth::user()->photos()->save($photo);
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();
            // DBとの不整合を避けるためアップロードしたファイルを削除
            Storage::disk()->delete($photo->filename);
            throw $exception;
        }

        // リソースの新規作成なので
        // レスポンスコードは201(CREATED)を返却する
        return response($photo, 201);
    }

    /**
     * 写真一覧
     */
    public function index()
    {
        $photos = Photo::with(['owner'])
            ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

        return $photos;
    }

    /**
     * 写真ダウンロード
     * @param Photo $photo
     * @return \Illuminate\Http\Response
     */
    public function download(Photo $photo)
    {
        // 写真の存在チェック
        if (! Storage::exists($photo->filename)) {
            abort(404);
        }

        $disposition = 'attachment; filename="' . $photo->filename . '"';
        $headers = [
            'Content-Type' => 'application/octet-stream',
            'Content-Disposition' => $disposition,
        ];

        return response(Storage::get($photo->filename), 200, $headers);
    }
}
```

####テスト実行

```terminal:Terminal
$ php artisan test --testdox

# 実行結果
Photo List Api (Tests\Feature\PhotoListApi)
 ✘ Should 正しい構造のJSONを返却する
   │
   │ Error: Class 'App\Storage' not found
   │
   │ C:\xampp\htdocs\vuesplash\app\Photo.php:70
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:473
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:1442
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:386
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:365
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Model.php:1633
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoListApiTest.php:34
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Support\Collection.php:638
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Collection.php:277
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoListApiTest.php:39
   │
```

ここでエラーが出ました。
`app\Photo.php`の70行目、`return Storage::cloud()->url($this->attributes['filename']);`がエラーの原因のようです。あとで修正します。

###ダウンロードリンク
####ルート定義

```routes/web.php
// 写真ダウンロード
Route::get('/photos/{photo}/download', 'PhotoController@download');
```

チュートリアルはここまでですので、先程のエラーを解消します。

###エラー解消のために試したこと
`app\Photo.php`に以下のようにします。
参考：https://readouble.com/laravel/7.x/ja/filesystem.html?header

```app\Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage; // ★追加

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url($this->attributes['filename']); // ★変更
    }

    /** JSONに含める属性 */
    protected $appends = [
      'url',
    ];
}

```

次にシンボリックリンクを張るには、storage:link Artisanコマンドを使用します。

```terminal:Terminal
$ php artisan storage:link
```

LaravelではStorage/app/public内に保存しますが、画像を読み込むフォルダはpublic/storageファルダになります。
**保存:storage/app/public**
**読込:public/storage**
これを解消するために、ファイルにパスを書いて保存してから、上記のコマンドを実行します。

しかし、'/photos/DBに存在する写真ID/download'にアクセスしても404エラーが出ていて、デバックもしづらいので次に進みます。

ここまでは下記で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-11

##[(12) 写真一覧ページ](https://www.hypertextcandy.com/vue-laravel-tutorial-list-photos-part-2/)
###Photo コンポーネント
####写真の表示

```resources/js/components/Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <img
        class="photo__image"
        :src="item.url"
        :alt="`Photo by ${item.owner.name}`"
      >
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
      <div class="photo__controls">
        <button
          class="photo__action photo__action--like"
          title="Like photo"
        >
          <i class="icon ion-md-heart"></i>12
        </button>
        <a
          class="photo__action"
          title="Download photo"
          @click.stop
          :href="`/photos/${item.id}/download`"
        >
          <i class="icon ion-md-arrow-round-down"></i>
        </a>
      </div>
      <div class="photo__username">
        {{ item.owner.name }}
      </div>
    </RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  }
}
</script>

```

###PhotoList コンポーネント

```resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
      />
    </div>
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'

export default {
  components: {
    Photo
  },
  data () {
    return {
      photos: []
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get('/api/photos')

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photos = response.data.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>

```

###ページネーション
####ルート定義

```resources/js/router.js
{
  path: '/',
  component: PhotoList,
  props: route => {
    const page = route.query.page
    return { page: /^[1-9][0-9]*$/.test(page) ? page * 1 : 1 }
  }
}

```

####Pagination コンポーネント

```resources/js/components/Pagination.vue
<template>
  <div class="pagination">
    <RouterLink
      v-if="! isFirstPage"
      :to="`/?page=${currentPage - 1}`"
      class="button"
    >&laquo; prev</RouterLink>
    <RouterLink
      v-if="! isLastPage"
      :to="`/?page=${currentPage + 1}`"
      class="button"
    >next &raquo;</RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    currentPage: {
      type: Number,
      required: true
    },
    lastPage: {
      type: Number,
      required: true
    }
  },
  computed: {
    isFirstPage () {
      return this.currentPage === 1
    },
    isLastPage () {
      return this.currentPage === this.lastPage
    }
  }
}
</script>

```

####PhotoList コンポーネント

```resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
      />
    </div>
    <Pagination :current-page="currentPage" :last-page="lastPage" />
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'
import Pagination from '../components/Pagination.vue'

export default {
  components: {
    Photo,
    Pagination
  },
  props: {
    page: {
      type: Number,
      required: false,
      default: 1
    }
  },
  data () {
    return {
      photos: [],
      currentPage: 0,
      lastPage: 0
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get(`/api/photos/?page=${this.page}`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photos = response.data.data
      this.currentPage = response.data.current_page
      this.lastPage = response.data.last_page
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>
```

####1ページあたりの項目数を制御する

```app/Photo.php
protected $perPage = 15;
```

####ページ遷移時にページ先頭を表示

```resources/js/router.js
const router = new VueRouter({
  mode: 'history',
  scrollBehavior () {
    return { x: 0, y: 0 }
  },
  routes
})
```

チュートリアルはここまでです。

###前章のエラーを解決する
具体的なファイルのパスを確認します。
ファイルのパスは下記のようになっているのが正しい状態です。
※`E2V4x-Qusyka@はランダムに生成されたファイル名です。

**保存:storage/app/public/photos/E2V4x-Qusyka.jpeg**
**読込:public/storage/photos/E2V4x-Qusyka.jpeg**

しかし、現状のパスは以下のようになっていて、`/photos`が抜けています。

**読込:public/storage/E2V4x-Qusyka.jpeg**

下記のようにファイルを修正します。

```app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真ダウンロード
 * @param Photo $photo
 * @return \Illuminate\Http\Response
 */
public function download(Photo $photo)
{
    // 写真の存在チェック
    //if (! Storage::exists($photo->filename)) {
    //    abort(404);
    //}
    $filePath = 'public/photos/' . $photo->filename;
    $mimeType = Storage::mimeType($filePath);
    $disposition = 'attachment; filename="' . $photo->filename . '"';
    $headers = [
        'Content-Type' => $mimeType,
        'Content-Disposition' => $disposition,
    ];
    return Storage::download($filePath, $photo->filename, $headers);
}
```

これで'public/photos/E2V4x-Qusyka.jpeg'でダウンロードできるようになりました。
また、モデルも以下のように変更しました。

```app/User.php
<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /** JSONに含める属性 */
    protected $visible = [
        'name',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    /**
     * リレーションシップ - photosテーブル
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function photos()
    {
        return $this->hasMany('App\Photo');
    }
}
```

```app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** JSONに含める属性 */
    protected $visible = [
        'id', 'owner', 'url',
    ];

    /** JSONに含める属性 */
    protected $appends = [
        'url',
    ];

    /** 1ページあたりのアイテム数 */
    protected $perPage = 15;

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url($this->attributes['filename']);
    }
}
```

これでもう1度テスト実行すると、成功しました。

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Photo List Api (Tests\Feature\PhotoListApi)
 ✔ Should 正しい構造のJSONを返却する
```

フォト一覧がまだうまく表示されていませんが、次に進みます。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-12

##[(13) 写真詳細ページ](https://www.hypertextcandy.com/vue-laravel-tutorial-photo-detail/)
###Web API
####テスト

```terminal:Terminal
$ php artisan make:test PhotoDetailApiTest
```

```tests/Feature/PhotoDetailApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PhotoDetailApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        factory(Photo::class)->create();
        $photo = Photo::first();

        $response = $this->json('GET', route('photo.show', [
            'id' => $photo->id,
        ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
            ]);
    }
}
```

####ルート定義

```routes/api.php
// 写真詳細
Route::get('/photos/{id}', 'PhotoController@show')->name('photo.show');
```

####コントローラー

```app/Http/Controllers/Auth/PhotoController.php
public function __construct()
{
    // 認証が必要
    $this->middleware('auth')->except(['index', 'download', 'show']);
}
```

```app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真詳細
 * @param string $id
 * @return Photo
 */
public function show(string $id)
{
    $photo = Photo::where('id', $id)->with(['owner'])->first();

    return $photo ?? abort(404);
}
```

####テスト実行

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✔ Should 正しい構造のJSONを返却する
```

###フロントエンド
####PhotoDetail コンポーネント

```resources/js/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button class="button button--like" title="Like photo">
        <i class="icon ion-md-heart"></i>12
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
    </div>
  </div>
</template>

<script>
import { OK } from '../util'

export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo = response.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>
```

チュートリアルはここまでです。

###写真を表示させる
具体的なAPIを確認してみます。`E2V4x-Qusyka`は環境によって変わります。
http://localhost:3000/api/photos/E2V4x-Qusyka

```api.json
{
  "id":"E2V4x-Qusyka",
  "url":"\/storage\/E2V4x-Qusyka.jpeg",
  "owner":{
    "name":"\u30c6\u30b9\u30c8\u3093"
  }
}
```

すると上記のようなjsonファイルが取得できました。
urlが本来ならば`storage/photos/E2V4x-Qusyka.jpeg`となるべきところが、`/storage/E2V4x-Qusyka.jpeg`となっているので、画像が読み込めていないことが分かります。
Photoモデルのurlを取得する部分は以下のように変更します。

```
/**
 * アクセサ - url
 * @return string
 */
public function getUrlAttribute()
{
    return Storage::url('photos/' . $this->attributes['filename']);
}
```

これで`storage/photos/E2V4x-Qusyka.jpeg`となり、画像が表示されるようになりました。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-13

##[(14) コメント投稿機能](https://www.hypertextcandy.com/vue-laravel-tutorial-add-comment/)
###Web API
####テスト
#####ファクトリ

```terminal:Terminal
$ php artisan make:factory CommentFactory
```

```database/factories/CommentFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Model;
use Faker\Generator as Faker;

$factory->define(Model::class, function (Faker $faker) {
    return [
        'content' => substr($faker->text, 0, 500),
        'user_id' => fn() => factory(App\User::class)->create()->id,
    ];
});
```

#####写真詳細取得テストケース

```tests/Feature/PhotoDetailApiTest.php
<?php

namespace Tests\Feature;

use App\Comment;
use App\Photo;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PhotoDetailApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        factory(Photo::class)->create()->each(function ($photo) {
            $photo->comments()->saveMany(factory(Comment::class, 3)->make());
        });
        $photo = Photo::first();

        $response = $this->json('GET', route('photo.show', [
            'id' => $photo->id,
        ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
                'comments' => $photo->comments
                    ->sortByDesc('id')
                    ->map(function ($comment) {
                        return [
                            'author' => [
                                'name' => $comment->author->name,
                            ],
                            'content' => $comment->content,
                        ];
                    })
                    ->all(),
            ]);
    }
}
```

#####コメント投稿テストケース

```terminal:Terminal
$ php artisan make:factory CommentFactory
```

```tests/Feature/AddCommentApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class AddCommentApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_コメントを追加できる()
    {
        factory(Photo::class)->create();
        $photo = Photo::first();

        $content = 'sample content';

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.comment', [
                'photo' => $photo->id,
            ]), compact('content'));

        $comments = $photo->comments()->get();

        $response->assertStatus(201)
            // JSONフォーマットが期待通りであること
            ->assertJsonFragment([
                "author" => [
                    "name" => $this->user->name,
                ],
                "content" => $content,
            ]);

        // DBにコメントが1件登録されていること
        $this->assertEquals(1, $comments->count());
        // 内容がAPIでリクエストしたものであること
        $this->assertEquals($content, $comments[0]->content);
    }
}
```

####ルート定義

```routes/api.php
// コメント
Route::post('/photos/{photo}/comments', 'PhotoController@addComment')->name('photo.comment');
```

####モデルクラス
#####Comment

```terminal:Terminal
$ php artisan make:model Comment
```

```app/Comment.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    /** JSONに含める属性 */
    protected $visible = [
        'author', 'content',
    ];

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function author()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }
}
```

#####Photo

```app/Photo.php
/**
 * リレーションシップ - commentsテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function comments()
{
    return $this->hasMany('App\Comment')->orderBy('id', 'desc');
}
```

```app/Photo.php
/** JSONに含める属性 */
protected $visible = [
    'id', 'owner', 'url', 'comments',
];
```

####フォームリクエスト

```terminal:Terminal
$ php artisan make:request StoreComment
```

```app/Http/Requests/StoreComment.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreComment extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'content' => 'required|max:500',
        ];
    }
}
```



####コントローラー
#####コメント投稿

```app/Http/Controllers/Auth/PhotoController.php
use App\Comment;
use App\Http\Requests\StoreComment;

class PhotoController extends Controller
{
    /* 中略 */

    /**
     * コメント投稿
     * @param Photo $photo
     * @param StoreComment $request
     * @return \Illuminate\Http\Response
     */
    public function addComment(Photo $photo, StoreComment $request)
    {
        $comment = new Comment();
        $comment->content = $request->get('content');
        $comment->user_id = Auth::user()->id;
        $photo->comments()->save($comment);

        // authorリレーションをロードするためにコメントを取得しなおす
        $new_comment = Comment::where('id', $comment->id)->with('author')->first();

        return response($new_comment, 201);
    }
}
```

テスト実行

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
   │
   │ Expected status code 201 but received 500.
   │ Failed asserting that 201 is identical to 500.
```

コメント投稿のテストケースがエラーが起きて失敗しました。気にせず次に進みます。

#####写真詳細

```app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真詳細
 * @param string $id
 * @return Photo
 */
public function show(string $id)
{
    $photo = Photo::where('id', $id)
        ->with(['owner', 'comments.author'])->first();

    return $photo ?? abort(404);
}
```

テスト実行

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✘ Should 正しい構造のJSONを返却する
```

またもやテストケースがエラーが起きて失敗しましたが、気にせず次に進みます。

###フロントエンド
####コメントを投稿する
PhotoDetail.vueは最終的に以下のようになります。

```resources/js/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button class="button button--like" title="Like photo">
        <i class="icon ion-md-heart"></i>12
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
      <ul v-if="photo.comments.length > 0" class="photo-detail__comments">
        <li
          v-for="comment in photo.comments"
          :key="comment.content"
          class="photo-detail__commentItem"
        >
          <p class="photo-detail__commentBody">
            {{ comment.content }}
          </p>
          <p class="photo-detail__commentInfo">
            {{ comment.author.name }}
          </p>
        </li>
      </ul>
      <p v-else>No comments yet.</p>
      <form v-if="isLogin" @submit.prevent="addComment" class="form">
        <div v-if="commentErrors" class="errors">
          <ul v-if="commentErrors.content">
            <li v-for="msg in commentErrors.content" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <textarea class="form__item" v-model="commentContent"></textarea>
        <div class="form__button">
          <button type="submit" class="button button--inverse">submit comment</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'
export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false,
      commentContent: '',
      commentErrors: null
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo = response.data
    },
    async addComment () {
      const response = await axios.post(`/api/photos/${this.id}/comments`, {
        content: this.commentContent
      })
      // バリデーションエラー
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.commentErrors = response.data.errors
        return false
      }
      this.commentContent = ''
      // エラーメッセージをクリア
      this.commentErrors = null
      // その他のエラー
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo.comments = [
        response.data,
        ...this.photo.comments
      ]
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>

```

###エラー修正編
試しに「乾杯！」とコメントしたところ、以下のエラーが出ました。

```
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'content' in 'field list' (SQL: insert into `comments` (`content`, `user_id`, `photo_id`, `updated_at`, `created_at`) values (乾杯！, 1, E2V4x-Qusyka, 2020-07-19 09:49:28, 2020-07-19 09:49:28))
```

コメントテーブルを確認します。

```XXXX_XX_XX_XXXXXX_create_comments_table.php
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }
```

コメントを保存するテーブルが抜けていたので追加します。まずはMigrationファイルを作成します。

```terminal:Terminal
$ php artisan make:migration comments_table_add_column
```

Migrationに変更したい定義を記述します。

```XXXX_XX_XX_XXXXXX_create_comments_table_add_column.php
    public function up()
    {
        // comment テーブル定義を変更する
        Schema::table('comments', function (Blueprint $table){

            // 新規カラムを追加する
            $table->text('content');
        });
    }
```

作成したMigrationを実行してテーブル定義を変更します。

```terminal:Terminal
$ php artisan migrate
```

これでもう1度テストを実行します。

```terminal:Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✘ Should 正しい構造のJSONを返却する
```

テストではまたもやエラーが出ましたが、画面上はコメントができるようになりましたのでよしとします。

※phpMyAdminからテーブルを削除するとテストも成功したので、箇条書きで簡単な手順を残しておきます。

- migrationsテーブルに入り、XXXX_XX_XX_XXXXXX_create_comments_tableとXXXX_XX_XX_XXXXXX_create_comments_table_add_columnを削除し、更にcommentsテーブル自体も削除します。
- XXXX_XX_XX_XXXXXX_create_comments_table_add_column.phpのファイルも削除します。
- XXXX_XX_XX_XXXXXX_create_comments_table.phpに`$table->text('content');`を適切な行に挿入します。
- これで`php artisan migrate`を実行すると、新しくcommentsテーブルを作ることができます。
- `php artisan test --testdox`を実行すると、テストがうまくいきました。

ここまでは以下にファイルがあります。
https://github.com/neneta0921/vuesplash/pull/new/ch-14



##[(15) いいね機能](https://www.hypertextcandy.com/vue-laravel-tutorial-likes/)
###いいね機能 API
####テストコード

```terminal:Terminal
$ php artisan make:test LikeApiTest
```

```tests/Feature/LikeApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class LikeApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        $this->user = factory(User::class)->create();

        factory(Photo::class)->create();
        $this->photo = Photo::first();
    }

    /**
     * @test
     */
    public function should_いいねを追加できる()
    {
        $response = $this->actingAs($this->user)
            ->json('PUT', route('photo.like', [
                'id' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_2回同じ写真にいいねしても1個しかいいねがつかない()
    {
        $param = ['id' => $this->photo->id];
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_いいねを解除できる()
    {
        $this->photo->likes()->attach($this->user->id);

        $response = $this->actingAs($this->user)
            ->json('DELETE', route('photo.like', [
                'id' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(0, $this->photo->likes()->count());
    }
}
```

####ルート定義

```routes/api.php
// いいね
Route::put('/photos/{id}/like', 'PhotoController@like')->name('photo.like');

// いいね解除
Route::delete('/photos/{id}/like', 'PhotoController@unlike');
```

####モデルクラス

```app/Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
 */
public function likes()
{
    return $this->belongsToMany('App\User', 'likes')->withTimestamps();
}
```

####コントローラー

```app/Http/Controllers/PhotoController.php
/**
 * いいね
 * @param string $id
 * @return array
 */
public function like(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);
    $photo->likes()->attach(Auth::user()->id);

    return ["photo_id" => $id];
}

/**
 * いいね解除
 * @param string $id
 * @return array
 */
public function unlike(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);

    return ["photo_id" => $id];
}
```

####テスト実行

```terminal:Terminal
$php artisan test --testdox

#実行結果
Like Api (Tests\Feature\LikeApi)
 ✔ Should いいねを追加できる
 ✔ Should 2回同じ写真にいいねしても1個しかいいねがつかない
 ✔ Should いいねを解除できる
```

テストが成功したので次に進みます。

###写真一覧・詳細取得 API
####テストコード
PhotoListApiTest および PhotoDetailApiTest のテストメソッド内で、 assertJsonFragment の引数の期待値配列に以下の2項目を追加します。

```php
'liked_by_user' => false,
'likes_count' => 0,
```

####モデルクラス
app/Photo.phpは最終的に以下のようになります。

```app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth; // ★ 追記
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** JSONに含める属性 */
    protected $visible = [
        'id', 'owner', 'url', 'comments', 'likes_count', 'liked_by_user',
    ];

    /** JSONに含める属性 */
    protected $appends = [
        'url', 'likes_count', 'liked_by_user',
    ];

    /** 1ページあたりのアイテム数 */
    protected $perPage = 15;

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url('photos/' . $this->attributes['filename']);
    }

    /**
     * アクセサ - likes_count
     * @return int
     */
    public function getLikesCountAttribute()
    {
        return $this->likes->count();
    }

    /**
     * アクセサ - liked_by_user
     * @return boolean
     */
    public function getLikedByUserAttribute()
    {
        if (Auth::guest()) {
            return false;
        }

        return $this->likes->contains(function ($user) {
            return $user->id === Auth::user()->id;
        });
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * リレーションシップ - commentsテーブル
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function comments()
    {
        return $this->hasMany('App\Comment')->orderBy('id', 'desc');
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
     */
    public function likes()
    {
        return $this->belongsToMany('App\User', 'likes')->withTimestamps();
    }
}
```

####コントローラー

#####写真一覧
メソッドを下記のように変更します。

```app/Http/Controllers/PhotoController.php
public function index()
{
    $photos = Photo::with(['owner', 'likes'])
        ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

    return $photos;
}
```

#####写真詳細
メソッドを下記のように変更します。

```app/Http/Controllers/PhotoController.php
public function show(string $id)
{
    $photo = Photo::where('id', $id)
        ->with(['owner', 'comments.author', 'likes'])->first();

    return $photo ?? abort(404);
}
```

####テスト実行

```terminal:Terminal
$php artisan test --testdox

#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✔ Should 正しい構造のJSONを返却する

Photo List Api (Tests\Feature\PhotoListApi)
 ✔ Should 正しい構造のJSONを返却する
```

###写真一覧ページ
####Photo コンポーネント

```resources/js/componets/Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <img
        class="photo__image"
        :src="item.url"
        :alt="`Photo by ${item.owner.name}`"
      >
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
      <div class="photo__controls">
        <button
          class="photo__action photo__action--like"
          :class="{ 'photo__action--liked': item.liked_by_user }"
          title="Like photo"
          @click.prevent="like"
        >
          <i class="icon ion-md-heart"></i>{{ item.likes_count }}
        </button>
        <a
          class="photo__action"
          title="Download photo"
          @click.stop
          :href="`/photos/${item.id}/download`"
        >
          <i class="icon ion-md-arrow-round-down"></i>
        </a>
      </div>
      <div class="photo__username">
        {{ item.owner.name }}
      </div>
    </RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  },
  methods: {
    like () {
      this.$emit('like', {
        id: this.item.id,
        liked: this.item.liked_by_user
      })
    }
  }
}
</script>
```

####PhotoList コンポーネント

```resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
        @like="onLikeClick"
      />
    </div>
    <Pagination :current-page="currentPage" :last-page="lastPage" />
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'
import Pagination from '../components/Pagination.vue'
export default {
  components: {
    Photo,
    Pagination
  },
  props: {
    page: {
      type: Number,
      required: false,
      default: 1
    }
  },
  data () {
    return {
      photos: [],
      currentPage: 0,
      lastPage: 0
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get(`/api/photos/?page=${this.page}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = response.data.data
      this.currentPage = response.data.current_page
      this.lastPage = response.data.last_page
    },
    onLikeClick ({ id, liked }) {
      if (! this.$store.getters['auth/check']) {
        alert('いいね機能を使うにはログインしてください。')
        return false
      }
      if (liked) {
        this.unlike(id)
      } else {
        this.like(id)
      }
    },
    async like (id) {
      const response = await axios.put(`/api/photos/${id}/like`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = this.photos.map(photo => {
        if (photo.id === response.data.photo_id) {
          photo.likes_count += 1
          photo.liked_by_user = true
        }
        return photo
      })
    },
    async unlike (id) {
      const response = await axios.delete(`/api/photos/${id}/like`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = this.photos.map(photo => {
        if (photo.id === response.data.photo_id) {
          photo.likes_count -= 1
          photo.liked_by_user = false
        }
        return photo
      })
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>
```

###写真詳細ページ
####PhotoDetail コンポーネント

```resources/js/pages/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button
        class="button button--like"
        :class="{ 'button--liked': photo.liked_by_user }"
        title="Like photo"
        @click="onLikeClick"
      >
        <i class="icon ion-md-heart"></i>{{ photo.likes_count }}
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
      <ul v-if="photo.comments.length > 0" class="photo-detail__comments">
        <li
          v-for="comment in photo.comments"
          :key="comment.content"
          class="photo-detail__commentItem"
        >
          <p class="photo-detail__commentBody">
            {{ comment.content }}
          </p>
          <p class="photo-detail__commentInfo">
            {{ comment.author.name }}
          </p>
        </li>
      </ul>
      <p v-else>No comments yet.</p>
      <form v-if="isLogin" @submit.prevent="addComment" class="form">
        <div v-if="commentErrors" class="errors">
          <ul v-if="commentErrors.content">
            <li v-for="msg in commentErrors.content" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <textarea class="form__item" v-model="commentContent"></textarea>
        <div class="form__button">
          <button type="submit" class="button button--inverse">submit comment</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'
export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false,
      commentContent: '',
      commentErrors: null
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo = response.data
    },
    async addComment () {
      const response = await axios.post(`/api/photos/${this.id}/comments`, {
        content: this.commentContent
      })
      // バリデーションエラー
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.commentErrors = response.data.errors
        return false
      }
      this.commentContent = ''
      // エラーメッセージをクリア
      this.commentErrors = null
      // その他のエラー
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo.comments = [
        response.data,
        ...this.photo.comments
      ]
    },
    onLikeClick () {
      if (! this.isLogin) {
        alert('いいね機能を使うにはログインしてください。')
        return false
      }

      if (this.photo.liked_by_user) {
        this.unlike()
      } else {
        this.like()
      }
    },
    async like () {
      const response = await axios.put(`/api/photos/${this.id}/like`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo.likes_count = this.photo.likes_count + 1
      this.photo.liked_by_user = true
    },
    async unlike () {
      const response = await axios.delete(`/api/photos/${this.id}/like`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo.likes_count = this.photo.likes_count - 1
      this.photo.liked_by_user = false
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>
```

ここまでのソースコードは以下に置いてあります。
https://github.com/neneta0921/vuesplash/pull/new/ch-15

##[(16) エラーハンドリング Part.2](https://www.hypertextcandy.com/vue-laravel-tutorial-error-handling-part-2/)
###認証エラー
####トークンリフレッシュ API

```routes/api.php
// トークンリフレッシュ
Route::get('/reflesh-token', function (Illuminate\Http\Request $request) {
    $request->session()->regenerateToken();

    return response()->json();
});
```

####レスポンスコード定義

```resources/js/util.js
export const UNAUTHORIZED = 419
```

####ルートコンポーネント

```resources/js/App.vue
<script>
import Message from './components/Message.vue'
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { UNAUTHORIZED, INTERNAL_SERVER_ERROR } from './util'

export default {
  components: {
    Message,
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      async handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        } else if (val === UNAUTHORIZED) {
          // トークンをリフレッシュ
          await axios.get('/api/refresh-token')
          // ストアのuserをクリア
          this.$store.commit('auth/setUser', null)
          // ログイン画面へ
          this.$router.push('/login')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>
```

###404 エラー
####レスポンスコード定義

```resources/js/util.js
export const NOT_FOUND = 404
```

####エラーページ作成

```resources/js/pages/errors/NotFound.vue
<template>
  <p>お探しのページは見つかりませんでした。</p>
</template>
```

```resources/js/route.js
import NotFound from './pages/errors/NotFound.vue'

/* 中略 */

const routes = [
  /* 中略 */
  {
    path: '*',
    component: NotFound
  }
]
```

####ルートコンポーネント

```resources/js/App.vue
<script>
import Message from './components/Message.vue'
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { NOT_FOUND, UNAUTHORIZED, INTERNAL_SERVER_ERROR } from './util'

export default {
  components: {
    Message,
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      async handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        } else if (val === UNAUTHORIZED) {
          // トークンをリフレッシュ
          await axios.get('/api/refresh-token')
          // ストアのuserをクリア
          this.$store.commit('auth/setUser', null)
          // ログイン画面へ
          this.$router.push('/login')
        } else if (val === NOT_FOUND) {
          this.$router.push('/not-found')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

```

以上で終了です。
お疲れさまでした。
https://github.com/neneta0921/vuesplash/pull/new/ch-16

あとで誤字脱字や修正加筆予定です。