LaravelとVue.jsによるユーザー名、ユーザーメールアドレス、ユーザーアイコンを後から変更できるフォームの作り方。

CSSフレームワークはvuetifyを利用。

↓完成イメージ

前提条件

・Laravel8、vue2で解説

・Laravelの認証システム「sanctum」によるログインやユーザー登録が既に可能なものとする

・画像縮小のため変化ライブラリ「intervention image」を使う

・ログインできるユーザーは既に登録済みのものとする

・新しい画像がアップロードされるたびに古い画像は削除される

・画像投稿のためにパスは既に通している

php artisan storage:linkコマンドをプロジェクトディレクトリですでに叩いている(Dockerならコンテナのプロジェクト内)

sanctumによる認証システム実装方法

ごめん、別の記事でまた解説するかも。

ほかのサイトだとこの人の記事が分かりやすい

【Laravel】Sanctomを使用してSPAでの会員登録を実装する

intervention image導入方法

画像を圧縮してくれたりするライブラリ。

ユーザーアイコンに必要がないほどのクソデカファイルを投稿て読み込みが遅くなったりするのを防ぐことが今回の目的。

Laravel アップロードされた画像のサイズを変換して保存する

↑使えるようにするにはこの記事参照。

パスの通し方もこの記事に書いてる。パスを通し忘れると、画像は表示できない。

データベース設計

Usersテーブルはこんな感じの設計。

使うのは、idnameemailpasswordicon

だけ。

iconには、ユーザーアイコンのファイル名(拡張子なし)がポストされてくる。

nullは許可されており、未設定の初期値はnull

編集を可能にするために後でLaravelのUserモデル’(User.php)でも許可登録を行う。

フロント(Vue.js)

コード全体はこんな感じ。(後で分割して解説する)

//Account.vue
<template>
    <div>
        <h1>アカウント</h1>
        <v-container>
            <v-row>
                <v-card width="400">
                    <v-img height="200px" src="https://cdn.pixabay.com/photo/2020/07/12/07/47/bee-5396362_1280.jpg">
                        <v-card-title class="white--text mt-8">
                            <v-avatar class="parent" size="150">
                                <img alt="user" class="userIcon" :src="blobUrl">
                                <label style="display:initial;">
                                    <v-icon :class="activeStatus" @mouseover="beActive" @mouseleave="beInActive"
                                        class="child" :icon="['fa', 'camera']">mdi-camera</v-icon>
                                    <input type="file" class="form-control-file " ref="file" @change="fileSelected"
                                        accept=".jpg,.jpeg,.png,.gif" style="display:none">
                                </label>
                            </v-avatar>
                            <p class="ml-3">
                                {{beforeUpdateName}}
                            </p>
                        </v-card-title>
                    </v-img>

                    <v-card-text>
                        <v-form ref="test_form">
                            <v-text-field v-model="name" prepend-icon="mdi-chevron-down" label="表示名"
                                :rules="[rules.required,rules.counter50]" counter="50" />
                            <v-text-field v-model="email" prepend-icon="mdi-at" label="ログインメールアドレス"
                                :rules="[rules.required,rules.email,rules.counter254]" v-bind:type="'email'"
                                counter="254" />
                            <v-btn @click="submit">更新</v-btn>
                        </v-form>

                    </v-card-text>
                </v-card>
            </v-row>
        </v-container>

        <div class="text-center ma-2">
            <v-snackbar v-model="centerSnackbar.snackbar" :timeout="centerSnackbar.timeout">
                {{ centerSnackbar.text }}
                <template v-slot:action="{ attrs }">
                    <v-btn color="pink" text v-bind="attrs" @click="closeCenterSnackbar">
                        閉じる
                    </v-btn>
                </template>
            </v-snackbar>
        </div>
    </div>
