見出し画像

今回は2回にわけて写真複数枚投稿をlaravelとvue.jsを使って実装していきます。備忘録的に自分がポイントだと思うところを中心に書いていきます。

この記事では新規登録について話していきます。次の記事では更新処理についてです。長くなるかもと思い分けることにしました。

目次

  1. どこまで実装するの?
  2. 前提
  3. 大まかな実装の流れ
  4. テーブル作成、モデルファイル編集。
  5. コントローラー作成、ルート編集
  6. blade側
  7. コンポーネント側
  8. コントローラー側
  9. まとめ
  10. 感想

どこまで実装するの?

複数枚投稿が出来る。base64データをlaravel側でfileデータに変換し写真までのパスをデータベースに保存します。

バリデーションに引っ掛かって戻って来た時に、選択していた写真を表示します。

同じ写真を投稿出来ないようにします。

エラーメッセージ表示します。

前提

写真以外の投稿機能は実装されている前提で進めていきます。

大まかな実装の流れ

テーブル作成、モデルファイル編集。

コントローラー作成、ルート編集

コンポーネント側

コントローラー側

っていう感じで進めます。最後の2つがメインです。

テーブル作成、モデルファイル編集。

コマンドで作成しますが、-mオプションをつけてマイグレーションファイルも同時生成します。

php artisan make:model Image -m

マイグレーションファイル。itemのidを外部キーとして保存します。onDelete()オプションでitemが消えると、そのitemに紐づいていた写真も消えるようにしています。

 public function up()
   {
       Schema::create('images', function (Blueprint $table) {
           $table->id();
           $table->foreignId('item_id')->constrained('items')->onDelete('cascade');
           $table->string('image_path');
           $table->timestamps();
       });
   }

モデルファイルを編集。Item側はhas_manyです。

class Image extends Model
{
   protected $fillable = ['item_id', 'image_path'];

   public function item()
   {
       return $this->belongsTo('App\Item');
   }
}

Itemモデルの方

public function images()
{
   return $this->hasMany('App\Image');
}

最後はコマンド実行でデータベースに反映させます。

php artisan migrate
もしうまく行かなかったら下記を実行
php artisan migrate:fresh

コントローラー作成、ルート編集

コントローラーはitemコントローラーのstoreをそのまま使用します。既に投稿機能が実装できている前提なのでそのまま使用しましょう。

ルートも同じものを使用します(言う必要ないw)

blade側

バリデーションに引っかかった時の元のデータの保持とエラーメッセージを表示させるための情報を渡しています。

formにはenctype=”multipart/form-dataの記載も忘れないようにしましょう。

<image-create :old_images="{{ json_encode(Session::getOldInput()) }}" :errors= "{{ $errors }}"></image-create>

コンポーネント側

<template>
 <div class="mb-3">
   <div class="form-group row">
     <div class="container row my-5">
1      <div v-for="(image, index) in images" :key="index" class="mx-1">
2        <img :src="image" style="width: 100px; height: 100px;">
3        <input type="hidden" v-model="image.name">
         <div class="row col mx-auto" style="width: 100px;">
           <div class="mr-1">
4            <label :for="['edit_img_' + index]">編集</label>
5            <input type="file" @change="editImage(image, index, $event)" :id="['edit_img_' + index]" hidden>
           </div>
6          <div class="ml-1" @click="remove(index)">削除</div>
         </div>
       </div>
     </div>

     <label for="title" class="col-md-4 col-form-label text-md-right">画像<span class="ml-2" style="color: red; font-size: .7rem;">必須</span></label>
     <div class="col-md-6">
       <label class="text-center mb-0" for="title" style="cursor: pointer;">クリックで画像追加(複数枚可能)</label>
 7     <span v-if="image_errors" class="invalid-feedback" role="alert" style="display: block;">
         <strong>
           {{image_errors[0]}}
         </strong>
       </span>
 8     <input @change="selectImage" id="title" type="file" class="form-control" hidden>
 9     <input type="hidden" name="images" :value="images">
     </div>
   </div>
 </div>
</template>

