近況ノートは、もともと同級生向けに作ったアプリでした。
最初は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がほとんど同じです。
そこで、次の部品を共通化しました。
GroupFormFieldsGroupFormActionsuseGroupFormImageInputField
画面ごとの差分は、送信先の処理とボタンの文言だけです。
<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に保存しておくことで、複数グループ対応へ拡張できました。
今後は、グループへの参加方法や幹事の追加など、グループを運用するための機能を少しずつ足していく予定です。