2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails 7.2 × Cloudinary 完全実装ガイド

Posted at

Rails 7.2 × Cloudinary 完全実装ガイド

📚 目次

  1. はじめに
  2. Cloudinaryアカウント作成
  3. Gemのインストール
  4. Active Storageのセットアップ
  5. Cloudinaryの設定
  6. モデルの設定
  7. コントローラーの実装
  8. ビューの実装
  9. 画像表示の最適化
  10. 画像削除機能
  11. JavaScript実装(ドラッグ&ドロップ)
  12. パフォーマンス最適化
  13. 本番環境へのデプロイ
  14. トラブルシューティング

はじめに

初実装のため、Claudeに教科書作っていただきました。
アプリ開発、備忘録用にぜひ!

Cloudinaryを選ぶメリット

  • ✅ 無料枠が充実(月25GB)
  • ✅ 画像の自動最適化(WebP変換、圧縮)
  • ✅ グローバルCDN標準搭載
  • ✅ URLパラメータだけで画像変換可能
  • ✅ 設定が簡単

Cloudinaryアカウント作成

手順

  1. Cloudinary公式サイトにアクセス
  2. 「Sign Up for Free」をクリック
  3. メールアドレスで登録(またはGitHub/Google連携)
  4. ダッシュボードで以下の情報を確認:
    • Cloud name
    • API Key
    • API Secret

💡 これらの情報は後で環境変数として使用します。


Gemのインストール

Gemfileに追加

# Gemfile

# Cloudinary本体
gem 'cloudinary'

# Active StorageとCloudinaryを連携
gem 'activestorage-cloudinary-service'

# 環境変数管理(推奨)
gem 'dotenv-rails', groups: [:development, :test]

インストール実行

bundle install

Active Storageのセットアップ

インストール

# Active Storageをインストール
rails active_storage:install

# マイグレーション実行
rails db:migrate

これにより以下のテーブルが作成されます:

  • active_storage_blobs - ファイルのメタデータ
  • active_storage_attachments - モデルとファイルの関連
  • active_storage_variant_records - 画像変換のキャッシュ

Cloudinaryの設定

1. 環境変数の設定

# .env ファイルを作成
touch .env

# .gitignoreに追加(重要!)
echo ".env" >> .gitignore
# .env
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

2. storage.ymlの設定

# config/storage.yml

# 開発環境用(ローカルストレージ)
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

# 本番環境用(Cloudinary)
cloudinary:
  service: Cloudinary
  cloud_name: <%= ENV['CLOUDINARY_CLOUD_NAME'] %>
  api_key: <%= ENV['CLOUDINARY_API_KEY'] %>
  api_secret: <%= ENV['CLOUDINARY_API_SECRET'] %>

3. 環境別設定

# config/environments/development.rb
Rails.application.configure do
  # 開発環境ではローカルストレージを使用
  config.active_storage.service = :local
end
# config/environments/production.rb
Rails.application.configure do
  # 本番環境ではCloudinaryを使用
  config.active_storage.service = :cloudinary
end

モデルの設定

Gameモデルの例

# app/models/game.rb
class Game < ApplicationRecord
  # 単一画像の場合(カバー画像など)
  has_one_attached :cover_image
  
  # 複数画像の場合(スクリーンショットなど)
  has_many_attached :screenshots
  
  # バリデーション
  validates :title, presence: true
  
  # 画像のバリデーション(オプション)
  validates :cover_image, 
            content_type: ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/webp'],
            size: { less_than: 5.megabytes, message: '5MB以下にしてください' }
  
  # Eager loading用スコープ
  scope :with_images, -> { 
    includes(
      cover_image_attachment: :blob,
      screenshots_attachments: :blob
    ) 
  }
end

マイグレーションの作成

rails generate model Game title:string hardware:string genre:string played_age:integer memo:text
rails db:migrate

コントローラーの実装

GamesController

