虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Deno で掲示板サイトを作ろう! with upstash & supabase その 7 (画面の整理)

皆さん、こんにちは。国民になりたいおっくんです。

今回は、「Deno で掲示板サイトを作ろう! with upstash & supabase」企画の 7 回目として、画面全体の整理を進めていきます。

前回記事はこちら

toranoana-lab.hatenablog.com

現在の画面

まず一旦現在の画面の状況を確認しておきたいと思います。

[トップページ(/)]

[トピックス一覧(/topics)]

[書き込み詳細(/topics/[topic_id])]

トップページが動作確認の時に作っていたものが残っているので、中々の酷さです。
少しスタイリングしたページも幅は目いっぱいとられるようになっています。

こういった部分の調整を今回は進めていきます。

実装

トップページ(/)

先に紹介した通り、特にスタイリングをしていないページが残っています。こちらを調整していきます。

調整前のソースがこちらです。

[anonymous-board/anonymous-board/routes/index.tsx]

export default function Home() {
  return (
    <div class="p-4 mx-auto max-w-screen-md">
      <a href="/auth/twitter/login">/auth/twitter/login</a>
      <a href="/auth/logout">/auth/logout</a>
    </div>
  );
}

修正後のソースはこちらです。

[anonymous-board/anonymous-board/routes/index.tsx(変更)]

import { Head } from "$fresh/runtime.ts";

export default function Home() {
  return (
    <div class="h-screen w-screen flex justify-center items-center bg-gray-50">
      <Head>
        <title>Anonymous board</title>
      </Head>
      <div class="flex flex-col">
        <a href="/topics" class="basis-1/2 block m-2 w-full">
          <img
            src="/logo.png"
            class="w-80 my-4 border-black border-black border-4 rounded-lg shadow-2xl"
            alt="Anonymous board"
          />
        </a>
      </div>
    </div>
  );
}

相変わらずセンスは無いのですが、文字だけよりかは見れるものになったかと思います。

[トップページ(/)(変更)]

トピックス一覧(/topics) 書き込み詳細(/topics/[topic_id])

トピックス一覧と書き込み詳細は、だいたい同じレイアウトなので、切り出して共通化していきたいです。

変更前のテンプレートは次の様になっています。

[routes/topics.tsx(テンプレート部分抜粋)]

export default function Topics(props: PageProps<TopicsResource>) {
  return (
    <div class="p-2">
      <Header publicId={props.data.publicId} />
      <div class="mb-2">
        <form method="POST" action="/topics">
          <input
            type="text"
            name="title"
            placeholder="新しい掲示板タイトル"
            class="w-full mb-1 rounded h-12 text-lg text-center"
          />
          <input type="hidden" name="csrfToken" value={props.data.tokenStr} />
          {props.data.errorMessage
            ? (
              <div class="mt-4 h-12 p-2 bg-red-200 bg-red-200 rounded text-center text-red-500">
                {props.data.errorMessage}
              </div>
            )
            : (
              ""
            )}
          <button class="bg-indigo-400 w-full rounded py-2 text-white">
            登録
          </button>
        </form>
      </div>
      {!props.data.topics
        ? ""
        : props.data.topics.map((topic) => (
          <a href={"/topics/" + topic.id}>
            <div class="w-full mb-2 select-none border-l-4 border-gray-400 bg-gray-100 p-4 font-medium hover:border-blue-500">
              <div>
                <p class="text-9x1 break-all">{topic.title}</p>
                <small>
                  <p class="text-gray-400">by {topic.accounts.public_id}</p>
                </small>
              </div>
            </div>
          </a>
        ))}
    </div>
  );
}

[routes/topics/[topicId].tsx(テンプレート部分抜粋)]

