LaravelとVue.jsによるユーザー名、ユーザーメールアドレス、ユーザーアイコンを後から変更できるフォームの作り方。
CSSフレームワークはvuetifyを利用。
↓完成イメージ
前提条件
・Laravel8、vue2で解説
・Laravelの認証システム「sanctum」によるログインやユーザー登録が既に可能なものとする
・画像縮小のため変化ライブラリ「intervention image」を使う
・ログインできるユーザーは既に登録済みのものとする
・新しい画像がアップロードされるたびに古い画像は削除される
・画像投稿のためにパスは既に通している
↓
php artisan storage:link
コマンドをプロジェクトディレクトリですでに叩いている(Dockerならコンテナのプロジェクト内)
sanctumによる認証システム実装方法
ごめん、別の記事でまた解説するかも。
ほかのサイトだとこの人の記事が分かりやすい
【Laravel】Sanctomを使用してSPAでの会員登録を実装する
intervention image導入方法
画像を圧縮してくれたりするライブラリ。
ユーザーアイコンに必要がないほどのクソデカファイルを投稿て読み込みが遅くなったりするのを防ぐことが今回の目的。
Laravel アップロードされた画像のサイズを変換して保存する
↑使えるようにするにはこの記事参照。
パスの通し方もこの記事に書いてる。パスを通し忘れると、画像は表示できない。
データベース設計
Usersテーブルはこんな感じの設計。
使うのは、id
、name
、email
、password
、icon
だけ。
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。データベースに置いてどのレコードを更新するか特定するのに必要。
name
、email
→ログインしているユーザーの名前とメールアドレス
blobUrl
→画面上に表示するユーザーアイコンのURLが格納される
fileinfo
→選択されたアイコン画像ファイルが格納される
activeStatus
→ユーザーアイコン中央のカメラのボタンにマウスが乗った時にCSSを変化させるための変数
beforeUpdateName
→更新前のユーザー名。nametとごちゃまぜにすると、インプット入力時にリアルタイムで更新されていまう。
rules
はバリデーションルールを定義している。
必須とか、パスワードの部分は半角英数記号のみとか。
文字数制限に関するcounter254
とcounter50
は、ユーザー情報をデータベースからフォームに読み込んだ後に定義するのでいったん空にしている。
そうしないと、[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.counter254
とrules.counter50
これらは、data()の段階ではじめから定義していると、ユーザーが取得される前に走って文字数が数えられずにコンソールにエラーが出てしまう。
そのため、async awaitを使って、axios.get('/api/user')
によってユーザー情報が取得が完了した後に、改めて文字数のバリデーションを定義している。
beActive
とbeInActive
メソッドは、ユーザーアイコン中央のカメラボタンの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
に編集可能なカラムをちゃんと登録しているか確認。