【個人開発】AIイラスト投稿サイト「ちびキャラパレット」を作った話

# はじめに

**ちびキャラ専用のAIイラスト** を投稿できるWebサービス **「ちびキャラパレット」** をリリースしました😸

3月末から開発を開始し、4月23日に正式リリースしました。
未完成の機能が色々ありましたが、大手競合サイトの障害が4月13日に発生[^1] したのを契機に昼休みに急いでβ版をリリースしました。

ちょっとした工夫点や躓いた点なども書いております。

# リリースしたサービス
[ちびキャラパレット-ちびキャラ専用のAIイラスト投稿サイト](https://chibipalette.com/)
![スクリーンショット 2023-04-25 010119.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122701/26e604a0-b37f-d399-7b82-32f180a327bd.png)
![スクリーンショット 2023-04-25 010205.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122701/b5d64c2c-aa45-bff6-4f9a-efadb0adac57.png)

# 方針
- できるだけ早く(1人月=160時間)以内にリリースする
- 格安レンタルサーバーで安く済ます
- できるだけ慣れた技術を使う(Laravel + blade)
- ユーザーとイラストが万単位になっても破綻しないようにする(そこまで増えるかは別として)

## 掛かった工数
Twitterで自分宛てにDMして記録しました

![スクリーンショット 2023-04-25 013429.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122701/55275c31-acdf-04c9-c04f-453545e4389a.png)


### 正式リリースまで合計130.5時間
**最初の20時間**
- サイト名決める
- 機能考える
- 画面一覧考える
- DB設計
- 環境構築
- migrationファイル作成
- Breezeインストール

**残り110.5時間**
- 省略

## レンタルサーバー
レンタルサーバーはすでに使っていたものと共用なので、実質ドメイン代だけです。
- ロリポップ
→ 550円/月
- ドメイン
→ 750円/年

## 技術周りについて
### 技術選定について
早く開発を終わらせたかったが **何か新しいことを1つは取り入れたい** という想いで**Breeze** を使ってみることにしました。(前回の個人開発ではTwitter認証を使用)Vue.jsは2年以上ブランクがあったので爆速開発のために見送りました。

### 使った技術
- Laravel 10
- PHP 8.1(リリース直前にレンタルサーバーが8.2に対応😿)
- MySQL 5.7
- TailwindCSS

### ユーザーとイラストが増えたときの考慮
全ての画像を1つのディレクトリにぶち込むと問題がありそうなのは容易に想像できるので、ユーザーごとに分けてみます。
```
└─ storage
   └─ images
      ├─ 000000001(ユーザーID)
      ｜ ├─ 001.webp
      ｜ └─ 002.webp
      └─ 000000002
         ├─ 001.webp
         ├─ 002.webp
         └─ 003.webp
```

しかし、1つのディレクトリ直下に数万個のファイルやディレクトリが存在すると、ファイルの検索や読み込み、削除などの処理が遅くなる恐れがあります。
数万ユーザーが登録するかもしれないので対策が必要ですね😹
というわけで、下記のようなイメージで1000ユーザーごとにディレクトリを分割しました。

```
└─ storage
   └─ images
      └─ 000
         └─ 000
            └─ 001
               ├─ 001.webp
               ├─ 002.webp
               └─ 003.webp
```

### 画像の形式
webpっていつの間にか全てのブラウザで使えるようになっていたんですね **(IEはもうこの世に存在しないものとする)** 。
たくさん投稿されたときのサーバーのストレージや、たくさん表示するときの転送量などの心配が減るのでwebpが使えるのは助かりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122701/19642f3a-280d-01bc-c8a2-6d1201864ab1.png)


# ChatGPT先生
無料版のChatGPT先生を使って工数を削減しました。
Webサイトの概要を伝え、サイト名、規約、機能の一覧、DB設計など、あらゆるお手伝いをしてもらいました。
おそらくChatGPT先生がいなかったら160時間で終わらなかったでしょう。

## ChatGTPに入力した内容
```
#Webサービス概要
- Stable Diffusion, Midjourney, Novel AIなど、自動画像生成AIを使って作成された画像を投稿するWebサービスである。
- このWebサービスは会員登録したユーザーのみ画像を投稿できる。
- 投稿できる画像は、ちびキャラと呼ばれる2頭身～3頭身くらいのかわいいキャラクターのみとする。

(以下、レンタルサーバーの規約や主要な機能の説明など)
```