# app/controllers/games_controller.rb
class GamesController < ApplicationController
  before_action :set_game, only: [:show, :edit, :update, :destroy]
  
  def index
    @games = Game.with_images.order(created_at: :desc)
  end
  
  def show
  end
  
  def new
    @game = Game.new
  end
  
  def create
    @game = Game.new(game_params)
    
    if @game.save
      redirect_to @game, notice: 'ゲームを登録しました'
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  def edit
  end
  
  def update
    if @game.update(game_params)
      redirect_to @game, notice: 'ゲームを更新しました'
    else
      render :edit, status: :unprocessable_entity
    end
  end
  
  def destroy
    @game.destroy
    redirect_to games_path, notice: 'ゲームを削除しました'
  end
  
  private
  
  def set_game
    @game = Game.find(params[:id])
  end
  
  def game_params
    params.require(:game).permit(
      :title, 
      :hardware, 
      :genre, 
      :played_age, 
      :memo,
      :cover_image,      # 単一画像
      screenshots: []    # 複数画像(配列)
    )
  end
end

ルーティング

# config/routes.rb
Rails.application.routes.draw do
  resources :games do
    member do
      delete :remove_cover_image
      delete 'remove_screenshot/:screenshot_id', 
             action: :remove_screenshot, 
             as: :remove_screenshot
    end
  end
  
  root 'games#index'
end

ビューの実装

フォーム(新規作成・編集)

<!-- app/views/games/_form.html.erb -->
<%= form_with(model: game, local: true, html: { multipart: true, class: "space-y-6" }) do |form| %>
  <% if game.errors.any? %>
    <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
      <h2 class="font-bold mb-2"><%= pluralize(game.errors.count, "error") %> がありました:</h2>
      <ul class="list-disc list-inside">
        <% game.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <!-- タイトル -->
  <div class="mb-4">
    <%= form.label :title, "ゲームタイトル", class: "block text-sm font-medium mb-2" %>
    <%= form.text_field :title, 
        class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
        placeholder: "例: ゼルダの伝説" %>
  </div>

  <!-- ハードウェア -->
  <div class="mb-4">
    <%= form.label :hardware, "ハードウェア", class: "block text-sm font-medium mb-2" %>
    <%= form.text_field :hardware, 
        class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
        placeholder: "例: Nintendo Switch" %>
  </div>

  <!-- ジャンル -->
  <div class="mb-4">
    <%= form.label :genre, "ジャンル", class: "block text-sm font-medium mb-2" %>
    <%= form.text_field :genre, 
        class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
        placeholder: "例: アクションRPG" %>
  </div>

  <!-- プレイした年齢 -->
  <div class="mb-4">
    <%= form.label :played_age, "プレイした年齢", class: "block text-sm font-medium mb-2" %>
    <%= form.number_field :played_age, 
        class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
        placeholder: "例: 25" %>
  </div>

  <!-- カバー画像(単一) -->
  <div class="mb-4">
    <%= form.label :cover_image, "カバー画像", class: "block text-sm font-medium mb-2" %>
    
    <div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition drop-zone">
      <%= form.file_field :cover_image, 
          accept: "image/png,image/jpg,image/jpeg,image/gif,image/webp",
          class: "hidden",
          id: "cover_image_input",
          onchange: "previewCoverImage(event)" %>
      
      <label for="cover_image_input" class="cursor-pointer">
        <div class="text-gray-600">
          <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
            <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <p class="mt-1">クリックまたはドラッグ&ドロップで画像を選択</p>
          <p class="text-xs text-gray-500 mt-1">PNG, JPG, GIF (最大5MB)</p>
        </div>
      </label>
    </div>
    
    <!-- 既存画像のプレビュー -->
    <% if game.cover_image.attached? %>
      <div class="mt-4 relative inline-block">
        <%= image_tag game.cover_image.variant(resize_to_limit: [300, 300]), 
            class: "rounded-lg border border-gray-300 shadow-sm" %>
        <%= link_to "削除", 
            remove_cover_image_game_path(game), 
            method: :delete,
            data: { confirm: "本当に削除しますか?" },
            class: "absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600" if game.persisted? %>
      </div>
    <% end %>
    
    <!-- 新規画像のプレビュー -->
    <div id="cover_preview" class="mt-4"></div>
  </div>

  <!-- スクリーンショット(複数) -->
  <div class="mb-4">
    <%= form.label :screenshots, "スクリーンショット(複数選択可)", class: "block text-sm font-medium mb-2" %>
    
    <div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-500 transition">
      <%= form.file_field :screenshots, 
          multiple: true,
          accept: "image/png,image/jpg,image/jpeg,image/gif,image/webp",
          class: "hidden",
          id: "screenshots_input",
          onchange: "previewScreenshots(event)" %>
      
      <label for="screenshots_input" class="cursor-pointer">
        <div class="text-gray-600">
          <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
            <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <p class="mt-1">クリックまたはドラッグ&ドロップで複数画像を選択</p>
          <p class="text-xs text-gray-500 mt-1">PNG, JPG, GIF (各最大5MB)</p>
        </div>
      </label>
    </div>
    
    <!-- 既存画像のプレビュー -->
    <% if game.screenshots.attached? %>
      <div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
        <% game.screenshots.each_with_index do |screenshot, index| %>
          <div class="relative group">
            <%= image_tag screenshot.variant(resize_to_fill: [200, 200]), 
                class: "w-full h-48 object-cover rounded-lg border border-gray-300 shadow-sm" %>
            <% if game.persisted? %>
              <%= link_to "×", 
                  remove_screenshot_game_path(game, screenshot_id: screenshot.id), 
                  method: :delete,
                  data: { confirm: "本当に削除しますか?" },
                  class: "absolute top-2 right-2 bg-red-500 text-white w-8 h-8 rounded-full flex items-center justify-center text-xl hover:bg-red-600 opacity-0 group-hover:opacity-100 transition" %>
            <% end %>
          </div>
        <% end %>
      </div>
    <% end %>
    
    <!-- 新規画像のプレビュー -->
    <div id="screenshots_preview" class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4"></div>
  </div>

  <!-- メモ -->
  <div class="mb-4">
    <%= form.label :memo, "メモ", class: "block text-sm font-medium mb-2" %>
    <%= form.text_area :memo, 
        rows: 4,
        class: "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500",
        placeholder: "ゲームについての感想やメモ" %>
  </div>

  <!-- 送信ボタン -->
  <div class="flex gap-4">
    <%= form.submit "保存", 
        class: "bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition cursor-pointer" %>
    <%= link_to "キャンセル", 
        games_path, 
        class: "bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition" %>
  </div>
