【Rails7】 Bootstrap と Stimulus でワードプレスのようなメディアライブラリを作ってみた(ActiveStorage導入済み)

最近、Railsで自作ブログの運営を始めましたが、自作ブログを作る過程で悩んだのがメディア(画像)の扱い。

僕はワードプレス(WordPress)に馴染みがあったので、できればワードプレスのようなメディアライブラリ(アップロードした画像ギャラリーの中から、画像を選択して投稿に挿入したりサムネイル画像に設定したりする機能)があったら便利だなと思っていました。

そこで、以前作ったActive Storageの複数枚画像アップロード機能を応用してメディアライブラリを作れないか試行錯誤してみた結果、、

あわせて読みたい
【Rails7】ActiveStorageで複数枚画像を投稿・更新する(プレビュー表示も実装) ActiveStorageで複数枚画像を投稿(保存)する方法についての情報はたくさん出てきますが、正直どれも僕が思い描いていた画像アップロード機能のイメージとは違うかなぁ...

なんとかワードプレス風のメディアライブラリを実装することができたので、忘備録として残しておこうと思います。

(基本的に自分用のメモ書きとしてまとめているため、僕の記事見て分からなかったら諦めて他を当たってくださいw)

目次

開発環境

  • Ruby 3.1.2
  • Ruby on Rails 7.0.4.3
  • Bootstrap 5.2.3
  • M1 Macbook Air 2020
  • mac OS Monterey (ver. 12.4)

出来上がりイメージ

今回作成するメディアライブラリの動作ゴールとしては、

  • メディアライブラリにアップロード済みの画像一覧が表示される
  • メディアライブラリから画像をアップロードできる(ドラッグ&ドロップも可)
  • メディアライブラリから選択した画像を削除できる

以上です。

まず、メディアライブラリを開くと、以下のようにアップロード済みの画像が表示されます。

「ファイルを選択」ボタンでアップロードしたいファイル(複数可)を選択し、アップロードできます。

また、ドラッグ&ドロップすることでもアップロードが可能です。

画像を削除したい場合は、画像を選択した状態で「削除」ボタンを押すと削除できます。

今回実装するメディアライブラリの動作はこんな感じです。

「アイキャッチ画像に設定」ボタンでサムネイル画像を設定したり、選択した画像をテキストエディタ内に挿入したりする動作はまた別記事で紹介しようと思います。

WordPress風メディアライブラリを作成する方法(画像アップロード編)

メディアライブラリを実装するにあたり、以下の項目を前提として話を進めていきます。

  • Rails7 導入済み
  • 投稿機能作成済み(Postモデル)
  • ActiveStorage 導入済み
  • Bootstrap 導入済み(モーダル使用)
  • Devise 導入済み
  • Rails7 の Stimulusについてある程度理解している

ちなみに、僕は分かりやすく説明するのが苦手(&面倒くさいw)なので、全体的にざっくりとした説明になります。

特にStimulusについての説明はかなり端折っていますので、分からなければググってください。

それではいきます。

StimulusでJS用コントローラー作成

以下のコマンドでJS用のコントローラーを作成します。

$ rails g stimulus images

コマンドを実行すると、app/javascript/controllers内にimages_controller.jsが生成されるかと思います。

今回作成するメディアライブラリに必要なJSコードは全てimages_controller.js内に記述していきます。

記事投稿フォーム(_form.html.erb)にコントローラー(images_controller.js)を適用する

先ほど生成したimages_controller.jsを記事投稿フォーム用のビューに適用させるように、data-controller="images"を追記します(以下のコードを参照)。

<div data-controller="images">
  <%= form_with(model: post) do |form| %>
  

    <!-- 省略 -->


  <% end %>
</div>