</template>
<script>
    export default {
        data: () => ({
            userId: null,
            name: null,
            email: null,
            blobUrl: null,
            fileinfo: null,
            activeStatus: 'inactive',
            beforeUpdateName: null,
            rules: {
                required: v => !!v || "必須",
                email: v => {
                    const pattern =
                        /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
                    return pattern.test(v) || 'メールアドレス形式で入力してください。'
                },
                password: v => {
                    const pattern = /^[ -~]+$/
                    return (
                        pattern.test(v) ||
                        v == "" ||
                        "半角英数記号だけが使えます。"
                    );
                },
                counter254:''||'',
                counter50:''||'',
            },
            centerSnackbar: {
                snackbar: false,
                text: "",
                timeout: 20000,
            },
        }),
        mounted() {
            this.getUserInfo()
        },
        methods: {
            getUserInfo:async function() {
                await axios.get('/api/user')
                    .then(response => {
                        this.beforeUpdateName = response.data.name //変更前のユーザー名
                        this.userId = response.data.id
                        this.name = response.data.name
                        this.email = response.data.email

                        if (response.data.icon) {
                            this.blobUrl = '/storage/icon/' + response.data.icon + '.jpg'
                        } else {
                            this.blobUrl = '/storage/default/user.jpg'
                        }
                        console.log('api完了')
                    })
                    .catch(error => {
                        this.$router.push('/login')
                    })
                this.rules.counter254 = value => value.length <= 254 || '文字数オーバーです。'
                this.rules.counter50 = value => value.length <= 50 || '文字数オーバーです。'
            },
            beActive() {
                this.activeStatus = 'active'
            },
            beInActive() {
                this.activeStatus = 'inactive'
            },
            fileSelected(event) {
                this.fileInfo = event.target.files[0] //選択されたファイルの情報を変数に格納

                if (event.target.files[0] != undefined) {
                    this.blobUrl = URL.createObjectURL(this.fileInfo) //選択されたファイルのURLを取得 
                } else {
                    this.blobUrl = ""
                }
            },
            submit() {
                const postData = new FormData()

                if (this.fileInfo) {
                    postData.append('file', this.fileInfo)
                    postData.append('extention', this.fileInfo.name.split('.').pop()) //拡張子を取得
                }

                postData.append('id', this.userId)
                postData.append('name', this.name)
                postData.append('email', this.email)

                axios.post('/api/user/update', postData).then(response => {
                    if (this.$refs.test_form.validate()) {
                        this.getUserInfo() //ユーザー情報更新
                        this.fileInfo = null

                        if (response.data == 'nothing') {
                            this.centerSnackbar.text = '変更はありませんでした。'
                        } else if (response.data == 'updated') {
                            this.centerSnackbar.text = '更新しました。'
                        }
                    } else {
                        this.centerSnackbar.text = '入力内容に不備があります。'
                    }
                    this.centerSnackbar.snackbar = true; //スナックバーを表示           
                });
            },
            closeCenterSnackbar() {
                this.centerSnackbar.snackbar = false
                this.centerSnackbar.text = ''
            },
        },
    }

</script>

<style scoped>
    .row {
        --bs-gutter-x: initial;
    }

    .parent {
        position: relative;
    }
    .parent .child {
        position: absolute;
        top: 50%;
        left: 50%;
        -ms-transform: translate(-50%, -50%);
        -webkit-transform: translate(-50%, -50%);
        transform: translate(-50%, -50%);
        margin: 0;
        /*余計な隙間を除く*/
        padding: 0;
        color: rgba(99, 99, 99, 0.721);
        /*余計な隙間を除く*/
        font-size: 30px;
        /*サイズ*/
    }
    .parent img {
        width: 100%;
    }
    .userIcon {
        width: 150;
        height: 150px;
        background: #ffffff;
        border-radius: 50%;
        object-fit: cover;
        margin-bottom: .7em;
    }
    .inactive {
        opacity: 0.8;
    }
</style>

それでは、分割して解説します。

フロントのhtml部分

