【個人開発】マイクラの建築物配布サービスを作ったメモ

## きっかけ
今年9月から無職になったので、以前からあたためていたサービスをつくってやろうと思い立ちました。

## つくったもの
https://poscra.com/

マイクラ統合版のPC版では（統合版とはって感じですが）、ストラクチャーブロックによって作成した建築を外部ファイルとしてエクスポートできます。
また建築物の3Dモデルのエクスポートもでき、3Dviwerなどでぐりぐり観察することもできます。
それらをつかってマイクラ建築のカタログみたいなものをつくりたいと考えました。

## 技術スタック
### アプリケーションフレームワーク
当初はNextJSを使おうと思っていました。
しかし、まちがったイメージかもしれませんがNextJSはクラウドサービスの使用前提な印象で、
- ファイルアップロード
- ログイン認証

などを使いたい＋構成はスリムにしたい（一つのサーバで完結）＋ファイル操作など慣れているPHPでやりたい　
等で結局『Laravel』を使用することにしました。

### フロント周り
デザインはLaravelに最初からついてくる『TailwindCSS』とどこかでおすすめされていた『DaisyUI』を追加で利用しました。
またSPAぽいことをJSではなくPHPで書ける 『Laravel Livewire』を利用しています。
さらに3Dモデルのプレビューをしたかったので、『ThreeJS』を利用。

### ログイン認証
ログイン認証はメールサーバの準備が面倒＋コスト削減、メールの利活用がとくにない
ためGoogleログインのみに限定して『Laravel Socialite』を利用。

### CDN
構成はスリムにといいましたが、ファイルキャッシュを使いたいのとオリジンサーバのIPを隠匿したかったので
『Cloudflare』を利用

## 進行振り返り
1. デザインの作成
    デザイン知識はあまりなかったが、いい加減「エンジニアがつくるデザイン」から卒業したかったのと、Figmaの使い方を知っておこうということで、友人に相談しながらデザインの作成をした。
    カラー周りの知識もなかったので「[AIがキーワードからパレットを作ってくれるサービス](https://colormagic.app/)」を活用
    <iframe style="border: 1px solid rgba(0, 0, 0, 0.1);" width="800" height="450" src="https://www.figma.com/embed?embed_host=share&url=https%3A%2F%2Fwww.figma.com%2Ffile%2FRAYXRsf5YvIkqcdsG0q1tm%2F%25E3%2581%25BD%25E3%2581%2599%25E3%2581%258F%25E3%2582%2589%3Ftype%3Ddesign%26node-id%3D1%253A2%26mode%3Ddev" allowfullscreen></iframe>
1. 開発環境の構築
    ここにもあまり時間はかけたくなかったので、公式がすすめている Sail を使って環境構築を行った。
1. 開発
    開発は完全にソロだったが一応ブランチ運用をおこなって開発
    （最後の修正部分はmain直pushでしたが...)
    開発単位は
    - ログイン部分
    - ダッシュボード
        - 新規投稿モーダル
        - お気に入り記事一覧
        - 自分の投稿一覧
        - 投稿編集モーダル
    - トップページ
        - トレンド記事ピックアップ
        - 新着記事ピックアップ
        - 記事一覧
    - 共通
        - 投稿詳細モーダル
            - お気に入りボタン
        - ヘッダ
            - ロゴ
            - メニュー
1. テスト公開
    とりあえず完成っぽいものができたので X で呼びかけて使ってもらったりなど
1. 本番公開
    本番公開用のサーバをあたらしく借り、本番公開
    Sail は本番環境にむいていないらしいので、Nginx,PHP-FPMを直インストールして、DBだけお手軽にDockerコンテナを利用