export default function Topic(props: PageProps<TopicResource>) {
  return (
    <div class="p-2">
      <Header publicId={props.data.publicId} />
      {props.data.isSuccess
        ? (
          <div>
            <div class="flex justify-between w-full mb-2 border-rl-4 border-indigo-200 bg-gray-50 p-2 font-large text-center rounded">
              <div class="w-full">
                {props.data.topic?.title}
              </div>
            </div>
            <div class="mb-2">
              {props.data.errorMessage
                ? (
                  <div class="flex justify-between w-full mb-2 border-rl-4 border-red-500 text-red-400 bg-red-100 p-2 font-large text-center rounded">
                    <div class="w-full">
                      {props.data.errorMessage}
                    </div>
                  </div>
                )
                : ""}
              <form method="POST" action={`/topics/${props.params.topicId}`}>
                <input
                  type="text"
                  name="comment"
                  placeholder="いまどうしてる?"
                  class="w-full mb-1 rounded h-12 text-lg text-center"
                />
                <input
                  type="hidden"
                  name="csrfToken"
                  value={props.data.tokenStr}
                />
                <button class="bg-indigo-400 w-full rounded py-2 text-white">
                  登録
                </button>
              </form>
            </div>

            {props.data.posts?.map((post) => (
              <div
                class="w-full mb-2 border-l-4 border-gray-400 bg-gray-50 p-4 font-medium rounded"
                key={post.id}
                id={post.id.toString()}
              >
                <div>
                  <p class="text-9x1 text-gray-600 break-all">{post.comment}</p>
                  <small>
                    <p class="text-gray-400">by {post.accounts.public_id}</p>
                  </small>
                </div>
              </div>
            ))}
          </div>
        )
        : (
          <div>
            <div class="flex justify-between w-full mb-2 border-rl-4 border-red-600 bg-gray-50 p-2 font-lerge text-center rounded">
              <div class="justify-center w-full">
                掲示板を取得できませんでした
              </div>
            </div>
          </div>
        )}
    </div>
  );
}

このページは横幅いっぱい表示されてしまう状態なので、切り出し済みのヘッダーを含めたコンテナとして新たに切り出します。

コンテナをつくるコンポーネントを、components/layout/container.tsx に作成します。

[components/layout/CustomContainer.tsx(新規)]

import { Head } from "$fresh/runtime.ts";
import Header from "../header.tsx";
type Props = {
  publicId?: string;
  title: string;
  children: ComponentChildren;
};

export default function CustomContainer({ publicId, title, children }: Props) {
  return (
    <div className="w-full mx-auto h-screen bg-gray-100">
      <Head>
        <title>{title}</title>
      </Head>
      <div className="container mx-auto h-screen md:w-9/12 p-4 bg-gray-50">
        <Header publicId={publicId} />
        {children}
      </div>
    </div>
  );
}

HTML の head 要素 各種設定を行う Head コンポーネントが Fresh から提供されます。別のモジュールの導入をせずに使えるのは、便利ですね。

他にも Form のエラーを表示する ErrorPostForm コンポーネントも切り出しました。

[components/ErrorPostForm.tsx(新規)]

interface ErrorPostFormProps {
  errorMessage?: string;
}

export default function ErrorPostForm({ errorMessage }: ErrorPostFormProps) {
  return (
    <div class="flex justify-between w-full mb-2 border-rl-4 border-red-500 text-red-400 bg-red-100 p-2 font-large text-center rounded">
      <div class="w-full">
        {errorMessage}
      </div>
    </div>
  );
}

これらのコンポーネントを使用するとともに各ページのテンプレート部分を以下のように整理しました。

[routes/topics.tsx(テンプレート部分抜粋)(変更)]

interface TopicPostFormProps {
  tokenStr: string;
  errorMessage?: string;
}

function TopicPostForm(props: TopicPostFormProps) {
  return (
    <form method="POST" action="/topics">
      <input
        type="text"
        name="title"
        placeholder="新しい掲示板タイトル"
        class="w-full mb-1 rounded h-12 text-lg text-center border-2 border-gray-200"
      />
      <input type="hidden" name="csrfToken" value={props.tokenStr} />
      {props.errorMessage
        ? <ErrorPostForm errorMessage={props.errorMessage} />
        : (
          ""
        )}
      <button class="bg-indigo-400 w-full rounded py-2 text-white">
        登録
      </button>
    </form>
  );
}

interface TopicProps {
  id: number;
  title: string;
  publicId: string;
}