//Account.vueのhtml部分
<template>
    <div>
        <h1>アカウント</h1>
        <v-container>
            <v-row>
                <v-card width="400">
                    <v-img height="200px" src="https://cdn.pixabay.com/photo/2020/07/12/07/47/bee-5396362_1280.jpg">
                        <v-card-title class="white--text mt-8">
                            <v-avatar class="parent" size="150">
                                <img alt="user" class="userIcon" :src="blobUrl">
                                <label style="display:initial;">
                                    <v-icon :class="activeStatus" @mouseover="beActive" @mouseleave="beInActive"
                                        class="child" :icon="['fa', 'camera']">mdi-camera</v-icon>
                                    <input type="file" class="form-control-file " ref="file" @change="fileSelected"
                                        accept=".jpg,.jpeg,.png,.gif" style="display:none">
                                </label>
                            </v-avatar>
                            <p class="ml-3">
                                {{beforeUpdateName}}
                            </p>
                        </v-card-title>
                    </v-img>

                    <v-card-text>
                        <v-form ref="test_form">
                            <v-text-field v-model="name" prepend-icon="mdi-chevron-down" label="表示名"
                                :rules="[rules.required,rules.counter50]" counter="50" />
                            <v-text-field v-model="email" prepend-icon="mdi-at" label="ログインメールアドレス"
                                :rules="[rules.required,rules.email,rules.counter254]" v-bind:type="'email'"
                                counter="254" />
                            <v-btn @click="submit">更新</v-btn>
                        </v-form>
                    </v-card-text>
                </v-card>
            </v-row>
        </v-container>

        <div class="text-center ma-2">
            <v-snackbar v-model="centerSnackbar.snackbar" :timeout="centerSnackbar.timeout">
                {{ centerSnackbar.text }}
                <template v-slot:action="{ attrs }">
                    <v-btn color="pink" text v-bind="attrs" @click="closeCenterSnackbar">
                        閉じる
                    </v-btn>
                </template>
            </v-snackbar>
        </div>
    </div>
</template>

画像ファイル選択と、表示入力、メールアドレスが入力できるフォームとなっている。

まずは現在登録されているユーザー情報が、フォームに初めから記載されるようになっている。

(名前とかメールアドレスとか設定済みアイコン)

ユーザーアイコンとファイル選択ボタンについて

//Account.vueのhtml部分のさらに一部
<v-avatar class="parent" size="150">
    <img alt="user" class="userIcon" :src="blobUrl">
    <label style="display:initial;">
        <v-icon :class="activeStatus" @mouseover="beActive" @mouseleave="beInActive"
            class="child" :icon="['fa', 'camera']">mdi-camera</v-icon>
        <input type="file" class="form-control-file " ref="file" @change="fileSelected"
            accept=".jpg,.jpeg,.png,.gif" style="display:none">
    </label>
</v-avatar>

メソッドの解説で触れるが、初期値は初期アバターが設定されている。

画像真ん中のカメラのアイコンをクリックすると、ファイルが選択可能になり選んだ画像が代わりに表示される。

:src="blobUrl”

blobUrlの部分のURLをメソッドで動的に書き換えることにより表示する画像が変わる仕組み

" @mouseover="beActive"メソッドと @mouseleave="beInActive"メソッドについて

→マウスカーソルがカメラのアイコンに載ったら、アイコンの色が変わる。外れたら元の色に戻る。というメソッドを発火させようとしている。

<input type="file" class="form-control-file " ref="file" @change="fileSelected"accept=".jpg,.jpeg,.png,.gif" style="display:none">

→ファイル選択メソッドfileSelectedを発火させるためのinput。

 デフォルトのファイル選択ボタンがダサいから、これはstyle="display:none"で非表示にしている。

名前の表示について

{{beforeUpdateName}}

は現在の名前を表している。

inputに使っているv-modelと同じnameにしてしまうと、

↑この赤丸部分も表示名の入力フォームと一緒にリアルタイムで書き換わってしまうので、あえて違う名前の変数にしている。

バリデーションについて

//Account.vueのhtml部分のさらに一部
<v-form ref="test_form">
    <v-text-field v-model="name" prepend-icon="mdi-chevron-down" label="表示名"
        :rules="[rules.required,rules.counter50]" counter="50" />
    <v-text-field v-model="email" prepend-icon="mdi-at" label="ログインメールアドレス"
        :rules="[rules.required,rules.email,rules.counter254]" v-bind:type="'email'"
        counter="254" />
    <v-btn @click="submit">更新</v-btn>