<script>
export default {
 data() {
   return {
     images: [],
     image_errors: null
   }
 },
 props: ['old_images', 'errors'],         10
 created() { 
   if(this.errors != []) {
     this.image_errors = this.errors.images
   }
   if(this.old_images.images != null) {         11
     let base64 = this.old_images.images.split(/(?<!data:image\/(jpeg|png);base64),/);
     base64.forEach((ele) => {                   12
       if(ele != undefined && ele.slice(0,10) == 'data:image') {
         this.images.push(ele)
       }
     })
   }
 },
 methods: {
   selectImage(e) {      13
     this.createImage(e.target.files[0])
   },
   createImage(file) {        14
     if(file) {
       const reader = new FileReader();
       reader.onload = (e) => {
         if(this.images.indexOf(e.target.result) == -1) {
           this.images.push(e.target.result)
         }
       }
       reader.readAsDataURL(file)
     }
   },
   editImage(edit_image, i, e) {          15
     // 写真が変更されたら
     if(edit_image, i, e) {
       // 変更前の写真のbase64データを取得。validate前後で値が入っている部分が違う。
       // constだとifの中でしか使えなくなるので注意
       if(edit_image.thumbnail) {           16
         var before_base = edit_image
       }
       
       // 新しい写真データを元のデータに追加。入れ替え。
       edit_image = e.target.files[0]        17
       const reader = new FileReader();
       reader.onload = (e) => {
         if(this.images.indexOf(e.target.result) == -1) {       18
           // imagesの中に入っているbase64達を取り出し。imagesは送信する情報。
           this.images.forEach((element, index) => {          19
             // 取り出した値が、before_baseと一致しているか?
             // 送信する情報の中で変更するものを取得。
             if(element == before_base) {         20
               // 一致したら新しい写真のbase64に入れ替える。indexはindex番号を振る事。
               this.images.splice(index, 1, e.target.result)
             }
           });
         }  
       }
       reader.readAsDataURL(edit_image)
     }
   },
   remove(i) {      21
     this.images.splice(i, 1)
   }
 }
}
</script>

1. imagesに入っているものをeachで回して表示しています。editやdeleteなどで使用するためにindexも取得しています。

2.  写真を表示しています。:srcで写真の中身を指定。filereaderによって作られたもの。

3.  表示されている写真の情報を格納するところです。コントローラーには送りません。

4.  labelをクリックした際に編集する写真の情報を紐付けている事で、クリックした際にどの写真に対してのイベントなのかを判断出来ます。

5.  編集というボタンがクリックされたら、選択された写真情報を持ってeditImage()メソッドが動くようにしています。

6.  remove()を動かしています。写真のindex番号を引数に入れています。

7.  バリデーションのエラーメッセージを表示しています。v-ifで値がある時のみと条件分岐しています。

8.  クリックでselectImage()メソッドが動くようにしています。hiddenをつけて”写真が選択されていません”の表示を消しています。

9.  name属性を指定してformが送信された際にコントローラーに送信されるようにしています。:valueで飛ばす値を指定しています。

10.  バリデーションで戻って来た場合の処理です。選択されていた値の保持とエラーメッセージを取得します。

11.  this.old_images.imagesに値が入っているかをチェックしています。値がある場合はsplit()メソッドで正規表現を使い値を配列に格納しています。base64コードで飛んでくるのでsplit()を使用しています。

12.  eleの値が求めている値(写真のbase64コード)かをチェックします。一致した場合はimagesの中に追加していきます。

13. 8で設定した部分と繋がっています。選択された写真のデータを取得してcreateImage()を呼び出しています。

14.  選択された写真をFileReader()を使用してプレビュー表示できるようにしています。写真の値はimagesに代入しています。

15.  5で説明した部分と繋がっています。元の写真の情報と新しく追加された写真の情報が入っています。入れ換え作業です。

16.  元々の写真がある場合はvarで変数定義して新しく追加されたものと入れ替える時に再度利用します。

17.  新しく追加された写真を取得、定義していきます。

18. 新しく選択した写真が既にimagesの中に含まれていないかをindexOf()を使用して確認。indexOf()は存在しなかった場合に-1を返します。

19.  いよいよ入れ替え作業。this.imagesをeachで回して中身を取り出して確認していく。元の写真を見つける作業。

20. 16で定義した元の写真に一致するものがあればsplice()メソッドを使って配列の順番は変えずに中身だけ入れ替えていきます

21.  splice()を使ってindexを元に写真情報をimagesから削除している。

コントローラー側

前提として、itemが保存された後の話になります。

省略。。。

$item->save();