モーダルウィンドウの作成(Bootstrap〕

Bootstrapのモーダルの雛形を用いて、_form.html.erbにメディアライブラリ用のモーダルウィンドウを設置します。(モーダルウィンドウを開く用のボタンも設置)

<div data-controller="images">
  <%= form_with(model: post) do |form| %>
  
    <!-- メディアライブラリを開くボタン -->
    <div class="my-3">
      <div type="button" class="d-flex justify-content-center mx-1 mb-3 modal-btn" data-bs-toggle="modal" data-bs-target="#fileUploadModal"></div>
    </div>

    <!-- 省略 -->


  <% end %>

  <!-- メディア追加用のモーダルウィンドウ -->
  <div class="modal fade" id="fileUploadModal" tabindex="-1" aria-labelledby="fileUploadModalLabel" aria-hidden="true">
    <div class="modal-dialog modal-lg modal-dialog-scrollable">
      <div class="modal-content">
        <div class="modal-header">
          <h1 class="modal-title fs-5" id="fileUploadModalLabel">メディアの選択またはアップロード</h1>
          <button type="button" class="btn-close" data-action="click->images#resetSelectedImage" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <!-- ドラッグ&ドロップ用のエリアに data-images-target="drop" と data-action を記述(アクションの中身は後ほど) -->
        <div class="modal-body" data-images-target="drop" data-action="dragover->images#dragover dragleave->images#dragleave drop->images#dropImages">
          <div class="my-3 text-center">
            <label for="filename">
              <!-- ボタンのCSSは省略。各自好きなデザインを -->
              <span class="upload-image-btn">ファイルを選択</span>
              <!-- 「ファイルを選択」ボタンに data-images-target="select" と data-action を記述(アクションの中身は後ほど) -->
              <input type="file" id="filename" accept="image/*" style="display: none" data-images-target="select" data-action="change->images#selectImages" multiple>
            </label>
          </div>
          <div class="my-3 text-center">
            <p>または</p>
            <p class="fs-5">ここにファイルをドラッグ&ドロップ</p>
          </div>
          <hr style="border: 1px solid #e3e1e1">
          <!-- 画像プレビューエリアに data-images-target="preview_images" と data-action を記述(アクションの中身は後ほど) -->
          <div class="my-2" data-images-target="preview_images">
            <!-- アップロード済みの画像をプレビュー(遅延読み込み) -->
            <% if current_user.images.exists? %>
              <%= turbo_frame_tag "images-page-#{@images.current_page}" do %>
                <% @images.each do |image| %>
                  <div class="image-box d-inline-flex justify-content-center mx-1 mb-3" data-action="click->images#selectedImageBox">
                    <%= image_tag(image, class: "mx-auto", id: image.blob_id) %>
                  </div>
                <% end %>
                <%= turbo_frame_tag "images-page-#{@images.next_page}", loading: :lazy, src: path_to_next_page(@images) %>
              <% end %>
            <% end %>
          </div>
        </div>
        <div class="modal-footer d-flex justify-content-start">
          <!-- 「削除」ボタンに data-action を記述(アクションの中身は後ほど) -->
          <button class="btn btn-danger" data-action="click->images#deleteImage">削除</button>
        </div>
      </div>
    </div>
  </div>
</div>

ここで、ドラッグ&ドロップエリアと「ファイルを選択」ボタン、画像プレビューエリア、および「削除」ボタンにdata-images-targetdata-actionを設定します(上記コードを参照)。

data-actionの中身は次の項目で紹介します。

なお、画像プレビューにはTurbo Streamにより遅延読み込みされるよう改良してあります。

遅延読み込みについては以下の記事を参考にしていただければと。

あわせて読みたい
【Rails7】WordPress風メディアライブラリに遅延読み込み(無限スクロール)を実装してみた つい最近、Railsで自作したブログを公開しましたが、運用するにあたり気になっていたことがありました。 https://plog.kobacchi.com/rails-self-made-blog-start/ それ...

画像のアップロード機能を実装(images_controller.js)

続いては、images_controller.jsに非同期で画像アップロード(複数枚)を行うコードを記述します。

非同期で画像をアップロードするやり方は、過去に僕が作った画像アップローダーを参考にしています。

あわせて読みたい
【Rails7】複数枚画像をドラッグ&ドロップで表示、アップロードする方法(Stimulusで実装) 以前、Active Storageで複数枚画像のプレビュー表示、およびアップロードする方法についてまとめました。(Stimulusで実装) https://plog.kobacchi.com/rails7-actives...

今回のように画像を非同期でデータベース上にアップロードする場合は、コントローラーを作成します。

$ rails g controller images

コントローラーを作成したら、以下のようにblobデータを生成するアクションを追記します(ActiveStorageとDeviseを導入している前提で進めます)。

class ImagesController < ApplicationController
  before_action :authenticate_user! # ログインユーザーのみアップロード可能(deviseのメソッド)

  # 画像のアップロード
  def upload_image
    blob = ActiveStorage::Blob.create_and_upload!(
      io: params[:file],
      filename: params[:file].original_filename,
      content_type: params[:file].content_type
    ) # params[:file]で取得した画像ファイルをblobデータとしてDBに保存
    current_user.images.attach(blob) # blobデータ(画像)とユーザーを紐付け
    render json: {location: url_for(blob), id: blob.id}, status: :ok # 画像のURLとIDを返す
  end

end

また、upload_imageアクションのルーティングを設定します。

Rails.application.routes.draw do
  # (省略)
  post "images/upload_image", to: "images#upload_image"
end

続いては、images_controller.jsに「ファイルを選択」ボタンを押下、もしくはドラッグ&ドロップした時に画像をアップロードするコードを記述します。

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="images"
export default class extends Controller {
  static targets = ["select", "preview_images", "preview_thumbnail", "drop"]


  // ファイルサイズの上限を指定
  imageSizeOver(file){ // 1枚あたりのファイルサイズの上限(2MB)
    const fileSize = (file.size)/1000
    if(fileSize > 2000){
      return true
    }else{
      return false
    }
  }

  // 「ファイルを選択」ボタンを押下したときの動作(画像選択後→アップロード) 
  selectImages(){
    const files = this.selectTargets[0].files
    // clearTimeout(this.timeoutImages)
    for(const file of files){
      if(this.imageSizeOver(file)){
        this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり2MB)を超えている画像はアップロードできません。"
        this.timeoutImages = setTimeout(() =>{
          this.errorTarget.textContent = ""
        }, 3000)
      }else{
        this.uploadImage(file)
      }
    }
    this.selectTarget.value = "" // 選択中のファイルをリセット
  }

  // ドラッグオーバーしたときの動作
  dragover(e){
    e.preventDefault()
    this.dropTarget.classList.add("image-drag-over")
  }

  // ドラッグリーブしたときの動作
  dragleave(e){
    e.preventDefault()
    this.dropTarget.classList.remove("image-drag-over")
  }

  // ドロップしたときの動作(画像ドロップ後→アップロード)
  dropImages(e){
    e.preventDefault()
    this.dropTarget.classList.remove("image-drag-over")
    const files = e.dataTransfer.files
    for(const file of files){
      if(this.imageSizeOver(file)){
        this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり2MB)を超えている画像はアップロードできません。"
        this.timeoutImages = setTimeout(() =>{
          this.errorTarget.textContent = ""
        }, 3000)
      }else{
        this.uploadImage(file)
      }
    }
  }

  // 画像アップロード用のアクション
  uploadImage(file){
    const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
    const formData = new FormData()
    formData.append("file", file) // formDataオブジェクトに画像ファイルをセット
    const options = {
      method: 'POST',
      headers: {
        'X-CSRF-Token': csrfToken
      },
      body: formData
    }
    fetch("/images/upload_image", options)
      .then(response => response.json())
      .then(data => {
        // ここに画像プレビュー用のアクションを記述(後ほど)
      })
      .catch((error) => {
        console.error(error)
      })
  }

}