<% end %>

<script>
  // カバー画像のプレビュー
  function previewCoverImage(event) {
    const file = event.target.files[0];
    const preview = document.getElementById('cover_preview');
    
    if (file) {
      const reader = new FileReader();
      reader.onload = function(e) {
        preview.innerHTML = `
          <img src="${e.target.result}" class="rounded-lg border border-gray-300 shadow-sm max-w-xs">
        `;
      };
      reader.readAsDataURL(file);
    }
  }
  
  // スクリーンショットのプレビュー
  function previewScreenshots(event) {
    const files = event.target.files;
    const preview = document.getElementById('screenshots_preview');
    preview.innerHTML = '';
    
    Array.from(files).forEach(file => {
      const reader = new FileReader();
      reader.onload = function(e) {
        const div = document.createElement('div');
        div.innerHTML = `
          <img src="${e.target.result}" class="w-full h-48 object-cover rounded-lg border border-gray-300 shadow-sm">
        `;
        preview.appendChild(div);
      };
      reader.readAsDataURL(file);
    });
  }
</script>

一覧表示(Dashboard)

<!-- app/views/games/index.html.erb -->
<div class="container mx-auto px-4 py-8">
  <div class="flex justify-between items-center mb-8">
    <h1 class="text-3xl font-bold text-gray-800">ゲームコレクション</h1>
    <%= link_to "新規登録", 
        new_game_path, 
        class: "bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
  </div>

  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    <% @games.each do |game| %>
      <div class="bg-white rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition group">
        <!-- カバー画像 -->
        <%= link_to game_path(game) do %>
          <% if game.cover_image.attached? %>
            <%= image_tag game.cover_image.variant(resize_to_fill: [400, 300]), 
                class: "w-full h-48 object-cover group-hover:scale-105 transition duration-300",
                loading: "lazy",
                alt: game.title %>
          <% else %>
            <div class="w-full h-48 bg-gray-200 flex items-center justify-center">
              <svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
              </svg>
            </div>
          <% end %>
        <% end %>
        
        <div class="p-5">
          <!-- タイトル -->
          <h3 class="text-xl font-bold mb-2 text-gray-800">
            <%= link_to game.title, game_path(game), class: "hover:text-blue-600" %>
          </h3>
          
          <!-- メタ情報 -->
          <p class="text-gray-600 text-sm mb-3">
            <%= [game.hardware, game.genre, (game.played_age ? "#{game.played_age}歳" : nil)].compact.join(" / ") %>
          </p>
          
          <!-- スクリーンショットサムネイル -->
          <% if game.screenshots.attached? %>
            <div class="flex gap-2 mb-3">
              <% game.screenshots.first(4).each do |screenshot| %>
                <%= image_tag screenshot.variant(resize_to_fill: [80, 80]), 
                    class: "w-16 h-16 object-cover rounded border border-gray-200",
                    loading: "lazy" %>
              <% end %>
              <% if game.screenshots.count > 4 %>
                <div class="w-16 h-16 bg-gray-100 rounded border border-gray-200 flex items-center justify-center text-gray-500 text-sm">
                  +<%= game.screenshots.count - 4 %>
                </div>
              <% end %>
            </div>
          <% end %>
          
          <!-- アクションボタン -->
          <div class="flex gap-2 mt-4">
            <%= link_to "編集", 
                edit_game_path(game), 
                class: "flex-1 text-center bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition text-sm" %>
            <%= link_to "削除", 
                game_path(game), 
                method: :delete,
                data: { confirm: "本当に削除しますか?" },
                class: "flex-1 text-center bg-red-100 text-red-700 px-4 py-2 rounded hover:bg-red-200 transition text-sm" %>
          </div>
        </div>
      </div>
    <% end %>
  </div>
  
  <% if @games.empty? %>
    <div class="text-center py-16">
      <svg class="mx-auto h-24 w-24 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
      </svg>
      <h3 class="mt-4 text-xl font-medium text-gray-900">ゲームが登録されていません</h3>
      <p class="mt-2 text-gray-500">最初のゲームを登録してみましょう!</p>
      <%= link_to "新規登録", 
          new_game_path, 
          class: "mt-6 inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
    </div>
  <% end %>
</div>

詳細表示

<!-- app/views/games/show.html.erb -->
<div class="container mx-auto px-4 py-8 max-w-4xl">
  <div class="bg-white rounded-xl shadow-lg overflow-hidden">
    <!-- カバー画像 -->
    <% if @game.cover_image.attached? %>
      <%= image_tag @game.cover_image.variant(resize_to_limit: [800, 600]), 
          class: "w-full h-96 object-cover",
          alt: @game.title %>
    <% end %>
    
    <div class="p-8">
      <!-- タイトル -->
      <h1 class="text-4xl font-bold mb-4 text-gray-800"><%= @game.title %></h1>
      
      <!-- メタ情報 -->
      <div class="flex flex-wrap gap-4 mb-6">
        <% if @game.hardware.present? %>
          <span class="bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium">
            🎮 <%= @game.hardware %>
          </span>
        <% end %>
        
        <% if @game.genre.present? %>
          <span class="bg-purple-100 text-purple-800 px-4 py-2 rounded-full text-sm font-medium">
            🎯 <%= @game.genre %>
          </span>
        <% end %>
        
        <% if @game.played_age.present? %>
          <span class="bg-green-100 text-green-800 px-4 py-2 rounded-full text-sm font-medium">
            👤 <%= @game.played_age %>歳でプレイ
          </span>
        <% end %>
      </div>
      
      <!-- メモ -->
      <% if @game.memo.present? %>
        <div class="mb-6">
          <h2 class="text-xl font-bold mb-2 text-gray-800">📝 メモ</h2>
          <p class="text-gray-700 whitespace-pre-wrap"><%= @game.memo %></p>
        </div>
      <% end %>
      
      <!-- スクリーンショット -->
      <% if @game.screenshots.attached? %>
        <div class="mb-6">
          <h2 class="text-xl font-bold mb-4 text-gray-800">📸 スクリーンショット</h2>
          <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
            <% @game.screenshots.each do |screenshot| %>
              <%= link_to screenshot.url, target: "_blank", class: "block group" do %>
                <%= image_tag screenshot.variant(resize_to_fill: [400, 300]), 
                    class: "w-full h-64 object-cover rounded-lg shadow-md group-hover:shadow-xl transition",
                    loading: "lazy" %>
              <% end %>
            <% end %>
          </div>
        </div>
      <% end %>
      
      <!-- アクションボタン -->
      <div class="flex gap-4 mt-8">
        <%= link_to "編集", 
            edit_game_path(@game), 
            class: "bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition" %>
        <%= link_to "削除", 
            game_path(@game), 
            method: :delete,
            data: { confirm: "本当に削除しますか?" },
            class: "bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 transition" %>
        <%= link_to "戻る", 
            games_path, 
            class: "bg-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:bg-gray-300 transition" %>
      </div>
    </div>
  </div>
</div>

画像表示の最適化

Helperメソッドの作成

# app/helpers/games_helper.rb
module GamesHelper
  # サムネイル画像(正方形)
  def game_thumbnail(game, size: 300)
    return unless game.cover_image.attached?
    
    image_tag game.cover_image.variant(
      resize_to_fill: [size, size],
      format: :webp,
      quality: 80
    ), 
    class: "rounded-lg",
    loading: "lazy"
  end
  
  # カバー画像(横長)
  def game_cover(game, width: 800, height: 600)
    return unless game.cover_image.attached?
    
    image_tag game.cover_image.variant(
      resize_to_fill: [width, height],
      format: :webp,
      quality: 85
    ),
    class: "w-full",
    loading: "lazy"
  end
  
  # レスポンシブ画像
  def game_responsive_image(game, alt: game.title)
    return unless game.cover_image.attached?
    
    image_tag game.cover_image.variant(resize_to_limit: [800, 600]),
              srcset: {
                game.cover_image.variant(resize_to_limit: [400, 300]).url => '400w',
                game.cover_image.variant(resize_to_limit: [800, 600]).url => '800w',
                game.cover_image.variant(resize_to_limit: [1200, 900]).url => '1200w'
              },
              sizes: '(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px',
              alt: alt,
              loading: "lazy",
              class: "w-full h-auto"
  end
  
  # 画像が存在するかチェック
  def has_image?(game)
    game.cover_image.attached?
  end
end

使用例

<!-- ビューでの使用 -->
<%= game_thumbnail(@game, size: 200) %>
<%= game_cover(@game) %>
<%= game_responsive_image(@game) %>

画像削除機能

コントローラーに追加

# app/controllers/games_controller.rb
class GamesController < ApplicationController
  # カバー画像を削除
  def remove_cover_image
    @game = Game.find(params[:id])
    @game.cover_image.purge
    redirect_to edit_game_path(@game), notice: 'カバー画像を削除しました'
  end
  
  # スクリーンショットを削除
  def remove_screenshot
    @game = Game.find(params[:id])
    screenshot = @game.screenshots.find(params[:screenshot_id])
    screenshot.purge
    redirect_to edit_game_path(@game), notice: 'スクリーンショットを削除しました'
  end
end

JavaScript実装

ドラッグ&ドロップ対応

// app/javascript/application.js
document.addEventListener('turbo:load', () => {
  setupDragAndDrop();
});

function setupDragAndDrop() {
  const dropZones = document.querySelectorAll('.drop-zone');
  
  dropZones.forEach(dropZone => {
    const fileInput = dropZone.querySelector('input[type="file"]');
    
    if (!fileInput) return;
    
    // ドラッグイベントのデフォルト動作を防止
    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
      dropZone.addEventListener(eventName, preventDefaults, false);
      document.body.addEventListener(eventName, preventDefaults, false);
    });
    
    // ハイライト表示
    ['dragenter', 'dragover'].forEach(eventName => {
      dropZone.addEventListener(eventName, () => {
        dropZone.classList.add('border-blue-500', 'bg-blue-50');
      }, false);
    });
    
    ['dragleave', 'drop'].forEach(eventName => {
      dropZone.addEventListener(eventName, () => {
        dropZone.classList.remove('border-blue-500', 'bg-blue-50');
      }, false);
    });
    
    // ドロップ処理
    dropZone.addEventListener('drop', (e) => {
      const files = e.dataTransfer.files;
      fileInput.files = files;
      
      // プレビュー表示をトリガー
      const event = new Event('change', { bubbles: true });
      fileInput.dispatchEvent(event);
    }, false);
  });
}

function preventDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}

パフォーマンス最適化

1. Eager Loadingの実装

# app/models/game.rb
class Game < ApplicationRecord
  # ... 既存のコード ...
  
  # 画像を含めて取得するスコープ
  scope :with_images, -> { 
    includes(
      cover_image_attachment: :blob,
      screenshots_attachments: :blob
    ) 
  }
end

# app/controllers/games_controller.rb
class GamesController < ApplicationController
  def index
    # N+1問題を回避
    @games = Game.with_images.order(created_at: :desc)
  end
end

2. キャッシュの実装