if($request->has('images')) {
   $images = [];
   // base64で飛んでくるので、その文字列で振り分け。下記のようにしてしまうとpngしか通らなくなる。
1  $image_list = explode(',', $request->images);
   foreach($image_list as $image) {
       // 値がある時のみ実行
       if($image != '') {
           
2          $fileData = base64_decode($image);

           // path_makeを呼び出しpathを取得。
3          $read_temp_path = $this->path_make($fileData);
           $extension = \File::extension($read_temp_path);

4          $result = $this->new_image($read_temp_path, $extension);
           $images = array_merge($images, $result);
       }
   }

5  $item->images()->saveMany($images);
   
   
   
  private function path_make($fileData)
{
   $tmpFilePath = sys_get_temp_dir() . '/' . Str::uuid()->toString();  3-1
   file_put_contents($tmpFilePath, $fileData);    3-2
   $tmpFile = new File($tmpFilePath);      

   $file = new UploadedFile(
       $tmpFile->getPathname(),
       $tmpFile->getFilename(),
       $tmpFile->getMimeType(),
       0,
       true
   );
   $path = $file->store('public');          3-3
   $extension = \File::extension($path);    3-4
   $read_temp_path = str_replace('public/', '/storage/', $path);   3-5

   return $read_temp_path;
}



private function new_image($read_temp_path, $extension)
{
   $images = [];     4-1

   $img = new Image();

   if($extension == 'png'){     4-2
       $read_temp_path = str_replace('.txt', '.png', $read_temp_path);
       $img->image_path = $read_temp_path;
       array_push($images, $img);
   } elseif($extension == 'jpeg') {
       $read_temp_path = str_replace('.txt', '.jpeg', $read_temp_path);
       $img->image_path = $read_temp_path;
       array_push($images, $img);
   } elseif($extension == 'jpg') {
       $read_temp_path = str_replace('.txt', '.jpg', $read_temp_path);
       $img->image_path = $read_temp_path;
       array_push($images, $img);
   } elseif($extension == 'gif') {
       $read_temp_path = str_replace('.txt', '.gif', $read_temp_path);
       $img->image_path = $read_temp_path;
       array_push($images, $img);
   }

   return $images;
}

1. コンポーネントから飛んできた情報をexplode()メソッドでカンマ区切りで取得し、配列に入れています。

2. base64_decode()メソッドを使って、写真データをデコードしています。この作業を挟まないと写真データを正しく扱えなくなります。

3. path_makeメソッドを呼び出しています。こちらは写真データを保管し、写真データまでのパスをデータベースに保存する作業です。

3-1.  この部分についてはこちらの記事を参考にしました。ややこしく見えますが、やっている事は画像ファイルとして扱えるようにtmp領域に保存しているらしいです。

3-2.  3-1で作成したtmp領域に引数で渡されているデコードされた写真の情報(文字列)をfile_put_contents()を使って内容を変更しています。これで写真のfileデータとして扱えるようになります。

3-3.  写真をstore()を使って写真を保管しています。こちらはシンボリックを貼る事でViewで表示できるようになります。php artisan storage:linkをターミナルで実行する必要があります。参考

3-4.  ファイルの拡張子を取得しています。拡張子の取得仕方は他にも色々あるので是非調べてみてください。ちなみに今回のFileはSymfony\Component\HttpFoundation\File\File;から来ています。

3-5.  保存した写真までのパスをstr_replace()メソッドで取得して、中身を変更してから変数定義しています。3-3の部分と関係してます。

4.  3の処理で取得した値を使ってデータベースに保存する値を作成します。拡張子によって保存する値が変化するようにしています。

4-1.  返り値となる配列の箱を作成します。ここに値を入れていきます。

4-2. 写真の拡張子によって保存する値の拡張子を変更しています。この作業がないと写真もデータベースにも保存出来るのに、写真が表示出来ないという状態になってしまいます。

5. 事前に保存されているitemに紐づく形でimages(配列)をsaveMany()を使って保存していきます。saveMany()を使う事で複数の関連したモデルを保存出来ます。

まとめ

コンポーネント側は写真の情報を重複がない形でbase64形式で配列の中に格納してコントローラーに送信する。

また、バリデーションで戻って来た時用に値を再代入する処理が必要になる。

コントローラー側はbase64をデコードし、tmp領域に入れてからファイルデータとして扱えるようにする処理が必要になる。

あとはstore()で保存するだけだが、viewで表示させるために拡張子によって保存する値を変化させる必要がある。シンボリックを通すのも忘れずに。

こんな感じでしょうか。。

感想

難関なのはコントローラー側で、base64データをfileデータとして扱えるようにする事でした。デコードとかもあまり触ったこと無かったので良い機会でした。

そもそも最初は以前の記事を参考に進めていたのに、データを送るとbase64になっていて、え?なにこれ?って所からのスタートだったので時間もかかりましたが学びも多かったです。

我流でやっている部分は多いと思うので、参考程度になれば嬉しいです。こんな書き方の方があるよという方居ましたら、是非教えてください!

次は編集処理を書いていきます。

今回はこんな感じで!

以上!