LoginSignup
1
2

【個人開発】公式サイトのマンガ更新情報を検索機能付きで作ってみた

Posted at

公式マンガ検索サイト「まんさく!」をリリースしました。

manga_screen.png

開発経緯

友人と話している際にジャンプやマガジンなど公式で公開されている漫画が一覧になってるサイトあったらいいよねーという話になり、更新作品がまとまって検索機能なんかもあったら需要あるんじゃね?と思い開発を始めました。

システム

stack_ppt.png

主に使用した技術スタック

  • NextJS
  • TypeScript
  • Shadcn/ui
  • Supabase
  • Vercel
  • CloudFlare

技術スタックの選定経緯

当初はフロントエンドとバックエンドは切り分ける想定で、フロントのみNextJS、バックエンドはExpressをDocker化してデプロイを想定していました。

Cronジョブを無料で行えるPaaSの中から「Render.com」いいやん!となり、開発を始めました。

Expressでほぼほぼソースコードを書き上げた後に「非アクティブ状態が15分続くと停止状態になる」という仕様に突き当たりました。スリープ対策は見つけたものの、めんどくさそうだったので、再度ホスティング先を検討した結果、Vercelなら「1日1回のCronジョブは無料」という神仕様を知り、PaaSにはVercelを選びました。

その後ドメインの取得やDDoS対策なんかも鑑み、将来的なことも踏まえフロントのデプロイ先はCloudFlareに変更しました。

DBは少し触ったことがあったものの、ほぼ放置していたSupabaseを選びました。
使った感じかなりいいサービスだと思います!

こだわった実装

日付・キーワードでの検索機能

const [searchWord, setSearchWord] = useState("");
const [date, setDate] = useState<Date | null>(null);
const [data, setData] = useState<BookType[]>([]);
const [filteredData, setFilteredData] = useState<BookType[]>([]);

/////////////////////////////////////////////////

useEffect(() => {
  let result = data;
  if (searchWord) {
    result = result.filter((book) => book.title.includes(searchWord));
  }
  if (date) {
    result = result.filter(
      (book) => format(new Date(book.date), "PPP") === format(date, "PPP"),
    );
  }
  setFilteredData(result);
}, [searchWord, date, data]);

dataにはsupabaseから取得した漫画のデータが格納されています。日付をdate、キーワードをsearchWordに入れるとuseEffectが発火してフィルタリングすることができます。

ちなみに、カレンダーや検索フォームはShadcnのコンポーネントまるまる持ってきています。めちゃくちゃデザインがグッドです:thumbsup:

微妙なデザインの調整がしたくなった場合は components > uiフォルダのtsxファイルをいじることで、UIコンポーネント単位で調整することができます。

日付順に並べる

<ul>
  {filteredData
    .sort(
      (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
    )
    .map((book) => (
        <li key={book.id} >
          <Book book={book} />
        </li>
    ))}
</ul>

各アイテムにはデータを取得した日付を格納していますが、id順に並べると上部に昔のアイテムが並ぶことになります。最新のデータを上部に表示するためにフィルターしたデータに対して「新しい日付順」でsortしています。

APIを外部からアクセスできないように保護する方法

export async function GET(request: NextRequest) {
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.SECRET_KEY}`) {
    return new Response("Unauthorized", {
      status: 401,
    });
  } else {

    /////////////////////////////
    itemsに取得したデータを格納
    /////////////////////////////

    const data = await supabase.from("データベース名").insert(items);
    
    return new Response(JSON.stringify({ status: data.status }));
  }
}

エンドポイントのtsファイルをそのままデプロイすると外部からアクセスできるため、Cronジョブ回りまくり案件が爆誕してしまう可能性があります。

リクエストヘッダーからauthorizationヘッダーを取得し、環境変数SECRET_KEYと比較します。一致しない場合は、401レスポンスを返します。

Authorizationヘッダーに環境変数トークンをスキームとして指定することでこの不穏な爆誕を防ぐことができます。

1か月前のデータ削除

const oneMonthAgo = new Date();
oneMonthAgo.setDate(oneMonthAgo.getDate() - 30);

const yearAgo = oneMonthAgo.getFullYear();
const monthAgo = oneMonthAgo.getMonth() + 1;
const dayAgo = oneMonthAgo.getDate();
const dateAgo = `${yearAgo}-${monthAgo.toString().padStart(2, "0")}-${dayAgo.toString().padStart(2, "0")}`;
const { data: deleteData, error: deleteError } = await supabase.from("データベース名").delete().lte("date", dateAgo);

作品ごとに異なるようですが、大体1か月くらいたつとそれ以前の作品が見れなくなるようです。DBの容量的には余裕はあるものの、いつかは限界が来そうなので、さしあたり1か月たった作品をDBから削除する機能を追加しています。

getMonthは月を0から数える謎仕様なので、「+1」します。padStartメソッドを使用して、月と日が2桁になるようにゼロパディングし、テンプレートリテラルを使用してYYYY-MM-DD形式の文字列を作成します。最後にlteメソッドで、dateAgo(30日前の日付)以下のレコードを削除する条件を指定して1か月経った作品を削除しています。

スクロールイベント実装

const [displayCount, setDisplayCount] = useState(60);

/////////////////////////////////////////////////

useEffect(() => {
  const handleScroll = () => {
    if (
      window.innerHeight + window.scrollY >=
      document.body.offsetHeight - 500
    ) {
      setDisplayCount((prevCount) => prevCount + 60);
    }
  };

  window.addEventListener("scroll", handleScroll);
  return () => window.removeEventListener("scroll", handleScroll);
}, []);

/////////////////////////////////////////////////

<ul>
  {filteredData
    .sort(
      (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
    )
    .slice(0, displayCount)
    .map((book) => (
        <li key={book.id} >
          <Book book={book} />
        </li>
    ))}
</ul>

handleScroll関数はユーザーがスクロールして、ページ下部から500px以内に突入したタイミングでdisplayCountに60(表示するマンガの数)を追加します。

useEffectの戻り値の関数は、コンポーネントがアンマウントされるときにスクロールイベントリスナーを削除して、メモリリークを防いでいます。

開発を完走した感想

本や動画での学習も極めて重要ですが、同じくらいアウトプットも大事だなと感じました。

また、サービスが本当に自分が作りたいものかというのも重要な要素だと感じます。制作は半分趣味のような形で進めることができ、明け方4時くらいまで集中して開発を進めることができました。

作り終わった感想としては「めっちゃ楽しかった!」という思いしかないです。

お世話になったリンク集

Supabaseの開発環境を爆速で作る方法

VercelのCron Jobsで定期実行する

Bearer認証について

1
2
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
1
2