</v-form>

意図しない値がデータベースに投稿されないように警告を出したりする。

:rules="[rules.required,rules.counter50]"

:rules="[rules.required,rules.email,rules.counter254]

がそれにあたる。

ルール内容の定義方法は後ほど解説。

またメールアドレスに関しては、v-bind:type="'email'との記述があるが

vuetifyはこれを書かないとinputのタイプがテキストになってしまう。

そのままだとブラウザ側でメールアドレスの記憶とかができなくなるので、書いてあげると親切。

結果のメッセージ

//Account.vueのhtml部分のさらに一部
<div class="text-center ma-2">
    <v-snackbar v-model="centerSnackbar.snackbar" :timeout="centerSnackbar.timeout">
        {{ centerSnackbar.text }}
        <template v-slot:action="{ attrs }">
            <v-btn color="pink" text v-bind="attrs" @click="closeCenterSnackbar">
                閉じる
            </v-btn>
        </template>
    </v-snackbar>
</div>

更新できたのか、ダメだったのか。

結果をsnackbarという方法で表示する部分。

普段は何も表示されていないので、メソッドから任意のタイミングで発動させて表示内容も動的に書き換えることになる。

フロントのjavascript部分


//Account.vueのjavascriptの部分
<script>
    export default {
        data: () => ({
            userId: null,
            name: null,
            email: null,
            blobUrl: null,
            fileinfo: null,
            activeStatus: 'inactive',
            beforeUpdateName: null,
            rules: {
                required: v => !!v || "必須",
                email: v => {
                    const pattern =
                        /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
                    return pattern.test(v) || 'メールアドレス形式で入力してください。'
                },
                password: v => {
                    const pattern = /^[ -~]+$/
                    return (
                        pattern.test(v) ||
                        v == "" ||
                        "半角英数記号だけが使えます。"
                    );
                },
                                counter254:''||'',
                counter50:''||'',
            },
            centerSnackbar: {
                snackbar: false,
                text: "",
                timeout: 20000,
            },
        }),
        mounted() {
            this.getUserInfo()
        },
        methods: {
                        getUserInfo:async function() {
                await axios.get('/api/user')
                    .then(response => {
                        console.log('accaount.vueでユーザー情報取得')
                        console.log(response.data)

                        this.beforeUpdateName = response.data.name //変更前のユーザー名
                        this.userId = response.data.id
                        this.name = response.data.name
                        this.email = response.data.email

                        if (response.data.icon) {
                            this.blobUrl = '/storage/icon/' + response.data.icon + '.jpg'
                        } else {
                            this.blobUrl = '/storage/default/user.jpg'
                        }
                                                counter254:''||'',
                                counter50:''||'',
                    })
                    .catch(error => {
                        this.$router.push('/login')
                    })
                this.rules.counter254 = value => value.length <= 254 || '文字数オーバーです。'
                this.rules.counter50 = value => value.length <= 50 || '文字数オーバーです。'
            },
            beActive() {
                this.activeStatus = 'active'
            },
            beInActive() {
                this.activeStatus = 'inactive'
            },
            fileSelected(event) {
                this.fileInfo = event.target.files[0] //選択されたファイルの情報を変数に格納

                if (event.target.files[0] != undefined) {
                    this.blobUrl = URL.createObjectURL(this.fileInfo) //選択されたファイルのURLを取得 
                } else {
                    this.blobUrl = ""
                }
            },
            submit() {
                const postData = new FormData()

                if (this.fileInfo) {
                    postData.append('file', this.fileInfo)
                    postData.append('extention', this.fileInfo.name.split('.').pop()) //拡張子を取得
                }

                postData.append('id', this.userId)
                postData.append('name', this.name)
                postData.append('email', this.email)

                axios.post('/api/user/update', postData).then(response => {
                    if (this.$refs.test_form.validate()) {
                        this.getUserInfo() //ユーザー情報更新
                        this.fileInfo = null

                        if (response.data == 'nothing') {
                            this.centerSnackbar.text = '変更はありませんでした。'
                        } else if (response.data == 'updated') {
                            this.centerSnackbar.text = '更新しました。'
                        }
                    } else {
                        this.centerSnackbar.text = '入力内容に不備があります。'
                    }
                    this.centerSnackbar.snackbar = true; //スナックバーを表示           
                });
            },
            closeCenterSnackbar() {
                this.centerSnackbar.snackbar = false
                this.centerSnackbar.text = ''
            },
        },
    }