function Topic({ id, title, publicId }: TopicProps) {
  return (
    <a href={"/topics/" + id}>
      <div class="w-full mb-2 select-none border-l-4 border-gray-400 bg-gray-100 p-4 font-medium hover:border-blue-500">
        <div>
          <p class="text-9x1 break-all">{title}</p>
          <small>
            <p class="text-gray-400">by {publicId}</p>
          </small>
        </div>
      </div>
    </a>
  );
}

export default function Topics(props: PageProps<TopicsResource>) {
  return (
    <CustomContainer title="トピック一覧" publicId={props.data.publicId}>
      <div class="mb-2">
        <TopicPostForm
          tokenStr={props.data.tokenStr}
          errorMessage={props.data.errorMessage}
        />
      </div>
      {!props.data.topics ? "" : props.data.topics.map((topic) => (
        <Topic
          id={topic.id}
          title={topic.title}
          publicId={topic.accounts.public_id}
        />
      ))}
    </CustomContainer>
  );
}

[routes/topics/[topicId].tsx(テンプレート部分抜粋)(変更)]

interface CommnetPostFormProps {
  tokenStr: string;
  topicId: number;
  errorMessage?: string;
}

function CommentPostForm(
  { tokenStr, topicId, errorMessage }: CommnetPostFormProps,
) {
  return (
    <form method="POST" action={`/topics/${topicId}`}>
      <input
        type="text"
        name="comment"
        placeholder="いまどうしてる?"
        class="w-full mb-1 rounded h-12 text-lg text-center border-2 border-gray-200"
      />
      <input
        type="hidden"
        name="csrfToken"
        value={tokenStr}
      />
      {errorMessage ? <ErrorPostForm errorMessage={errorMessage} /> : ""}
      <button class="bg-indigo-400 w-full rounded py-2 text-white">
        登録
      </button>
    </form>
  );
}

interface CommentProps {
  id: number;
  comment: string;
  publicId: string;
}

function Comment({ id, comment, publicId }: CommentProps) {
  return (
    <div
      class="w-full mb-2 border-l-4 border-gray-400 bg-gray-50 p-4 font-medium rounded"
      key={id}
      id={id}
    >
      <div>
        <p class="text-9x1 text-gray-600 break-all">{comment}</p>
        <small>
          <p class="text-gray-400">by {publicId}</p>
        </small>
      </div>
    </div>
  );
}

function ErrorGetTopic() {
  return (
    <div class="flex justify-between w-full mb-2 border-rl-4 border-red-600 bg-gray-50 p-2 font-lerge text-center rounded">
      <div class="justify-center w-full">
        掲示板を取得できませんでした
      </div>
    </div>
  );
}

export default function Topic(props: PageProps<TopicResource>) {
  return (
    <CustomContainer
      title={props.data.topic.title}
      publicId={props.data.publicId}
    >
      {props.data.isSuccess
        ? (
          <>
            <div class="flex justify-between w-full mb-2 border-rl-4 border-indigo-200 bg-gray-50 p-2 font-large text-center rounded">
              <div class="w-full">
                {props.data.topic?.title}
              </div>
            </div>
            <div class="mb-2">
              <CommentPostForm
                tokenStr={props.data.tokenStr}
                topicId={props.params.topicId}
                errorMessage={props.data.errorMessage}
              />
            </div>

            {props.data.posts?.map((post) => (
              <Comment
                id={post.id}
                comment={post.comment}
                publicId={post.accounts.publicId}
              />
            ))}
          </>
        )
        : <ErrorGetTopic />}
    </CustomContainer>
  );
}

[トピックス一覧(/topics)(変更)]

[書き込み詳細(/topics/[topic_id])(変更)]

横幅一杯に広がっていた部分など、各種修正を行うとともに共通化ができました。


今回は、整理されていなかったり確認の過程でリンクだけ残っているページのテンプレートの見直しを行いました。 Fresh を使うにしても、整理や共通化の方針は Preact(React) など、フレームワークでの一般的な進め方と変わらないと思います。 Tailwind を使っているのでクラス割り当てが手厚く見えますが、別途UIコンポーネントを導入すればこの点はもっとスッキリ見えるものになるかと思います。

次回は、連携元をTwitterから、GitHubに切り替えを行いたいと思います。

今回進めた内容は、以下のリポジトリに上げていますので全体感を確認したい場合はこちらをご参考ください。

github.com

採用情報

虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp