0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

近況ノートをグループ作成できるようにする

0
Posted at

近況ノートは、もともと同級生向けに作ったアプリでした。

最初は1つのグループが存在すれば十分でしたが、使っているうちに「別の学校や別のコミュニティでも使えるようにしたい」と考えるようになりました。

そこで今回は、特定のグループに依存していたアプリへ、誰でもグループを作成できる機能を追加しました。

この記事では、グループ作成画面、共有URLの自動発行、グループ情報の編集、グループごとの動的タイトル対応までをまとめます。

変更前の状態

変更前の近況ノートは、実質的に同級生専用のアプリでした。

画面には固定のグループ名や説明文が表示され、URLも決め打ちです。

この構成では、別の学校やコミュニティが使おうとしたときに、次の問題があります。

  • 新しいグループを作る画面がない
  • グループ名や説明文を利用者が変更できない
  • URLを発行する仕組みがない
  • ページタイトルやOGPが同級生用のままになる
  • グループ画像が未設定の場合も、特定の学校向け画像に依存してしまう

アプリを汎用化するには、投稿機能を増やすだけでは足りません。

「誰のためのグループか」を設定でき、共有URLを持ち、グループごとにページの情報が切り替わる必要があります。

目指した状態

今回のMVPでは、次の状態を目指しました。

  • ログインしたユーザーが新しいグループを作成できる
  • グループ名と説明文を入力できる
  • グループ画像を設定できる
  • 共有URLはサーバーが自動で発行する
  • グループ作成後は、そのグループのホームへ遷移する
  • グループ作成者がグループ情報を編集できる
  • グループごとにページタイトルやOGPを切り替える
  • 画像未設定時は汎用画像を表示する

既存の近況投稿や閲覧の流れは、グループURLを差し替えるだけで利用できる形にしました。

グループ作成画面を追加する

新しいグループの作成画面は、次のURLにしました。

/groups/new

入力項目は、グループ情報の編集画面と共通化しています。

  • グループ名
  • 説明文
  • グループ画像

説明文には200文字の上限を設け、入力中の文字数も表示します。

<Field label="説明文(任意)" error={errors.description?.message}>
  <textarea
    maxLength={200}
    placeholder="どんなグループか、どんなメンバーに向けたものかなどを自由に入力してください。"
    {...register("description")}
  />
  <p>{descriptionLength} / 200</p>
</Field>

作成画面では、共有URLを入力させないことにしました。

共有URLは作成時に自動で発行されます。

URL名を利用者に考えてもらうと、入力項目が増えるだけでなく、重複や不適切な文字列の扱いも必要になります。

MVPでは、グループ名は表示用の情報、URLはシステムが管理する識別子として役割を分けました。

URLをサーバー側で自動発行する

グループ作成APIは次のエンドポイントにしました。

POST /api/groups

リクエストにはグループ名と説明文だけを含めます。

const input = groupCreateSchema.parse(await c.req.json());

slugはWorker側で生成します。

function createGroupSlug() {
  const token = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
  return `g-${token}`;
}

作成されるURLは、たとえば次のようになります。

/g/g-6c5ae9dfb9c8

グループ名を変更してもURLは変わりません。

これは、すでに共有したURLをあとから壊さないためです。表示名と共有URLを分離しておくことで、幹事がグループ名を変更しても参加者は同じURLからアクセスできます。

作成時には、ログイン中のユーザーを所有者として保存します。

await db.insert(groups).values({
  ownerUserId: session.user.id,
  name: input.name,
  slug,
  description: input.description,
  createdAt: now,
  updatedAt: now,
});

グループ作成者が情報を編集する

グループホームから、グループ情報の編集画面へ移動できます。

/g/:slug/settings

編集できるのは次の情報です。

  • グループ名
  • 説明文
  • グループ画像

編集APIは、ログインユーザーがそのグループの所有者であることを確認します。

PATCH /api/me/groups/:slug
PUT   /api/me/groups/:slug/avatar

