はじめに
今回初めてリリースした個人開発のサービスに関する記事を書いてみました。
リリースしてから時間は経ってしまったのですが、
機能追加も一段落したので復習の意味も込めてまとめてみた次第です。
実務未経験で現在スクールで勉強中の身でありますので
間違いなどあれば教えていただけると幸いです。
サービス名・GitHub
サービス名:誰でもデザイン
GitHub:https://github.com/yuita-yoshihiko/design_support
開発に際して意識した基本的なことや技術選定の理由などは
以前に書いた記事にまとめてあるのでよかったらこちらもご覧ください
このサービスを作ろうと思った理由
私は前職で金融機関に勤めており、
多くの中小企業の方々と関わる中でコロナの影響もあり
ネットを利用したビジネス路線の開拓ニーズはかなり高まっているように感じていました。
その中で自社サイトを立ち上げて販促活動を行なっている事業者さんもありましたが
中小企業はデザイナーを雇う資金力がなく、
自社社員がWebサイトの制作を行なっているところも多かったです。
しかしせっかくサイトを開設することはできても運用するのは難しいし、
特にページの構成やデザイン面は調べても情報が多すぎて何が正解かわからない
という声をよく聞きました。
私自身デザインに興味があったため自分でも調べることがありましたが、
確かに情報量が膨大すぎて基本的な情報を整理するだけでもかなり苦労しました。
そんな経験からWeb系デザインの基本的な情報を集めたサービスを作ってみたいと思い
このサービス制作することを決めました。
サービス概要
このサービスはWeb系デザイン初学者向けの情報閲覧、検索サービスです。
主な機能は以下の通りです。
・情報の閲覧、検索機能
・情報のレコメンド機能(2種類)
・トレンド情報の閲覧
・情報のお気に入り、リスト化機能
・カラー抽出機能
・UI・UXクイズ
・星レビュー機能
・高レビュー情報のピックアップ
・情報追加時の通知機能
・GoogleMapを用いたデザインギャラリー
その他の機能・使用技術
その他の機能、主な使用技術は以下の通りです。
その他の機能
・ユーザー登録、ログイン機能
・Twitter,Googleログイン機能
・パスワードリセット機能
・利用規約、プライバシーポリシー
・お問い合せ機能
・管理者画面
主な使用技術
バックエンド
Ruby(3.1.3)
Ruby on Rails(7.0.4)
フロントエンド
TailwindCSS
daisyUI
インフラ
PostgreSQL
Heroku
Docker
docker-compose
GitHub Actions
API
Twitter API
Google API(ログイン)
Google Cloud Vision API(画像解析)
pixabay API(画像検索)
Google Map API(地図表示)
機能・技術説明
情報の閲覧・検索
まず主要機能である情報の閲覧、検索についてです。
トップページに情報ジャンルの一覧を表示しており、
そこからジャンルごとの情報一覧ページに遷移することができます。
情報はWeb、書籍、動画の3種類の媒体ごとに分けています。
最初はジャンル分けだけする予定でしたが、何かの情報を得たり勉強したりする際
人によって好みの媒体って色々違うよなとふと思いました。
そこでスクール内でそのことを話してみると賛同していただけた方が多かったので
ジャンルと媒体の2方面から情報を分類することに決めました。
情報のジャンル分けは情報にタグを設定して行なっています。
情報のタグ付けにはgem 'acts-as-taggable-on'
を使用しました。
実装は以下の記事を参考にして行っています。
中間テーブルを利用したタグ付けは以前にスクールの課題で実装したことがあったので
今回は初使用のgemを利用して実装してみました。
個人的にはかなり簡単にタグ付け機能を実装できるのでおすすめです。
ジャンルごとのページの切り替えは検索機能を実装するgem 'ransack'
を利用して
ジャンル名のリンクからジャンル名(タグ名)で情報を検索した結果に飛べるよう
実装しています。該当部分のコードは以下の通りです。
class DesignTipsController < ApplicationController
before_action :set_q
skip_before_action :require_login
def index
@design_tips = @q.result(distinct: true).preload(:tags)
@tag_list = DesignTip.tag_counts_on(:tags).most_used(20)
@list = List.new
return unless current_user
@lists = current_user.lists
end
#略
private
def set_q
@q = DesignTip.ransack(params[:q])
end
end
# 略
# 情報一覧ページのリンク部分
<div class='my-12 shadow bg-zinc-100 hidden xl:flex'>
<hr>
<div class="flex container mx-auto" >
<% @tag_list.each do|tag| %>
<%= link_to tag.name, design_tips_path(q: { tags_name_cont: tag.name }), { class: "text-base font-serif text-gray-500 hover:text-emerald-600 m-4" } %>
<% end %>
</div>
<hr>
</div>
# 略
媒体による分類は情報にmediumというカラムを持たせ、
それに以下のenumを定義しています。
enum medium: { web: 0, book: 1, movie: 2 }
情報媒体ごとのタブの切り替えをSPA化するために
Rails7から導入されたHotwireのstimulusというライブラリを利用しました。
<div data-controller="view">
<div class="flex justify-around pt-20">
<a class="tab is-active text-gray-500 hover:text-brown active:text-brown text-lg md:text-2xl font-serif mb-4 md:mb-6" aria-current="page" data-view-target="menu" data-action="view#menuClick">Webサイト</a>
<a class="tab not-active text-gray-500 hover:text-brown active:text-brown text-lg md:text-2xl font-serif mb-4 md:mb-6" data-view-target="menu" data-action="view#menuClick">書籍</a>
<a class="tab not-active text-gray-500 hover:text-brown active:text-brown text-lg md:text-2xl font-serif mb-4 md:mb-6" data-view-target="menu" data-action="view#menuClick">動画</a>
</div>
<div data-view-target="content" >
<div class="container mx-auto pt-3 grid sm:grid-cols-2 xl:grid-cols-3 gap-8 md:gap-12 xl:gap-16 mt-20 pb-16">
<% @design_tips.each do |design_tip| %>
<% if design_tip.medium == 'web' %>
<%= render 'design_tip', design_tip: design_tip %>
<% end %>
<% end %>
</div>
</div>
<div class="hidden" data-view-target="content" >
<div class="container mx-auto pt-3 grid sm:grid-cols-2 xl:grid-cols-3 gap-8 md:gap-12 xl:gap-16 mt-20 pb-16">
<% @design_tips.each do |design_tip| %>
<% if design_tip.medium == 'book' %>
<%= render 'design_tip', design_tip: design_tip %>
<% end %>
<% end %>
</div>
</div>
<div class="hidden" data-view-target="content" >
<div class="container mx-auto pt-3 grid sm:grid-cols-2 xl:grid-cols-3 gap-8 md:gap-12 xl:gap-16 mt-20 pb-16">
<% @design_tips.each do |design_tip| %>
<% if design_tip.medium == 'movie' %>
<%= render 'design_tip', design_tip: design_tip %>
<% end %>
<% end %>
</div>
</div>
</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "content"]
menuClick(event){
const menus = this.menuTargets
const current = event.currentTarget
const currentIndex = menus.indexOf(current)
const contents = this.contentTargets
menus.forEach((menu, index)=>{
if(current.classList.contains("not-active")){
menu.classList.remove("is-active")
menu.classList.add("not-active")
contents[index].classList.add("hidden")
}
})
if(current.classList.contains("not-active")){
current.classList.remove("not-active")
current.classList.add("is-active")
contents[currentIndex].classList.remove("hidden")
}
}
}
以下記事を参考にして実装しました。
この方はRails7系の記事を色々書いていらっしゃるみたいなので
Rails7系を用いて実装する場合は参考になるかと思います。
各ページに設置してある情報検索バーもransack
を使用して
情報のタイトルのあいまい検索を実装し、オートコンプリート化しています。
<%= search_form_for @q, url: search_design_tips_path, class: 'flex items-center gap-2 font-serif' do |f| %>
<%= f.search_field :title_cont, class: "w-44 md:w-48 md:w-64 h-12 outline-green-800 rounded-lg", placeholder: 'タイトル検索', autocomplete: "on", list: "titles", required: true %>
<datalist id="titles">
<% @search_design_tips.each do |design_tip| %>
<option value="<%= "#{design_tip.title}" %>"></option>
<% end %>
</datalist>
<button class="btn bg-green-700 hover:bg-green-600 text-white rounded-lg hidden md:inline-block">
<div class='flex items-center'>
<svg class="icons"><use xlink:href="#icons-search"></use></svg>
<div>検索</div>
</div>
</button>
<% end %>
情報のレコメンド機能(2種類)
情報をおすすめする機能は2種類実装しました。
1つ目は3つの質問を用意し、ユーザーの答えに応じた
おすすめの情報を表示する機能です。
質問の内容(Ask)、それに対する回答と回答コードA、B(Response)
質問3つに2通りずつ存在する回答の組み合わせ8通り(Answer)
をデータベースに保存しておき、それに紐付けた情報3つが表示されます
以下のビューで受け取ったResponseの情報をモデルで配列化し、
それをコントローラーで変数@answer_codeに代入。
Answerテーブルにあらかじめ保存してあるanswer_codeとの一致を確認し
Answerと紐づいている情報が表示されるロジックになっています。
<%= turbo_frame_tag 'ans' do %>
<%= form_with(url: root_path, method: 'get', remote: true) do |f| %>
<% @asks.each do |ask| %>
<div class='mb-10'>
<div class= 'font-serif xl:text-lg'><%= ask.ask_detail %></div>
<% ask.responses.each do |response| %>
<div class="my-2">
<%= f.radio_button "responses[#{ask.id}]", response.id, required: true%>
<%= label_tag "responses_#{ask.id}_#{response.id}", response.content %>
</div>
<% end %>
</div>
<% end %>
<%= f.submit '回答する', data: { turbo_frame: 'ans' }, class: "btn btn-sm bg-green-700 hover:bg-green-600 text-white font-serif font-light rounded-full" %>
<% end %>
<div class="container mx-auto gap-8 md:gap-12 xl:gap-16 pt-8">
<% @answer_design_tip.each do |answer_design_tip| %>
<% if answer_design_tip.answer.answer_code == @answer_code %>
<div class='pt-4'>
<%= render 'design_tips/design_tip', design_tip: answer_design_tip.design_tip %>
</div>
<% end %>
<% end %>
</div>
<% end %>
def self.create_response(params)
responses = params[:responses]&.values || []
responses.map { |response_id| Response.find(response_id).is_answer }.join
end
def ask
@asks = Ask.includes(:responses)
@answer_design_tip = AnswerDesignTip.preload(:answer)
@answer_code = Ask.create_response(params)
end
この機能はロジック部分よりもデータベースの設計に迷ったので
以下の記事を参考にしました。
最初は思いっきりアンチパターンを使ってしまっていました。。。
この記事などを参考にしながら正規化したおかげで
今後選択肢を追加することがあっても柔軟に対応できるように
なったかなと思います。
もう一つのおすすめ機能はユーザーがお気に入り登録した情報から
情報をピックアップしてトップページに表示するものです。
この機能ではユーザーがお気に入りした情報と同じタグを持つ情報を
既にお気に入りしている情報を除いて3つ表示するようにしています。
情報の抽出に使用しているコードは以下の通りです。
def self.recommended_for(user)
# お気に入り登録してある情報のidを配列として取得
design_tip_ids = user.likes.pluck(:design_tip_id)
# 配列化した情報のidに付けられているタグ名を取得
tag_names = ActsAsTaggableOn::Tag.joins(:taggings).where(taggings: { taggable_type: 'DesignTip', taggable_id: design_tip_ids }).pluck(:name)
# 取得したタグの付いている情報を既にお気に入りしている情報を除いて取得
DesignTip.tagged_with(tag_names, any: true).where.not(id: design_tip_ids).distinct
end
レコメンド機能はサービスを使っていただいた方からのフィードバックで
そういった機能があるといいと言う声をいただいたのでそれをもとに実装しました。
最近のWebサービスは何かしらの方法でおすすめ情報を表示したり
ランキング付けして食いつきが良さそうな情報を見やすい位置に配置したりして
掴みにしているサービスがほとんどなのでこういった機能は
メディア系のサービスには特に必須かなと思います。
トレンド情報の閲覧
こちらの機能はgem 'mechanize'
を利用してスクレイピングを行い
それを24時間キャッシュに保存して掲載しています。
キャッシュが時間切れになった場合再度スクレイピングを行い
情報を取得するようにしています。
この機能の実装経緯は後述します。
require 'mechanize'
class Scraping
def self.scrape_hoge
# キャッシュが存在する場合は、キャッシュからデータを取得して返す
cached_data = Rails.cache.read('hoge_data')
return cached_data if cached_data.present?
agent = Mechanize.new
page_hoge = agent.get('引用元サイトURL')
title = page_hoge.search('.title a')
img = page_hoge.search('img')
title_array = hoge.map { |node| { title: node.text, url: node.attr('href') } }
img_array = img.map { |node| { src: node.attr('src'), alt: node.attr('alt') } }
data = [title_array, img_array]
Rails.cache.write('hoge_data', data, expires_in: 24.hours)
data
end
# 略
end
namespace :scraping do
desc 'Scrape PhotoshopVIP data'
task photoshopvip: :environment do
Scraping.scrape_photoshopvip
end
desc 'Scrape WebDesignClip data'
task webdesignclip: :environment do
Scraping.scrape_webdesignclip
end
end
これらのスクレイピングの処理を本番環境ではHeroku Schedulerを利用して
24時間に一回実行されるように実装しました。
実装には以下の記事を参考にしています。
情報のお気に入り・リスト化機能
ログイン後は気に入った情報のお気に入り登録と、お気に入りした情報を
任意の名前をつけたリストに分類して管理することができるようになります。
こちらの機能は各所で利用している情報を表示するパーシャルに条件分岐を設定し、
お気に入り一覧ページのpathを通した場合にリストボタンが表示されるよう実装しました。
div class="bg-white flex md:flex-row rounded-lg shadow-lg">
<div class="flex flex-col place-content-between gap-2 p-4 lg:p-6">
<div class="text-brown text-xl font-serif hover:text-yellow-900">
<%= link_to design_tip.title, design_tip.url, target: :_blank, rel: "noopener noreferrer" %>
</div>
<p class="text-gray-500 text-sm"><%= design_tip.guidance %></p>
<div class="my-5 flex place-content-between">
<div class='flex gap-1 md:gap-3'><%= render 'tag_list', tag: design_tip.tags.pluck(:name) %></div>
<div class='flex gap-1 md:gap-2'>
<% if logged_in? %>
<%= render 'like_button', design_tip: design_tip %>
# 以下の部分で条件分岐させています
<% if request.path == likes_design_tips_path %>
<%= render 'list_button', design_tip: design_tip %>
<% end %>
<% else %>
<%= render 'design_tips/before_login_like'%>
<% end %>
</div>
</div>
</div>
</div>
表示されるリストボタンにdaisyUIのモーダル機能を利用し、
そのウィンドウの中でlist_design_tips_controllerの処理を呼び出し
情報を任意のリストに追加できるように実装しています。
<div>
<label for="my-modal-<%= design_tip.id %>" class="container text-gray-300 hover:bg-amber-100 rounded-lg flex items-center text-xs">
<svg class="icons-list"><use xlink:href="#icons-list_add_check"></use></svg>
<div>リスト</div>
</label>
</div>
<input type="checkbox" id="my-modal-<%= design_tip.id %>" class="modal-toggle" />
<div class="modal">
<div class="bg-white modal-box relative font-serif">
<label for="my-modal-<%= design_tip.id %>" class="btn btn-sm btn-circle bg-green-800 hover:bg-green-700 text-white absolute right-2 top-2">✕</label>
<h3 class="text-lg my-2">どのリストに追加しますか?</h3>
<div class="flex justify-center">
<div class="col-md-10 offset-md-1 col-lg-8 offset-lg-2">
<div class="p-12 md:p-18">
<%= form_with url: {controller: "list_design_tips", action: "create"}, class: 'items-end' do |f| %>
<%= hidden_field_tag :design_tip_id, design_tip.id %>
<%= f.select :list_id, options_from_collection_for_select(@lists, :id, :name) %>
<%= f.submit "追加する", class: 'badge bg-green-700 hover:bg-green-600 text-white' %>
<% end %>
<div class='mt-10'>
<%= link_to new_list_path, class:'flex justify-center text-gray-400 hover:text-green-800 active:text-green-900' do %>
<div>リスト作成ページへ</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
class ListDesignTipsController < ApplicationController
def index
@list_design_tips = ListDesignTip.includes(:list, :design_tip, :tags)
@lists = current_user.lists.includes(list_design_tips: :design_tip)
end
def create
@list = List.find(params[:list_id])
@design_tip = DesignTip.find(params[:design_tip_id])
if @list.design_tips.include?(@design_tip)
flash.now[:error] = 'その情報は既にリストに登録されています'
render :error, status: :unprocessable_entity
else
@list.design_tips << @design_tip
if @list.save
redirect_to list_design_tips_path, flash: { success: 'リストに情報を追加しました。' }
else
flash.now[:error] = '情報を追加できませんでした。'
render :index, status: :unprocessable_entity
end
end
end
def destroy
@list_design_tip = ListDesignTip.find(params[:id])
@list_design_tip.destroy
redirect_to list_design_tips_path, success: 'リストから情報を削除しました。'
end
end
この機能を実装することでお気に入りした情報を管理しやすくしています。
とりあえずお気に入りだけして後から見返そうと思ったとき
一箇所に全ての情報が混ざっていると見返しにくく、
なんでお気に入りしたのか忘れてしまうこともあるかなと考えたので
管理も簡単に細分化して行えるようにしました。
カラー抽出機能
この機能はGoogleCloudVisionAPIという画像解析のAPIを利用して
画像に多く使われている色を5色抽出する機能です。
お気に入りの画像や写真と同じ雰囲気のサイトを作りたい時や
ロゴやOGPなどを外部サービスに作成を依頼した時に
そこから色を抽出して配色をサポートすることを想定して実装しました。
専用のgem 'google-cloud-vision'
をインストールし
GoogleでAPIキー等を作成すると機能が使えるようになります。
APIキーの作成等は以下のサイトなどを参考にして行いました。
具体的なコードは以下の通りです。
class Image
include ActiveModel::Model
attr_accessor :image
validates :image, presence: true
validate :acceptable_image
# バリデーションの作成
def acceptable_image
return unless image
if image.size > 5.megabytes
errors.add(:image, '※5MB以下の画像を選択してください')
end
unless image.content_type.in?(%('image/jpeg image/png'))
errors.add(:image, '※条件に合う形式の画像を選択してください')
end
end
def self.get_image_colors(params)
require 'google/cloud/vision'
# 認証情報を設定
image_annotator = Google::Cloud::Vision.image_annotator do |config|
config.credentials = ENV['GOOGLE_APPLICATION_CREDENTIALS']
end
# 送信された画像ファイルを取得
image_file = params[:image][:image]
# 画像ファイルのパスを取得
image_path = image_file.tempfile.path
# APIを利用して画像から主要な5色を検出
response = image_annotator.image_properties_detection(
image: image_path,
max_results: 5
)
# APIからのレスポンスを取得
response.responses.first.image_properties_annotation.dominant_colors.colors
end
end
<%= turbo_frame_tag "color" do %>
<div class='container mx-auto'>
<div class='text-gray-600'>条件:jpeg(jpg)またはpng形式で5MB以下</div>
<div class='flex'>
<%= form_with(model: @image_restriction, url: images_path, data: { turbo_frame: 'color' }, remote: true, multipart: true) do |form| %>
<div class='flex gap-1 font-serif'>
<div class='flex flex-col'>
<%= form.file_field :image, required: true, class: 'file-input file-input-ghost w-full max-w-xs border border-gray-500 bg-white' %>
<% if @image_restriction.errors[:image].any? %>
<div class="error-message">
<% @image_restriction.errors[:image].each do |message| %>
<p class='text-red-500'><%= message %></p>
<% end %>
</div>
<% end %>
</div>
<%= form.submit "カラー抽出を行う", class: 'btn bg-green-700 hover:bg-green-600 text-white font-serif rounded-lg' %>
</div>
<% end %>
</div>
<% if @colors.present? %>
<div class='font-serif pt-8 text-gray-600 '>多く使われているのは以下の5色です。</div>
<div class='flex gap-4 pt-10'>
<% @colors.each do |color| %>
<div class='text-gray-600'>
# 取得した色を表示
<div style="background-color: rgb<%= "(#{color.color.red}, #{color.color.green}, #{color.color.blue})" %>;"> </div>
# レスポンスはRGB値で帰ってくるのでそれを16進数にしてカラーコードに変換
<div><%= "##{color.color.red.round.to_s(16).rjust(2, '0')}#{color.color.green.round.to_s(16).rjust(2, '0')}#{color.color.blue.round.to_s(16).rjust(2, '0')}" %></div>
# RGB値をそのまま出力
<div><%= "RGB:(#{color.color.red.round}, #{color.color.green.round}, #{color.color.blue.round})" %></div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
何でもかんでも送信されると危険なので画像サイズと形式でバリデーションをかけています。
色の表示形式を変換するのは結構面倒なのでユーザーの手間を減らすことも考えて
色を示す際によく使用されているカラーコードとRGB値の両方を表示するようにしました。
色抽出の処理実装は参考情報が少なすぎて相当苦労しました。。。
GoogleのAPIは全体的にRubyで利用する際の情報が少ないような気が、、、
参考にしたい画像を持っていないというユーザー向けに
画像検索機能も同時に実装しました。
こちらはpixabayAPIを利用して実装しました。
class PixabayApi
#基本URLとAPIキーを定義
BASE_URL = 'https://pixabay.com/api/'
API_KEY = Rails.application.credentials.dig(:pixabay, :key)
def self.search_images(query)
# Faradayを使用してPixabayAPIと通信するための接続オブジェクトを作成
conn = Faraday.new(url: BASE_URL)
# APIからデータを取得
response = conn.get('', { key: API_KEY, q: query, per_page: 12 })
# 取得したjsonデータをRubyのハッシュに変換
json = JSON.parse(response.body)
# 検索結果が存在するか確認し、存在する場合は検索結果のURLの配列を作成
if json['hits']
json['hits'].map do |image|
image['webformatURL']
end
else
[]
end
end
end
漠然とこういうデザインを作りたいと思っているときや
デザインに何から入っていったらいいかわからない時に
まず使う色を決めることで少しでもデザインを行うイメージが
明確になるかなと考えたので色に特化した機能を実装しました。
デザインを何から始めたらいいかわからないという人もこのサービスの
ターゲットにしているので、こういったサポート機能も
今後充実させていけたらなと考えています。
UI・UXクイズ
続いてUI・UXに関連するクイズ機能についてです。
こちらのデータベース設計は先ほど紹介した質問からおすすめ情報をピックアップする機能と
同じような構成にしています。
データベースさえしっかり作ってあげればあとはユーザーからの回答に応じて
正解不正解の判定を行い、turboを利用して動的に結果を表示してあげるだけなので
割と簡単に実装できました。
問題は全てChatGPTを利用して作成しています。
コーディングの補助はもちろんですが
こういったコンテンツを簡単に作れるとサービスの幅を広げやすいので
非常に便利ですね。
UI・UXについては、よく聞く言葉ではあるけど実際具体的に
どういった内容のことなのか把握できていない人も多いかなと思ったので
(実際自分も勉強中です、、)クイズ形式で手軽に学べるようにしてみました。
実際に勉強してみるとデザイン以外の分野に通ずる内容も多々あるので
これをきっかけに勉強してみようかなと思う人が増えるような機能に
ブラッシュアップしていきたいですね。
星レビュー機能
ログイン後のユーザーは情報に星レビューをつけられるようになっています。
点数は5点満点で、ユーザーがレビューした点数の平均を算出して表示しています。
表示ロジック
def average_score(design_tip_id, percentage: false)
unless self.reviews.empty?
score = reviews.average(:score).to_f.round(1)
percentage ? score * 20 : score
else
0.0
end
end
<div class="flex text-sm gap-1 text-gray-500">評価 :
<span class="star-rating">
<span class="star-rating-front" style="width: <%= design_tip.average_score(design_tip.id, percentage: true) %>%;">★★★★★</span>
<span class="star-rating-back">★★★★★</span>
</span>
<div><%= design_tip.average_score(design_tip.id) %>点</div>
</div>
レビュー登録
def create
# 既にレビューのデータが存在するか確認
@review = Review.find_or_initialize_by(user_id: current_user.id, design_tip_id: review_params[:design_tip_id])
# ユーザーが新たに登録しようとしているレビューの値を取得
@review.score = review_params[:score]
if @review.save
redirect_to request.referer, success: '評価を登録しました。'
else
redirect_to request.referer, error: "評価が登録できませんでした。"
end
end
private
def review_params
params.require(:review).permit(:design_tip_id, :score)
end
レビュー登録用のモーダル
<label for="my-modal-<%= design_tip.id %>" class="text-sm">評価する</label>
<input type="checkbox" id="my-modal-<%= design_tip.id %>" class="modal-toggle" />
<div class="modal">
<div class="modal-box relative">
<label for="my-modal-<%= design_tip.id %>" class="btn btn-sm btn-circle bg-green-800 hover:bg-green-700 text-white absolute right-2 top-2">✕</label>
<div class="flex justify-between items-center my-8">
<div class="text-gray-500">この情報の評価は?</div>
<%= form_with model: @review, url: design_tip_reviews_path(design_tip) do |f| %>
<div class="flex gap-4">
<div class="post_form">
<%= f.radio_button :score, 5 ,id: "star1-#{design_tip.id}"%>
<label for="star1-<%= design_tip.id %>">★</label>
<%= f.radio_button :score, 4 ,id: "star2-#{design_tip.id}"%>
<label for="star2-<%= design_tip.id %>">★</label>
<%= f.radio_button :score, 3 ,id: "star3-#{design_tip.id}"%>
<label for="star3-<%= design_tip.id %>">★</label>
<%= f.radio_button :score, 2 ,id: "star4-#{design_tip.id}"%>
<label for="star4-<%= design_tip.id %>">★</label>
<%= f.radio_button :score, 1 ,id: "star5-#{design_tip.id}"%>
<label for="star5-<%= design_tip.id %>">★</label>
<%= f.hidden_field :design_tip_id, value: design_tip.id %>
</div>
<%= f.submit '評価を送信', class: "submit text-gray-400 rounded-lg mt-1" %>
</div>
<% end %>
</div>
</div>
</div>
この機能では初めてレビューを行う場合は新規のレビューを作成、
既にレビューしたことがある場合はその値を更新するように実装しています。
ビューに表示される星の欠け具合は
灰色の星の上に黄色の星を配置して、黄色の星が表示される割合を
レビューの点数をパーセンテージに変換(5点を100%とすると何%か)し、
それと連動させて表現しています。
以下参考情報です。
高レビュー情報のピックアップ
レビューの点数が高い情報はトップページに表示されるようにしています。
def self.sort_by_average_score
DesignTip.all.sort_by { |tip| -tip.average_score(tip.id) }
end
このコードで情報をレビューの平均点の降順に並べ替え、
上から3つを画面に表示しています。
意見やコメントを投稿するとなると面倒に感じる人も増えそうなので
今回は5段階評価のみを実装しました。
レビュー機能はユーザーからの意見を汲み取ることもでき、
今後追加する情報の良い参考資料にもなるかなと考えています。
継続して運用を続けていくことを考えると、
こういった機能以外にも何か手軽にユーザーからのフィードバックがもらえる機能を
実装してみてもいいのかなとも思ってます。
情報追加時の通知機能
新しく情報が追加されたらその情報をすぐに確認できるように
通知機能を実装しました。
未読の通知があると通知ボタンの右上にオレンジのアイコンが表示され、
既読になると消えるように実装しています。
管理者画面で新たな情報を作成した際に
その情報と紐づいた通知(notification)が作成されるように実装しています。
def create
@design_tip = current_user.design_tips.build(design_tip_params)
if @design_tip.save
# 通知を作成
DesignTip.create_notification(@design_tip)
redirect_to admin_design_tip_url(@design_tip), success: t('defaults.message.created', item: DesignTip.model_name.human)
else
render :new, status: :unprocessable_entity
end
end
def self.create_notification(design_tip)
Notification.create(design_tip_id: design_tip.id, title: design_tip.title, url: design_tip.url)
end
after_createコールバックを利用してnotificationが作成されると同時に
create_notification_readsメソッドを走らせ
未読状態(checkedがfalseの状態)の通知(notification_reads)も一緒に作成しています。
class Notification < ApplicationRecord
after_create :create_notification_reads
default_scope -> { order(created_at: :desc) }
belongs_to :design_tip
has_many :notification_reads, dependent: :destroy
validates :title, presence: true
validates :url, presence: true
def create_notification_reads
User.where(role: 0).find_each do |user|
notification_read = user.notification_reads.build(notification: self, checked: false)
notification_read.save
end
end
end
通知ページに遷移すると、未読の通知の情報を全て取得し、
既読状態に変更するよう実装しています。
class NotificationsController < ApplicationController
def index
# ログイン中のユーザーに対する通知を全て取得する
@notifications = current_user.notifications
@notifications.each do |notification|
# 未読の通知を抽出
notification_read = current_user.notification_reads.find_by(notification_id: notification.id)
# 未読の通知を全て既読状態に更新する
notification_read.update(checked: true) if notification_read.present?
end
end
end
通知ページへの遷移ボタンの上に未読があればオレンジのアイコンが表示されるように
実装した部分です。
<%= link_to(notifications_path) do%>
<div class='box'>
<div class='text-green-800'>
<% if current_user.notification_reads.exists?(checked: false) %>
<svg class="icons-circle"><use xlink:href="#icons-circle"></use></svg>
<% end %>
<svg class="icons-notifications"><use xlink:href="#icons-notifications"></use></svg>
</div>
</div>
<% end %>
通知機能は以下の記事を参考に実装しました。
こちらの記事はいいね、フォローなどユーザー同士でアクションを起こした際の通知を作成する際は
かなり役に立つと思います。
この機能については情報が登録されると同時に
多くの情報がデータベースに作成される(ユーザー全員に通知が作成される)形になるので
もっと上手いやり方があるかなと色々試しましたが実装できずでした、、、
ここはバッチリはまる実装ができるように
今後も色々調べていこうと思います。
実際に運用されている大規模なサービスでこういった全体への通知の既読管理は
どう行っているのかすごく気になります。。。
GoogleMapを用いたデザインギャラリー
サービスのアクセントの意味も込めてユニークな機能を一つ取り入れたかったので
GoogleMapを用いて見た目のインパクトのある機能を実装しました。
以下の記事を参考にしてGoogleMapAPIを導入し、
ピンにモーダルを埋め込んで各地域の優れたデザインの
Webサイトへのリンクを掲載しました。
サービス全体をシンプルに仕上げたかったのですが、
1ページくらい他のページと見た目が違うとユーザーの目も楽しませられるかなと考えて
地図を全面に配置したレイアウトにしました。
これが正解かどうかはわかりませんが、サービス全体を俯瞰してバランスを整えていく力は
今後身につけていきたいです。
こだわりポイントなど
デザインを主に扱うサービスということで、
サービス自体のデザインもこだわって作りました。
フォント
フォントは少し綺麗めな印象を与えるためにtailwindcssにデフォルトで入っている
font-serif
というクラスをメインで使用しています。
説明文などの長めの文章は読みやすさも考慮してノーマルフォントにしました。
配色・画像
シンプルで整ったデザインにするために使用する色の数は最小限にしています。
ただサービス全体としてあまりにもシンプルすぎると
手抜きというか間の抜けた印象を与える可能性もあると考え
適度に画像を取り入れメリハリをつけることを意識しました。
フリー画像などは容量が大きいものもありそのまま使うとかなり読み込みが遅かったので
以下の画像圧縮サービス等を利用しサイズを小さくしました。
スカした顔のパンダがいい仕事してくれます。笑
フレームワーク・ライブラリの扱い
tailwindcssというCSSフレームワークとdaisyUIというライブラリを利用しているのですが
いかにも個人サービスといった印象を少しでも抑えるために極力そういった機能の使用感を
無くす工夫も施しました。
具体的には以下のようなことを行っています
・ボタンのアニメーションの無効化
・コンポーネントをそのまま使わない
・テンプレをフル活用する
daisyUIのボタンはデフォルトでは上のように押すと動きが出るようになっています。
シンプルで落ち着いた印象のサービスに仕上げたかったこともあり、
今回この機能は無効化しました。
ボタンやドロップダウンなどのコンポーネントもそのまま使うのではなく
サービスに合わせて少しカスタマイズして使用しています。
具体的には
・フォントをサービスに合わせたものに変更する
・角の丸みをシャープにする
・使い所によってボタンの大きさを変化させる
などです
テンプレの活用も重要かなと思います。
1からページを全て作るとかなり時間もかかる上に
クオリティの高いものを作るのも難しいです。
私の場合はほぼ全てのページを色々なテンプレをカスタマイズして作成しています。
実装スピードも上がるし簡単にそれっぽく作ることもできるので
どんどんテンプレは活用していくべきかなと思います。
toCのWebサービスは特に見た目で判断される部分が大きいと思います。
もちろん機能の実装が第一ですが、
見た目が崩れているサービスはなんとなく信頼できないと思われることもあると思うので
シンプルでいいのでデザインを整えることは今後もサービスを作る時には心掛けたいです。
ユーザー登録
また気軽にサービスを使ってもらうためにサービス内にある機能はほとんど
ログイン無しで使用できるようになっています。
ユーザー登録してもらう場合も簡単に登録できるようにメールアドレスでの登録に加えて
SNS認証を取り入れました。
最初はTwitter認証だけ導入していましたが、
Twitter社のゴタゴタでTwitterユーザーの減少やAPI自体が使用できなくなる懸念があったため
Google認証を途中で追加しました。
案の定TwitterAPIは以前のように使用することができなくなったので
今はGoogleログインのみ採用しています。
認証は全てgem'sorcery'
を利用して実装しました。
sorcery.rbでそれぞれのAPIキーやコールバックURLの設定を行います。
config.twitter.user_info_mappingの部分では
SNSのユーザー情報をデータベースに保存する形に変換しています。
今回はTwitterでログインする場合は
Twitterのscreen_name(@から始まるTwitterのユーザー名)をemailカラムに、
name(Twitter上のニックネーム)をnameカラムに保存しています。
Googleの場合は
Googleアカウントに登録してあるメールアドレスをemailカラムに、
Googleのアカウント名をnameカラムに保存しています。
Rails.application.config.sorcery.configure do |config|
# 略
config.external_providers = %i[twitter google]
# 略
config.twitter.key = Rails.application.credentials.dig(:twitter, :key)
config.twitter.secret = Rails.application.credentials.dig(:twitter, :secret_key)
config.twitter.callback_url = Settings.sorcery[:twitter_callback_url]
config.twitter.user_info_mapping = {email: 'screen_name', name: 'name'}
# 略
config.google.key = Rails.application.credentials.dig(:google, :client_id)
config.google.secret = Rails.application.credentials.dig(:google, :client_secret)
config.google.callback_url = Settings.sorcery[:google_callback_url]
config.google.user_info_mapping = {email: "email", name: "name"}
oauths_controllerで処理の切り分けを行います。
class OauthsController < ApplicationController
skip_before_action :require_login
def oauth
login_at(auth_params[:provider])
end
def callback
# Twitterログイン時にユーザーがログインをキャンセルした場合
provider = auth_params[:provider]
return redirect_to(root_path, notice: 'ログインをキャンセルしました') if auth_params[:denied].present?
# ログイン処理に進んだ場合
@user = login_from(provider)
if @user # 登録済みユーザー場合
redirect_to root_path, success: "#{provider.titleize}アカウントでログインしました"
else # 新規ユーザーの場合(priveteメソッド内の処理にて新規ユーザー登録を行った上でログインされます)
create_user_and_login(provider)
end
# 何らかのエラーが発生した場合
rescue ActiveRecord::RecordNotUnique
redirect_to root_path, error: "#{provider.titleize}ログインに失敗しました。他の方法でユーザー登録されている可能性があります。"
rescue
redirect_to root_path, error: "#{provider.titleize}アカウントでのログインに失敗しました"
end
private
def auth_params
params.permit(:code, :provider)
end
def create_user_and_login(provider)
@user = create_from(provider)
reset_session
auto_login(@user)
redirect_to root_path, success: "#{provider.titleize}アカウントでログインしました"
end
end
何らかのエラーが発生した場合の処理は2パターンに分けました。
ActiveRecord::RecordNotUniqueとはDB内でのユニーク制約に違反した場合に発生するエラーです。
このエラー発生が想定される場面は、既にメールアドレスでユーザー登録を行っているユーザーが
新たにGoogleアカウントで新規ユーザー登録を行おうとした場合です。
この場合emailカラムにvalidationでユニーク制約をかけているので上記エラーが発生します。
こういうケースが多発することはないと思いますが、この場合はエラーメッセージに
「他の方法でユーザー登録されている可能性があります。」という文言を追加し、
ユーザー登録済みであることを仄めかすようにしています。
それ以外のエラーの場合は共通のエラーメッセージが表示されるようになっています。
APIキーなどの機密情報はcredentialsで保護しています。
twitter:
key: APIキーの値
secret_key: APIシークレットキーの値
google:
client_id: クライアントidの値
client_secret: クライアントシークレットの値
コールバックURLは環境ごとに異なるのでgem'config'
を利用して管理しています。
# development.yml
sorcery:
twitter_callback_url: 'http://localhost:3000/oauth/callback?provider=twitter'
google_callback_url: 'http://localhost:3000/oauth/callback?provider=google'
# production.yml
sorcery:
twitter_callback_url: 'https://本番環境のドメイン/oauth/callback?provider=twitter'
google_callback_url: 'https://本番環境のドメイン/oauth/callback?provider=google'
実装にあたって以下記事を参考にしました。
私はCredentialsに関する知識も乏しかったので
以下の記事も参考にしました。
掲載する情報の選定
取り扱う情報の選定についても色々と工夫した点があります。
まず情報を多く掲載しすぎないことにしました。
元々初学者向けの情報検索サービスなので初学者にとって有益だと思える記事のみを
掲載する予定ではありましたが量についてはあまり深く考えていませんでした。
しかし、今は誰もがWeb上で情報を閲覧することに慣れています。
例えば Googleで検索するとき
このページネーションの10のところまで隈なく見る人って多分ほぼいません。笑
現代のIT社会ではページネーションの後半にある情報は
ほとんどの人が感覚的、無意識的に古いとか有益ではないと判断しており
後半に配置するだけで情報の価値が下がってしまうと考えました。
多くの情報を扱う必要がある場合は仕方ありませんが、
今回作成したサービスについては情報量よりも質を重視したかったので
情報の細分化や厳選を行い
ストレスなく見れる範囲で1ページに情報が収まるように実装しました。
(具体的には1ページ12個以下程度)
情報のメンテナンス
掲載している情報については手動で登録しており、今後も手動で
メンテナンスしていく予定です。
理由は大きく2つで
・初心者向けの有益な情報の選定を自動化するのは難しい
・情報の質が担保できないとこのサービスの存在意義がなくなる
と判断したためです。
仮に何かしらの方法で機械的に情報を取ってきたとして、
タイトルに「初心者向け」のような文言が入っていても実際中身が良いものとは限りません。
初心者向けと謳ったサービスですし
情報の質を担保しなければググれば良くね?という話になってしまうので
掲載する情報に関しては1ユーザーでもある自分の目で選ぶことにしました。
また、基本や初歩の知識や情報というのはトレンドが変わっても
大きく変化することはないので頻繁な更新は必要なく、
メンテナンスは月1回程度で十分と判断しました。
この頻度であれば負担は少なく、手動でも運用可能だという結論に至りました。
トレンド情報ページ
ただそれだと更新の少なさ故にユーザー離れが起こる懸念があったので
トレンド情報ページを取り入れ、そこで日々更新されるコンテンツを取り入れました。
こちらについてはスクレイピングのサイクルを自動化しているため
管理も容易です。
これにより情報の質を担保しつつある程度の更新性もあるサービスを目指しました。
ページ構成
今回作成したサービスはトップページから全ての機能にアクセスできるようにしています。
トップページ → 何かしらの機能まででそれより深いリンクを作成しないようにしました。
リンクが深くなっていくとユーザーが今サービスの中のどこにいるかわからなくなったり
目当ての機能を探しにくくなってしまうと考えたためこのような構成にしました。
同じように機能を実装するにしてもリンクの仕方一つで
使用感はかなり変わってくると思うので
ユーザー側の視点に立ってサービスを作ることは今後も意識していきたいです。
小ネタ
ちょっとしたことですがやっておいてよかったのかな?と思うことを少し書いてみます。
ユーザー登録後のオートログイン
メールアドレスでユーザー登録後、再びログイン画面でメールアドレスと
パスワードを入力するのは手間だなと感じたので
ユーザー登録後はそのままログインできるように実装しました。
実装はめちゃくちゃ簡単です。
def create
@user = User.new(user_params)
if @user.save
auto_login(@user)
redirect_to root_path, success: t('.success')
else
flash.now[:error] = t('.fail')
render :new, status: :unprocessable_entity
end
end
auto_loginの記述を入れてリダイレクト先をトップページにでも持っていけば
それだけで実装できます。
ユーザーからしても一手間無くしてすぐにログイン後の機能が使える方がいいと思うので
オートログインは実装した方がいいかなと思います。
セッションタイムアウト
今度は逆に自動的にログアウトする機能についてです。
ログインしたらしっぱなしというのはセキュリティ的によろしくないかなと思ったので
一定時間が経つと自動的にログアウトされるようにしました。
これもsorceryを使っているなら簡単に実装できます。
Rails.application.config.sorcery.submodules = [:external, :reset_password, :session_timeout]
Rails.application.config.sorcery.configure do |config|
# 略
config.session_timeout = 3.hour
# Use the last action as the beginning of session timeout.
# Default: `false`
#
config.session_timeout_from_last_action = true
# 略
submodulesにsession_timeoutの記述を追加し
後はコメントアウトしている該当部分を有効にし、任意の時間設定を
行うだけです。
真似をする
これは小ネタと言えるかわかりませんが笑
テンプレを使うのと同じように1から考えるより既存のものの真似をする方が
実装スピードは確実に上がります。
デザイン面で言うと私はUdemyやInstagram、食べログなどの見た目を参考にして
このサービスを作りました。
機能面でも類似サービスからヒントを得ながら実装していったので
アイディア出しの時間は結構短縮できたのではないかと思います。
サービス作りのヒントは色々なところにあると思います。
丸パクリはもちろんNGですが参考にするのは全然OKですし
そのような実装方法でも作り込んでいくと意外としっかり
独自性が出てくるものだなと実際サービスを作っていて思いました。
使えるものはどんどん使っていきましょう!
さいごに
今回初めて自分で作ったサービスをリリースしましたが、気付けたことが多かったです。
何より大きかったのが自分は仕組みを作るのが好きだということに気付けたことですね。
今回のサービス自体デザインをテーマに扱っているものですし
冒頭に申し上げた通り、私自身デザインに興味もあったためこのサービスを作りました。
今回のサービスの見た目の部分に関してもかなりこだわったつもりです。
ですから自分は見た目を作るフロント部分の方が楽しめるのかなと思っていましたが
実際サービスを作ってみると裏側のロジックを考えたり
仕組みを学んだりするのが好きなのかなと思うようになりました。
今回のサービスの中に取り入れた自動的に情報が更新される機能や
質問をもとにおすすめの情報を提供する機能など
お世辞にも高度なロジックとは言えませんが、
自分なりに方法を考えたり、ユーザーから意見を聞いたりして
一つ一つの機能に意味を込めて
サービスに合った形に落とし込んでいく過程はとても楽しかったです。
自己分析じゃないですが、自分がプログラミングの何が好きなのかを理解するためにも
これからも簡単でもいいので色々サービスを作ったり、様々なサービスに触れていきたいと
思います。
最後までご覧いただきありがとうございました。