</script>

data部分の変数について

//Acccount.vueのjavascript部分のさらに一部
data: () => ({
            userId: null, //どのユーザーの情報を更新するか、ログインユーザーのIDが格納される
            name: null,//ロググインユーザーの名前
            email: null,
            blobUrl: null,
            fileinfo: null,
            activeStatus: 'inactive',
            beforeUpdateName: null,
            rules: {
                required: v => !!v || "必須",
                email: v => {
                    const pattern =
                        /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
                    return pattern.test(v) || 'メールアドレス形式で入力してください。'
                },
                password: v => {
                    const pattern = /^[ -~]+$/
                    return (
                        pattern.test(v) ||
                        v == "" ||
                        "半角英数記号だけが使えます。"
                    );
                },
                                counter254:''||'',//後から定義する
                counter50:''||'',//後から定義する

            },
            centerSnackbar: {
                snackbar: false,
                text: "",
                timeout: 20000,
            },
        }),

userId
→ログインしているユーザーのID。データベースに置いてどのレコードを更新するか特定するのに必要。


nameemail

→ログインしているユーザーの名前とメールアドレス


blobUrl

→画面上に表示するユーザーアイコンのURLが格納される


fileinfo
→選択されたアイコン画像ファイルが格納される


activeStatus
→ユーザーアイコン中央のカメラのボタンにマウスが乗った時にCSSを変化させるための変数


beforeUpdateName

→更新前のユーザー名。nametとごちゃまぜにすると、インプット入力時にリアルタイムで更新されていまう。


rulesはバリデーションルールを定義している。

必須とか、パスワードの部分は半角英数記号のみとか。

文字数制限に関するcounter254counter50は、ユーザー情報をデータベースからフォームに読み込んだ後に定義するのでいったん空にしている。

そうしないと、[Vue warn]: Error in beforeMount hook: "TypeError: Cannot read properties of undefined (reading 'length')”

とのエラーがコンソールに表示されてしまう。

mounted()について

ページ読み込み時に必ず通るフック。

//Acccount.vueのjavascript部分のさらに一部
mounted() {
            this.getUserInfo()
},

メソッドについて

//Acccount.vueのjavascript部分メソッド
methods: {
            getUserInfo:async function() {
                await axios.get('/api/user')
                    .then(response => {
                        this.beforeUpdateName = response.data.name //変更前のユーザー名
                        this.userId = response.data.id
                        this.name = response.data.name
                        this.email = response.data.email

                        if (response.data.icon) {
                            this.blobUrl = '/storage/icon/' + response.data.icon + '.jpg'
                        } else {
                            this.blobUrl = '/storage/default/user.jpg'//アイコン未設定なら初期画像を設定(自分でなんか画像用意して)
                        }
                    })
                    .catch(error => {
                        this.$router.push('/login')
                    })
                this.rules.counter254 = value => value.length <= 254 || '文字数オーバーです。'
                this.rules.counter50 = value => value.length <= 50 || '文字数オーバーです。'
            },
            beActive() {
                this.activeStatus = 'active'
            },
            beInActive() {
                this.activeStatus = 'inactive'
            },
            fileSelected(event) {
                this.fileInfo = event.target.files[0] //選択されたファイルの情報を変数に格納

                if (event.target.files[0] != undefined) {
                    this.blobUrl = URL.createObjectURL(this.fileInfo) //選択されたファイルのURLを取得 
                } else {
                    this.blobUrl = ""
                }
            },
            submit() {
                const postData = new FormData()

                if (this.fileInfo) {
                    postData.append('file', this.fileInfo)
                    postData.append('extention', this.fileInfo.name.split('.').pop()) //拡張子を取得
                }

                postData.append('id', this.userId)
                postData.append('name', this.name)
                postData.append('email', this.email)

                axios.post('/api/user/update', postData).then(response => {
                    if (this.$refs.test_form.validate()) {
                        this.getUserInfo() //ユーザー情報更新
                        this.fileInfo = null

                        if (response.data == 'nothing') {
                            this.centerSnackbar.text = '変更はありませんでした。'
                        } else if (response.data == 'updated') {
                            this.centerSnackbar.text = '更新しました。'
                        }
                    } else {
                        this.centerSnackbar.text = '入力内容に不備があります。'
                    }
                    this.centerSnackbar.snackbar = true; //スナックバーを表示           
                });
            },
            closeCenterSnackbar() {
                this.centerSnackbar.snackbar = false
                this.centerSnackbar.text = ''
            },
        },