アップロード済み画像のプレビュー(images_controller.js)

続いては、アップロード済みの画像を表示(プレビュー)するためのアクションを記述していきます。

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="images"
export default class extends Controller {
  static targets = ["select", "preview_images", "preview_thumbnail", "drop"]


  // ファイルサイズの上限を指定
  imageSizeOver(file){ // 1枚あたりのファイルサイズの上限(2MB)
    const fileSize = (file.size)/1000
    if(fileSize > 2000){
      return true
    }else{
      return false
    }
  }

  // 「ファイルを選択」ボタンを押下したときの動作(画像選択後→アップロード)
  selectImages(){
    const files = this.selectTargets[0].files
    // clearTimeout(this.timeoutImages)
    for(const file of files){
      if(this.imageSizeOver(file)){
        this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり2MB)を超えている画像はアップロードできません。"
        this.timeoutImages = setTimeout(() =>{
          this.errorTarget.textContent = ""
        }, 3000)
      }else{
        this.uploadImage(file)
      }
    }
    this.selectTarget.value = "" // 選択中のファイルをリセット
  }

  // ドラッグオーバーしたときの動作
  dragover(e){
    e.preventDefault()
    this.dropTarget.classList.add("image-drag-over")
  }

  // ドラッグリーブしたときの動作
  dragleave(e){
    e.preventDefault()
    this.dropTarget.classList.remove("image-drag-over")
  }

  // ドロップしたときの動作(画像ドロップ後→アップロード)
  dropImages(e){
    e.preventDefault()
    this.dropTarget.classList.remove("image-drag-over")
    const files = e.dataTransfer.files
    for(const file of files){
      if(this.imageSizeOver(file)){
        this.errorTarget.textContent = "ファイルサイズの上限(1枚あたり2MB)を超えている画像はアップロードできません。"
        this.timeoutImages = setTimeout(() =>{
          this.errorTarget.textContent = ""
        }, 3000)
      }else{
        this.uploadImage(file)
      }
    }
  }

  // 画像アップロード用のアクション
  uploadImage(file){
    const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
    const formData = new FormData()
    formData.append("file", file) // formDataオブジェクトに画像ファイルをセット
    const options = {
      method: 'POST',
      headers: {
        'X-CSRF-Token': csrfToken
      },
      body: formData
    }
    fetch("/images/upload_image", options)
      .then(response => response.json())
      .then(data => {
        /************** ↓追記 *****************/
        this.previewImage(file, data.id, data.location)
        /**************************************/
      })
      .catch((error) => {
        console.error(error)
      })
  }

  /********************** ↓追記 ***************************/
  // 画像プレビュー用のアクション
  previewImage(file, blob_id, blob_src){
    const previewImages = this.preview_imagesTarget
    const fileReader = new FileReader()
    const setAttr = (element, obj)=>{
      Object.keys(obj).forEach((key)=>{
        element.setAttribute(key, obj[key])
      })
    }
    fileReader.readAsDataURL(file)
    fileReader.onload = (function() {
      const img = new Image()
      const imgBox = document.createElement("div")
      const imgBoxAttr = {
        "class" : "image-box d-inline-flex justify-content-center mx-1 mb-3",
        "data-action" : "click->images#selectedImageBox"
      }
      setAttr(imgBox, imgBoxAttr)
      imgBox.appendChild(img)
      img.src = blob_src
      img.id = blob_id
      img.classList.add("mx-auto")
      console.log(img)
      previewImages.prepend(imgBox)
    })
  }