## 出力された内容(軽く修正しました)
そのままでは使えないので、足りない情報はさらに補って使いました。
自力で一から作成するより圧倒的に楽でした。
特にmigrationファイルの作成からmodelのリレーション設定なんかは楽に感じました。
```
#機能一覧
##ユーザー登録機能
- ユーザー名、メールアドレス、パスワードなどの登録情報を入力し、アカウントを作成する機能
- 入力された情報のバリデーション機能（重複チェック、形式チェックなど）
- アカウント情報の保存機能
##ログイン/ログアウト機能
- 登録済みユーザーがログインできる機能
- ログイン状態を保持するためのセッション機能
- ログアウト機能
##画像投稿機能
- ログインしたユーザーのみが画像を投稿できる機能
- 画像アップロード機能（JPEG、PNG、GIF形式などに対応）
- 画像にタイトル、タグ、モデル名などをつけて投稿できる機能
- 投稿された画像の保存機能
- 投稿された画像の表示機能
##画像一覧表示機能
- 投稿された画像を一覧表示する機能
- タグやキーワードで検索する機能
- 投稿者で絞り込みする機能
- ソート機能（投稿日時やお気に入り数などによるソート）
##お気に入り機能
- ログインしたユーザーがお気に入り画像を登録できる機能
- お気に入り登録された画像の表示機能
##プロフィール編集機能
- ユーザーがプロフィール情報を編集できる機能
- アバター画像のアップロード機能
- プロフィール情報の保存機能
##ページング機能
- 多数の画像が投稿された場合、ページング機能によりページを分割する機能
##バックアップ機能
- 投稿された画像やユーザー情報をバックアップする機能
##管理者機能
- 管理者がユーザー情報や投稿された画像を削除できる機能
- 管理者が不適切な投稿を報告できる機能

#画面一覧
##ログイン画面
- メールアドレスとパスワードでログインする
##新規会員登録画面
- メールアドレスとパスワードを入力して新規登録する
##画像投稿画面
- タイトル、タグ、モデル名、画像などを入力して投稿する
##マイページ画面
- 投稿数、被お気に入り数、アバター、プロフィール、投稿画像一覧などを表示する(ログイン状態)
##画像一覧表示画面
- 投稿された画像を一覧表示する。検索やソートなどを実装する
##プロフィール編集画面
- アバターやプロフィールを編集する(ログイン状態)
##管理者画面 (ユーザー情報や投稿された画像を削除する機能、不適切な投稿を報告する機能)
- 管理者画面(ログイン状態)

#DB設計
##usersテーブル
- id: ユーザーID（int, PK, AI）
- name: ユーザー名（varchar）
- email: メールアドレス（varchar）
- password: パスワード（varchar）
- remember_token: ログイン情報保存用トークン（varchar）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##avatarsテーブル
- id: アバターID（int, PK, AI）
- user_id: ユーザーID（int, FK）
- path: 画像ファイルパス（varchar）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##postsテーブル
- id: 投稿ID（int, PK, AI）
- user_id: ユーザーID（int, FK）
- title: タイトル（varchar）
- model_name: モデル名（varchar）
- description: 説明文（varchar）
- twitter: Twitterの記事URL（varchar）
- created_at: 投稿日時（timestamp）
- updated_at: 更新日時（timestamp）
##post_tagテーブル
- id: タグ付き投稿ID（int, PK, AI）
- post_id: 投稿ID（int, FK）
- tag_id: タグID（int, FK）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##tagsテーブル
- id: タグID（int, PK, AI）
- name: タグ名（varchar）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##favoritesテーブル
- id: お気に入りID（int, PK, AI）
- user_id: ユーザーID（int, FK）
- post_id: 投稿ID（int, FK）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##reportsテーブル
- id: レポートID（int, PK, AI）
- user_id: ユーザーID（int, FK）
- post_id: 投稿ID（int, FK）
- reason: 不適切な投稿の理由（varchar）
- created_at: 登録日時（timestamp）
- updated_at: 更新日時（timestamp）
##access_logs
- id: アクセスログID（int, PK, AI）
- post_id: 投稿ID（int, FK）
- accessed_at: アクセス日時（timestamp）
##rankings
- id: ランキングID（int, PK, AI）
- post_id: 投稿ID（int, FK）
- ranking_type: ランキング区分（varchar）
- ranking_order: ランキング順位（int）
- access_count: アクセス数（int）

上記のテーブルには、以下のカラムが含まれています。
PK：Primary Key
FK：Foreign Key
AI：Auto Increment
```