getUserInfoメソッド

//Account.vueのjavascriptのメソッド部分のさらに一部
getUserInfo:async function() {
      await axios.get('/api/user')
          .then(response => {
              this.beforeUpdateName = response.data.name //変更前のユーザー名
              this.userId = response.data.id
              this.name = response.data.name
              this.email = response.data.email

              if (response.data.icon) {
                  this.blobUrl = '/storage/icon/' + response.data.icon + '.jpg'
              } else {
                  this.blobUrl = '/storage/default/user.jpg'//アイコン未設定なら初期画像を設定(自分でなんか画像用意して)
              }
          })
          .catch(error => {
              this.$router.push('/login')
          })
      this.rules.counter254 = value => value.length <= 254 || '文字数オーバーです。'
      this.rules.counter50 = value => value.length <= 50 || '文字数オーバーです。'
  },

現在ログインしているカレントユーザーの情報を取得するapiを呼び出してる。

ログインユーザーの情報が取得できなければ、未ログインとみなし、this.$router.push('/login')でログインフォームのページに移動させる。

ログインユーザーの情報取得できたら、まずはフォームにその内容が表示されるようにbeforeUpdateName, name,emailなどの変数を上書きする。

if (response.data.icon) { ・・・}

で条件分岐。

既にアイコンが設定されているのであれば、その画像を表示。

まだ設定されていなければ、デフォルト用の共通画像を表示する。

初期アイコンは自分で用意してな!

プロジェクトディレクトリ\storage\app\public\default\user.jpg

って名前で保存してくれ。画像探すのんどくさかったらこれを保存して。↓

さらにバリデーションルールに文字数の上限を決めるルールをここで初めて定義する。

rules.counter254rules.counter50
これらは、data()の段階ではじめから定義していると、ユーザーが取得される前に走って文字数が数えられずにコンソールにエラーが出てしまう。

そのため、async awaitを使って、axios.get('/api/user')によってユーザー情報が取得が完了した後に、改めて文字数のバリデーションを定義している。


beActivebeInActiveメソッドは、ユーザーアイコン中央のカメラボタンのCSSを変化させるだけの関数。

//Account.vueのjavascriptのメソッド部分のさらに一部
beActive() {
    this.activeStatus = 'active'
},
beInActive() {
    this.activeStatus = 'inactive'
},

fileSelectedは選択されたファイルをプレビューするために一時的なURLを発行したり、

データベースにポストするためにファイルを変数に収めたりしている。


submit関数はフォームに入力された情報や、選ばれた画像の情報を使って更新用apiを呼び出している。

//Account.vueのjavascriptのメソッド部分のさらに一部
submit() {
      const postData = new FormData()

      if (this.fileInfo) {
          postData.append('file', this.fileInfo)
          postData.append('extention', this.fileInfo.name.split('.').pop()) //拡張子を取得
      }

      postData.append('id', this.userId)
      postData.append('name', this.name)
      postData.append('email', this.email)

      axios.post('/api/user/update', postData).then(response => {
          if (this.$refs.test_form.validate()) {
              this.getUserInfo() //ユーザー情報更新
              this.fileInfo = null

              if (response.data == 'nothing') {
                  this.centerSnackbar.text = '変更はありませんでした。'
              } else if (response.data == 'updated') {
                  this.centerSnackbar.text = '更新しました。'
              }
          } else {
              this.centerSnackbar.text = '入力内容に不備があります。'
          }
          this.centerSnackbar.snackbar = true; //スナックバーを表示           
      });
  },

postData.append('extention', this.fileInfo.name.split('.').pop())

では、拡張子を取得している。拡張子の有無で、そもそもファイルが添付されているかを後々判断するためにこんなことをしている。

axios.post('/api/user/update', postData)

では、フォームにバリデーションルールに引っかかるやつがいなければユーザー情報と画像をフォームの内容で上書きする。

うまくいったらユーザー情報を更新して再取得する。

バリデーションルールを通過できたかどうかにかかわらず、任意のメッセージをスナックバーを表示する。


closeCenterSnackbarはスナックバーを閉じるためのメソッド。

//Account.vueのjavascriptのメソッド部分のさらに一部
closeCenterSnackbar() {
    this.centerSnackbar.snackbar = false
    this.centerSnackbar.text = ''
},

api(Laravel)

//api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserController;
use App\Http\Controllers\Api\Auth\RegisterController;
use App\Http\Controllers\Api\Auth\LoginController;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});//ログインユーザーの情報を取得するapi

//ユーザー登録api(今回は触れない)
Route::post('/userRegister', [RegisterController::class, 'userRegister']);

//ユーザー情報更新api
Route::post('/user/update', [UserController::class, 'update']);

//ログインとログアウトのapi(今回は触れない)
Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LoginController::class, 'logout']);

バックエンド(Laravel)

コントローラー

//UserController.php
<?php

namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Carbon\Carbon;
use Image;//画像変換ライブラリ「intervention image」を読み込み

class UserController extends Controller
{
    public function update(User $user,Request $request){
        $userRecord =  User::where('id', $request->id)->first();//ポストされてきたユーザーIDをもとに更新するレコードを検索
        $beforeUpdatedAt = $userRecord->updated_at;//更新前のupdated_atを記憶(更新があったかどうか判断するために後で利用)
        $fileName = substr(bin2hex(random_bytes(8)), 0, 8);//ランダムなファイル名を定義

        if($request->extention){
                        //ファイルの拡張子がポストされてきたら
            $oldIcon = $userRecord->icon;    
            $user->deleteOldIcon($oldIcon);//UserモデルのdeleteIconを呼び出して古いアイコン画像データを削除

                        //画像を最低限のサイズに変換
            Image::make($request->file)->resize(300, 300, function ($constraint) {
                $constraint->aspectRatio();
            })->save(storage_path(('app/public/icon/'. $fileName.'.jpg'), 100));   
            $userRecord->update(['icon' => $fileName,]);//新しい画像のファイル名をデータベースに登録
            $userRecord->touch();//更新があったことを明らかにするためにupdated_atカラムを更新            
        }

        //ユーザー名とemailを更新
        $result = $userRecord->fill($request->only([
            'name', 'email'
        ]))->update();//前回と変化がなければupdated_atカラムは更新されない。

        $afterUpdatedAt = $userRecord->updated_at; //更新後のupdated_at

        if($beforeUpdatedAt == $afterUpdatedAt ) {//updated_atに差分があるかをリターン
            return 'nothing';//何も変わらなかったので更新はなかったことをリターン
        }else{
            return 'updated';//更新があったことをリターン
        }        
    }
}

$fileName = substr(bin2hex(random_bytes(8)), 0, 8);

では、ランダムなファイル名を保存するファイルに付与する。

同じ値をカラムにも登録して、誰がどんなファイル名のアイコンを設定しているかをわかるようにしている。

画像を新しいデータに更新するたびにファイル名が変わるところがみそ。

変わらないとキャッシュを引っ張り続けて中々変更が反映されないことがある。

キャッシュを読み込まないようにフロント側で設定することもできるが、キャッシュを使わずに毎回画像を読み込むのもそれはそれで時間がかかるからやめたほうがいい。


$user->deleteOldIcon($oldIcon);