所有者の確認は、画面で編集ボタンを表示するかどうかだけではなく、API側で実施します。

if (group.ownerUserId !== userId) {
  throw new HTTPException(403, {
    message: "このグループを管理する権限がありません",
  });
}

これにより、編集ボタンを直接表示していないユーザーがURLやAPIを操作しても、グループ情報を変更できません。

グループ作成と編集でフォームを共通化する

グループ作成画面と編集画面は、入力項目と画像選択のUIがほとんど同じです。

そこで、次の部品を共通化しました。

  • GroupFormFields
  • GroupFormActions
  • useGroupForm
  • ImageInputField

画面ごとの差分は、送信先の処理とボタンの文言だけです。

<GroupFormFields
  avatarError={avatarError}
  avatarInputRef={avatarInputRef}
  avatarPreviewUrl={avatarPreviewUrl}
  descriptionLength={descriptionLength}
  errors={errors}
  onAvatarChange={handleAvatarChange}
  onClearAvatar={clearAvatarInput}
  register={register}
/>

入力値のバリデーションも、作成と編集で同じスキーマを利用しています。

export const groupCreateSchema = groupUpdateSchema;

作成画面だけにURL入力欄を追加するような設計にしなかったことで、フォームの責務をシンプルに保てました。

グループ画像のフォールバックを汎用化する

画像を設定していないグループでは、特定の学校を連想させる画像を表示しないようにしました。

共通のフォールバック画像を定数にまとめています。

export const heroImage = "/free-irasutoya.png";

グループ画像がある場合はその画像を使い、ない場合はフォールバック画像を使います。

backgroundImage: `linear-gradient(...), url(${group.avatarUrl ?? heroImage})`;

これで、別の学校やコミュニティがグループを作成したときにも、同級生専用に見える初期状態を避けられます。

グループごとにタイトルとOGPを切り替える

グループを増やすと、ページタイトルも固定値のままでは不自然です。

変更前は、どのグループを開いても次のサイト名が表示されていました。

同級生近況ノート

そこで、グループのレイアウトでgenerateMetadataを実装しました。

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const group = await getGroup(slug);
  const image = group.avatarUrl ?? heroImage;

  return {
    title: {
      absolute: group.name,
      default: group.name,
      template: `%s | ${group.name}`,
    },
    openGraph: {
      images: [image],
      siteName: group.name,
      title: group.name,
      url: `/g/${slug}`,
    },
  };
}

グループのトップページでは、グループ名そのものをタイトルにします。

テストグループ

投稿画面やフィードなどの下層ページでは、ページ名とグループ名を組み合わせます。

近況を入力する | テストグループ

OGPとXカのードにもグループ名、説明文、グループ画像を反映します。

共有されたURLを見た人が、どのグループのページなのかをブラウザのタブやSNSのプレビューから判断できるようになりました。

既存の画面との接続

既存のグループホームには、新しいグループ作成画面へのリンクを追加しました。

グループホーム
  └─ 新しいグループを作成する
       └─ /groups/new

作成画面を開いた時点でログインしていない場合は、ログイン画面へ遷移します。

ログイン後は作成画面に戻り、入力内容を送信できます。作成に成功すると、APIが返したslugを使って新しいグループのホームへ遷移します。

navigateWithViewTransition(() => {
  router.replace(groupPath(result.group.slug));
});

これで、既存の/g/hanchuを入口にしながら、新しいグループを作って別の共有URLへ移動できるようになりました。

まとめ

今回の変更で、近況ノートは特定の学校向けアプリから、グループ単位で使える近況共有サービスへ一歩進みました。

同級生向けに作り始めた小さなアプリでも、表示する情報と識別する情報を分け、所有者をDBに保存しておくことで、複数グループ対応へ拡張できました。

今後は、グループへの参加方法や幹事の追加など、グループを運用するための機能を少しずつ足していく予定です。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?