/*********************************************************/

}

ここで、previewImage(file, blob_id, blob_src)で指定している引数について。

fileは「ファイルを選択」もしくはドラッグ&ドロップで取得したファイルを指し、blob_idblob_srcはそれぞれimages_controller.rbのupload_imageアクションの返り値(render json: {location: url_for(blob), id: blob.id}の部分)を表しています。

ここまで記述すれば、画像アップロードと同時にモーダルウィンドウ上にアップロード済み画像が表示されるようになります。

WordPress風メディアライブラリを作成する方法(削除編)

ここでは、アップロード済みの画像の中から、選択した画像を削除する方法について述べます。(複数枚同時ではなく、1枚ずつ削除)

画像選択時の動作(images_controller.js)

画像をアップロードしてプレビューしただけだと特定の画像を選択できないので、ここではクリックした画像を選択状態にする動作を記述します。

(モーダルウィンドウを閉じた時に選択状態を解除するためのアクションも記述しておきます)

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="images"
export default class extends Controller {
  static targets = ["select", "preview_images", "preview_thumbnail", "drop"]


 // (省略)

  /********************** ↓追記 ***************************/
  // 画像を選択したときの動作(クラスの追加)
  selectedImageBox(e){
    const imageBoxSelected = e.currentTarget // 現在の要素を取得( e.currentTarget )
    const imageBoxes = document.querySelectorAll(".image-box-selected")
    for(const imageBox of imageBoxes){
      imageBox.classList.remove("image-box-selected") //image-box-selectedクラスを一旦削除(初期化)
    }
    imageBoxSelected.classList.add("image-box-selected") // 選択画像にimage-box-selectedクラスを追加
  }

  // 選択の解除(モーダルを閉じるとき)
  resetSelectedImage(){
    const selectedImages = this.preview_imagesTarget.querySelectorAll(".image-box-selected")
    for(const selectedImage of selectedImages){
      selectedImage.classList.remove("image-box-selected")
    }
  }
/*********************************************************/

}

ここでは、特定の画像を選択すると、選択した画像のclassにimage-box-selectedが付与されるようにしてあります。

選択状態の画像が一目でわかるよう、image-box-selectedにCSSを記述しておくと良いでしょう。

アップロードした画像を削除