で古いユーザーアイコンを削除するメソッドをUserモデルから呼び出す。

ユーザーアイコンは新しくなるたびに別名で保存される。

そのため、古い不要なユーザーアイコンの画像データでサーバー内の容量がひっ迫されないようにしている。

ちなみに
削除→新しい画像のファイル名をデータベースに登録→新しい画像をアップロード
という順であることは結構重要。

新しい画像を登録・アップロードしてから削除すると、新しい方のアイコンを削除してしまう。

削除メソッドに関してはモデルで解説する。


画像変換ライブラリ「intervention image」によるアイコン縮小

//UserController.phpの一部

//画像を最低限のサイズに変換
  Image::make($request->file)->resize(300, 300, function ($constraint) {
      $constraint->aspectRatio();
  })->save(storage_path(('app/public/icon/'. $fileName.'.jpg'), 100));   

  $userRecord->update(['icon' => $fileName,]);//新しい画像のファイル名をデータベースに登録
  $userRecord->touch();//更新があったことを明らかにするためにupdated_atカラムを更新     

縦もしくは横は300pxを最高値とする。

コントローラー冒頭のuse Image;も忘れずに。

ここではImageという名前になっているが、

config/app.phpに何て名前でエイリアス登録をしたかによるから、自分の環境に合わせて。

↓記事の最初にもリンクしていたintervention imageの導入方法などの解説ページ

Laravel アップロードされた画像のサイズを変換して保存する

モデル

//User.php
<?php

namespace App\Models;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

use Illuminate\Support\Facades\Storage;//画像削除のために必要

class User extends Authenticatable 
{
    use HasFactory, Notifiable, HasApiTokens;
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var string[]
     */
    protected $fillable = [
        'name',,//書き換えられるように追加
        'email',,//書き換えられるように追加
        'password',,//書き換えられるように追加
        'icon',//書き換えられるように追加
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

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

    public function deleteOldIcon($oldIcon)
    {
        if($oldIcon){
            //古い画像を削除
            $file = 'public/icon/'.$oldIcon.'.jpg';
            Storage::delete($file);
        }
    }
}

protected $fillable

ここに編集可能なカラムをあらかじめ登録しておかないと、更新できない。

しかもなんでかはエラーでちゃんと教えてくれないからLaravelはたちが悪い。


deleteOldIconメソッドでは、新しい画像があるなら古い画像は削除してくれるメソッド。

    public function deleteOldIcon($oldIcon)
    {
        if($oldIcon){
            //古い画像を削除
            $file = 'public/icon/'.$oldIcon.'.jpg';
            Storage::delete($file);
        }
    }

ファイル冒頭にuse Illuminate\Support\Facades\Storage;も忘れずに。これがないとStorage::deleteは動かない。

$file = 'public/icon/'.$oldIcon.'.jpg';
のパスは、1文字でも間違えると削除できない。

拡張子が本当に必要なのか(データベースに拡張子付きの名前でそもそも保存している場合などは不要)など、消えない場合はよく確かめる必要がある。

トラブル対処法

画像が表示できない

初期アバターは、自分で画像を用意してください。

プロジェクトディレクトリ\storage\app\public\default\user.jpg

プロジェクトディレクトリ\storage\app\public\iconに画像はアップロードされるのに、ユーザーのアイコンが表示されないときはパスが通っていないかも。

php artisan storage:linkコマンドをプロジェクトディレクトリでたたいたか確認。(Dockerならコンテナのプロジェクト内)

詳しくは、記事冒頭にもリンクした

Laravel アップロードされた画像のサイズを変換して保存する

解説している。

api/user/updateの呼び出しに失敗する、投稿できない

User.phpの protected $fillableに編集可能なカラムをちゃんと登録しているか確認。

無制限に質問可能なプログラミングスクール!

万が一転職できない場合は、転職保障全額返金できるコースもあり!!

無制限のメンター質問対応

 

DMMウェブキャンプでプログラミングを学習しませんか?

独学より成長スピードをブーストさせましょう!

 

まずは無料相談から!

おすすめの記事