# app/models/game.rb
class Game < ApplicationRecord
  # サムネイルURLをキャッシュ
  def thumbnail_url(size: 300)
    Rails.cache.fetch(
      ['game_thumbnail', id, cover_image.blob.checksum, size], 
      expires_in: 1.day
    ) do
      cover_image.variant(
        resize_to_fill: [size, size], 
        format: :webp
      ).processed.url
    end if cover_image.attached?
  end
  
  # カバー画像URLをキャッシュ
  def cover_url
    Rails.cache.fetch(
      ['game_cover', id, cover_image.blob.checksum], 
      expires_in: 1.day
    ) do
      cover_image.variant(
        resize_to_fill: [800, 600], 
        format: :webp
      ).processed.url
    end if cover_image.attached?
  end
end

3. ビューでのキャッシュ使用

<!-- app/views/games/index.html.erb -->
<% @games.each do |game| %>
  <% cache ['game-card', game, game.cover_image&.blob] do %>
    <div class="game-card">
      <% if game.cover_image.attached? %>
        <%= image_tag game.thumbnail_url(size: 400), 
            class: "w-full h-48 object-cover",
            loading: "lazy" %>
      <% end %>
      <!-- ... 他のコンテンツ ... -->
    </div>
  <% end %>
<% end %>

本番環境へのデプロイ

1. 環境変数の設定(Heroku)

# Herokuで環境変数を設定
heroku config:set CLOUDINARY_CLOUD_NAME=your_cloud_name
heroku config:set CLOUDINARY_API_KEY=your_api_key
heroku config:set CLOUDINARY_API_SECRET=your_api_secret

2. credentials.yml.encを使用する方法

# credentialsを編集
EDITOR="code --wait" rails credentials:edit

# または vim を使用
EDITOR="vim" rails credentials:edit
# config/credentials.yml.enc に追加
cloudinary:
  cloud_name: your_cloud_name
  api_key: your_api_key
  api_secret: your_api_secret
# config/storage.yml で参照
cloudinary:
  service: Cloudinary
  cloud_name: <%= Rails.application.credentials.dig(:cloudinary, :cloud_name) %>
  api_key: <%= Rails.application.credentials.dig(:cloudinary, :api_key) %>
  api_secret: <%= Rails.application.credentials.dig(:cloudinary, :api_secret) %>

3. デプロイ

# 変更をコミット
git add .
git commit -m "Add Cloudinary image upload feature"

# Herokuにプッシュ
git push heroku main

# マイグレーション実行
heroku run rails db:migrate

トラブルシューティング

よくある問題と解決方法

1. 画像が表示されない

原因: Active Storageが正しくインストールされていない

解決方法:

rails active_storage:install
rails db:migrate

2. Cloudinaryに画像がアップロードされない

原因: 環境変数が正しく設定されていない

解決方法:

# .envファイルを確認
cat .env

# Railsコンソールで確認
rails console
ENV['CLOUDINARY_CLOUD_NAME']

3. 画像変換が遅い

原因: 画像処理ライブラリの問題

解決方法:

# Gemfileに追加
gem 'image_processing', '~> 1.2'

# libvipsをインストール(Macの場合)
brew install vips

# config/application.rbに追加
config.active_storage.variant_processor = :vips

4. 開発環境で画像が表示されない

原因: storage.ymlの設定ミス

解決方法:

# config/environments/development.rb
config.active_storage.service = :local

5. 本番環境で画像が表示されない

原因: 環境変数が設定されていない、またはstorage.ymlの設定ミス

解決方法:

# Herokuで確認
heroku config

# 設定されていない場合
heroku config:set CLOUDINARY_CLOUD_NAME=your_cloud_name
heroku config:set CLOUDINARY_API_KEY=your_api_key
heroku config:set CLOUDINARY_API_SECRET=your_api_secret

まとめ

この記事では、Rails 7.2でCloudinaryを使った画像投稿機能の実装方法を解説しました。

実装のポイント

✅ Active StorageとCloudinaryの連携
✅ 単一画像と複数画像の扱い方
✅ 画像の最適化(WebP変換、リサイズ)
✅ パフォーマンス最適化(Eager Loading、キャッシュ)
✅ ドラッグ&ドロップ対応
✅ 本番環境へのデプロイ

次のステップ

  • 画像の直接アップロード実装
  • 画像の一括削除機能
  • 画像のタグ付け機能
  • AIによる画像認識・タグ自動生成

参考リンク:

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?