_form.html.erbのモーダルに「削除」ボタンが追加されていることを確認します。

<div data-controller="images">

    <!-- 省略 -->

  <!-- メディア追加用のモーダルウィンドウ -->
  <div class="modal fade" id="fileUploadModal" tabindex="-1" aria-labelledby="fileUploadModalLabel" aria-hidden="true">
    <div class="modal-dialog modal-lg modal-dialog-scrollable">
      <div class="modal-content">

        <!-- (省略) -->
        
        <div class="modal-footer d-flex justify-content-start">
          <!-- 「削除」ボタンに data-action を記述(アクションの中身は後ほど) -->
          <button class="btn btn-danger" data-action="click->images#deleteImage">削除</button>
        </div>
      </div>
    </div>
  </div>
</div>

続いて、images_controller.rbにアップロード済みの画像を削除するアクションを記述します。

class ImagesController < ApplicationController
  before_action :authenticate_user! # ログインユーザーのみアップロード可能(deviseのメソッド)

  # 画像のアップロード
  def upload_image
    blob = ActiveStorage::Blob.create_and_upload!(
      io: params[:file],
      filename: params[:file].original_filename,
      content_type: params[:file].content_type
    ) # params[:file]で取得した画像ファイルをblobデータとしてDBに保存
    current_user.images.attach(blob) # blobデータ(画像)とユーザーを紐付け
    render json: {location: url_for(blob), id: blob.id}, status: :ok # 画像のURLとIDを返す
  end

  ################# ↓追記 ##################
  # アップロードした画像の削除
  def delete_image
    image = current_user.images.find_by(blob_id: params[:image_id])
    image.purge
  end
  #########################################

end

続いて、delete_imageアクションのルーティングを設定します。

Rails.application.routes.draw do
  # (省略)
  post "images/upload_image", to: "images#upload_image"
  post "images/delete_image", to: "images#delete_image" # ←追記
end

最後に、images_controller.jsに「削除」ボタンを押下した時に画像を削除するコードを記述します。

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="images"
export default class extends Controller {
  static targets = ["select", "preview_images", "preview_thumbnail", "drop"]


  // (省略)


  // 画像を選択したときの動作(クラスの追加)
  selectedImageBox(e){
    const imageBoxSelected = e.currentTarget // 現在の要素を取得( e.currentTarget )
    const imageBoxes = document.querySelectorAll(".image-box-selected")
    for(const imageBox of imageBoxes){
      imageBox.classList.remove("image-box-selected") //image-box-selectedクラスを一旦削除(初期化)
    }
    imageBoxSelected.classList.add("image-box-selected") // 選択画像にimage-box-selectedクラスを追加
  }

  // 選択の解除
  resetSelectedImage(){
    const selectedImages = this.preview_imagesTarget.querySelectorAll(".image-box-selected")
    for(const selectedImage of selectedImages){
      selectedImage.classList.remove("image-box-selected")
    }
  }

  /********************** ↓追記 ***************************/
  // 画像削除のアクション
  deleteImage(){
    const thisImageBox = document.querySelector(".image-box-selected")
    if(thisImageBox){
      const imageId = thisImageBox.firstElementChild.id
      const csrfToken = document.getElementsByName('csrf-token')[0].content // CSRFトークンを取得
      const formData = new FormData()
      formData.append("image_id", imageId)
      const options = {
        method: 'POST',
        headers: {
          'X-CSRF-Token': csrfToken
        },
        body: formData
      }
      const result = window.confirm("本当に削除しますか?")
      if(result){
        fetch("/images/delete_image", options)
        thisImageBox.remove()
      }
    }
  }
  /*********************************************************/
}

images_controller.jsdeleteImage()アクションでは、選択状態の画像(image-box-selectedクラスの付与された要素)からimage_idを取得し、images_controller.rbdelete_imageアクションに送信してします。

(画像プレビュー時に返り値のblob_idをidとして設定しておく必要があります)

delete_imageアクションでは、受け取ったimage_idから対象のblobデータを探し出し、そのblobデータをpergeメソッドで削除する、という流れです。

以上となります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

愛知の34歳。無職で暇になり始めたプログラミング(Ruby on Rails)の忘備録をまとめたブログです。最近は別にやりたいことができたのでプログラミングほぼやっていません。気が向いたらまた再開するかも。僕の日常はメインブログの方で更新しています。

コメント

コメントする

目次