## ロジックなど
トップページのトレンド表示は一週間あたりでお気に入り登録を集計してランキング化し、最大4つまで表示するようにしています。
```php
$trend_posts = DB::table('favorites')
                        ->select(DB::raw('count(*) as favorites_count, post_id'))
                        ->whereDate('created_at', '>=', Carbon::today()->subDay(7)) // 一週間で集計
                        ->groupBy('post_id')
                        ->orderBy('favorites_count', 'desc')
                        ->limit(4)
                        ->get()
                        ->map(function($item, $key){
                            return Post::find($item->post_id);
                        });
```
またトップページのように「トレンド・新着・一覧」に同じ投稿が存在するページでは同じコンポーネントを表示しないと、
トレンド一覧から選択→お気に入り→同じ投稿を一覧部分から選んで表示　するとあれ？お気に入りが反映されてない！みたいなことが発生します。単純にリソースの無駄も生まれます。
このため表示する投稿とIDを管理するクラスを作成してそこからモーダルを表示するようにしました。
幸い一覧部分ではすべての投稿を取得して表示するようになっているのでそこに記載できました。
```php
class PostModalProvider {

    protected $posts = [];
    protected $prefix;

    public function __construct($prefix = "")
    {
        $this->prefix = $prefix;
    }

    public function registerBulk(array|Collection $posts) {
        foreach ($posts as $post) {
            $this->register($post);
        }
    }

    public function register(Post $post) {
        if (isset($this->posts[$post->id])) return;
        $modal_id = $this->prefix . str_replace('-', '_', $post->id);
        $this->posts[$post->id] = [
            'modal_id' => $modal_id,
            'post' => $post
        ];
    }

    public function getModalId(Post $post) {
        if (!isset($this->posts[$post->id])) {
            return null;
        }

        return $this->posts[$post->id]['modal_id'];
    }

    public function getModalList() {
        return $this->posts;
    }

}
```
```php
//in TopPageController
$posts = Post::orderBy('updated_at', 'desc')->get();
$trend_posts = DB::table('favorites')
                    ->select(DB::raw('count(*) as favorites_count, post_id'))
                    ->whereDate('created_at', '>=', Carbon::today()->subDay(7)) // 一週間で集計
                    ->groupBy('post_id')
                    ->orderBy('favorites_count', 'desc')
                    ->limit(4)
                    ->get()
                    ->map(function($item, $key){
                            return Post::find($item->post_id);
                    });
$new_posts = Post::orderBy('created_at', 'desc')->limit(4)->get();
    
$modal_provider = new PostModalProvider('detail_');
$modal_provider->registerBulk($posts);

return view(
        'welcome', 
        [
            'posts' => $posts,
            'trend_posts' => $trend_posts,
            'new_posts' => $new_posts,
            'modal_provider' => $modal_provider,
        ]
);
```
```php
// in blade file
// openModal() は jsで定義済み [dialog].showModal()したり、モーダルにURLをつけたりなど
@foreach($trend_posts as $trend_post)
    // カルーセル表示したり
    <img onclick="openModal('{{ $modal_provider->getModalId($trend_post) }}', '{{ $trend_post->id }}', '{{ $trend_post->title }}')" />
@endforeach
@foreach($new_posts as $new_post)
    // トレンドと同じく
    <img onclick="openModal('{{ $modal_provider->getModalId($new_post) }}', '{{ $new_post->id }}', '{{ $new_post->title }}')" />
@endforeach
@foreach ($posts as $post)
    // カード表示したり
    <div onclick="openModal('{{ $modal_provider->getModalId($post) }}', '{{ $post->id }}', '{{ $post->title }}')"></div>

    //ここでモーダルの中身を表示するようにする
    <dialog id="{{ $modal_provider->getModalId($post) }}" class="modal">
    </dialog>
@endforeach
```
もし、一覧部分がページネーションなど一気に表示するロジックではなかった場合は一覧部分でせずに
別で `$modal_provider->getModalList()` で表示するなど？
（ここはあまり詰められてない）

---

投稿画面と投稿編集時で、プレビューのコンテンツが3Dmodel (GLBファイル)の場合は、ThreeJSで表示する必要があります。
表示順序としては
1. ファイルアップロード
1. ファイル形式確認
1. アップロード先のURLをフロントへ返す
1. URLから3Dモデルを読み込んで (GLTFLoader) 表示

としました。
セキュリティの都合で画像以外は一時URLの発行ができないので、いったん本アップロードするようにしました。
これだとゴミが溜まってしまうので、日時のバッチで不要なファイルを削除するようにしています。

---

また画像の場合カード形式で表示する際にサムネとしてそのまま画像を表示すればよいですが、3Dモデルの場合は画像になっていないのでサムネ画像を作成する機能を作りました。
![intro.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178346/35b6dbab-edfb-5242-84f2-4f6bcf9f9fce.gif)

仕組みとしては、Canvas要素を画像に変換して hidden要素で送るようにしています。
これでよいアングルでサムネを作れるようにできました。
「サムネイルとして設定」ボタンを置いており、連続でボタンが押されないよう、ボタンを無効化してcanvasが動かされたときに再度押せるようにしています。

## 開発メモ
- LivewireのコンポーネントでDOM操作をJSで行う際はPHPからイベントを発火して行う。
- 同一ページに共通のコンポーネント（今回は記事詳細モーダル）がある際は対象の要素に一意のidを振っておかないと一つのコンポーネントしか操作できない

## 大変だったこと
新規投稿モーダルでは画像か3Dモデルかでプレビューの表示形式を変える必要があったがロジック部分を考えたり、3Dモデルのサムネイル画像をどうしようか悩んだりしました。

## 最後に
結構のんびりつくりましたが、正味としては一か月くらいでできました。
結構使ってくれるかなとドキドキしてたんですが、意外に利用者がいないのでオープンにするかもしれません。

久々に文章を書いたのでまとまりが悪かったりするかもしれませんが何かの役にたてばと思います。