## 機能追加も楽々
例えば、下記のスライドショーコンポーネントは左右のボタンをクリックしたとき、瞬時に切り替わってしまいます。
```slideshow.blade.php
@props(['posts'])

<div class="flex items-center justify-between bg-gray-100 py-4">
    <button class="flex items-center justify-center w-10 h-10 px-2 mx-2 rounded-lg border text-gray-700 bg-gray-300 hover:bg-gray-300 focus:outline-none" onclick="prevSlide()">
        <span class="font-bold">&lt;</span>
    </button>
    <div class="flex overflow-x-auto space-x-4 overflow-x-hidden">
        @foreach ($posts as $post)
            <div class="w-32 h-32 flex-none bg-gray-300 bg-center bg-cover rounded-lg shadow">
                <a href="{{ route('posts.show', ['ulid' => $post->ulid]) }}">
                    <img
                        id="preview"
                        src="{{ asset('storage/images/post/' . user_directory_path($post->user_id) . '/thumbnail/' . $post->ulid . '.webp') }}"
                        alt="{{ $post->title }}"
                    />
                </a>
            </div>
        @endforeach
    </div>
    <button class="flex items-center justify-center w-10 h-10 px-2 mx-2 rounded-lg border text-gray-700 bg-gray-300 hover:bg-gray-300 focus:outline-none" onclick="nextSlide()">
        <span class="font-bold">&gt;</span>
    </button>
</div>

<script>
    function prevSlide() {
        const slider = document.querySelector(".overflow-x-auto");
        slider.scrollBy(-144, 0);
    }

    function nextSlide() {
        const slider = document.querySelector(".overflow-x-auto");
        slider.scrollBy(144, 0);
    }
</script>
```

コードをそのまま貼り付けて「ボタン押下時に滑らかにスライドさせたい」と入力した結果、`behavior: 'smooth'` を教えてもらいました。

```
<script>
    function prevSlide() {
        const slider = document.querySelector(".overflow-x-auto");
        slider.scrollBy({
            left: -144,
            behavior: 'smooth'
        });
    }

    function nextSlide() {
        const slider = document.querySelector(".overflow-x-auto");
        slider.scrollBy({
            left: 144,
            behavior: 'smooth'
        });
    }
</script>
```
![IMG_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122701/7e1a90ae-9a05-2156-209c-b499df307006.gif)



# デプロイ
基本的には過去記事[［備忘録］Laravel10をロリポップにデプロイする](https://qiita.com/tsu_eng/items/224cb1e44487bd1089eb)の とおりにデプロイしました。

## 画像が表示されない件
ロリポップにデプロイした際、画像が表示されない問題が発生しました😾

画像は下記のディレクトリ構成で保存する予定でした。
しかし、ロリポップのハイスピードプランではドキュメントルート以外の場所にシンボリックリンクできない仕様らしく、この構成ではダメでした。
```.NGな例
└─ storage
   └─ app
      └─ public
         └─ images
```

というわけで、public直下に保存することにしました。
```.OKな例
└─ public
   └─ storage
      └─ images
```

### 修正するファイル
`config/filesystems.php` にdirectを追加して、 `Storage::disk()` に directを設定します。

```修正前.php
// 画像を保存する
Storage::disk('public')->put($filePath, (string) $image->encode());
```
```修正後.php
// 画像を保存する
Storage::disk('direct')->put($filePath, (string) $image->encode());
```

```config/filesystems.php
'disks' => [
    // directを追加
    'direct' => [
        'driver' => 'local',
        'root'   => public_path() . '/storage',
        'url' => env('APP_URL').'/public',
        'visibility' => 'public',
    ],
```


# 今後について
**AI画像生成のローカル環境構築方法の解説**
AI画像の生成方法を知らない人も利用できるようにします。

**検索機能の強化**
ユーザー増加に伴い画像が増えてくれば、検索機能を充実させる必要があると思います。

**バックアップ対応**
他サイトで障害が発生したりしているのを見ていると、レンタルサーバーのバックアッププランに入るとか、S3 Glacierに画像を保管するなどの対応をしておかないとなと感じます。

**収益化**
これまでGoogle Adsenseで稼ぐことが多かったのですが、Amazonアソシエイトや、広告枠を直接企業に売ることなども視野に入れて運営していきたいと思います。

[^1]: 【ご参考】[ちちぷい障害発生時のツイート](https://twitter.com/chi2pui/status/1646264957854703616)