元々公開された こちら
Remix は比較的新しいフルスタック JS フレームワークであり、JS コミュニティのいくつかの巨人に支えられています。 ケント・C・ドッズ, ライアン・T・フローレンス および マイケルジャクソン. Next.js が登場するまでは、さまざまなツールを組み合わせて SPA を構築することが、JS アプリを構築するための事実上の方法でした。 Next.js はそれをある程度変革し、しばらく無敵の存在でした。 しかし、RedwoodJ、BlitzJ、そして現在の Remix との健全な競争により、この XNUMX 年ほどで状況は急速に変化しています。 これらのツールはすべて、Web 開発における古くからの問題のいくつかを、より創造的で信頼性の高い方法で解決しようとしています。 最も重要なこと、開発者に優しい方法で、パフォーマンスの高い Web アプリの構築が JS 開発者のデフォルトになります。
この分野のこれらすべてのツールの中で明確な勝者を特定するのは非常に時期尚早ですが、Remix は確かに有力な候補のように見えます. したがって、Remix の素晴らしさにまだ足を踏み入れていない場合は、このチュートリアルを参考にして、次に作成するものに Remix を選択するよう説得してください。
鳥瞰図
この投稿では、Remix を使用して AMA (Ask Me Anything) アプリを作成する方法について説明します。 以下は、このアプリの構築に使用する主なツールのリストです。 いくつかのツール (もちろん Remix を除く) の基本に精通していれば、読者が理解を深めやすくなりますが、そうでない場合でもあまり心配する必要はありません。
- Remix – 主要なフレームワーク
- React – UI フレームワーク
- Prisma – データベース ORM
- PostgreSQL – データベース
- TailwindCSS – CSS フレームワーク
これは長い投稿になるので、何度か読み進めることをお勧めします。すべてを読むことが投資に値するかどうかを簡単に判断できるようにするために、私たちが行うこと/学ぶことの概要を以下に示します。全体を時系列で:
- アプリの仕様 – より高いレベルから構築しようとしているアプリの機能の概要を説明します。
- Remix を始めましょう – ほとんどの場合、公式ドキュメントに従い、いくつかのものをインストールします。
- データベース スキーマ – アプリに必要なすべての動的コンテンツをサポートできるデータベース スキーマをセットアップします。
- CRUD – 標準の Remix 方法での基本的な CRUD 操作。
- UI/UX – Tailwind を少し振りかけて、見栄えを良くします。
おわかりのように、カバーすることがたくさんあるので、すぐに飛び込んでみましょう。ああ、その前に、私のようにせっかちでコードを見たいだけなら、github でアプリ全体をご覧ください。 https://github.com/foysalit/remix-ama
アプリの仕様
どんなプロジェクトでも、何を構築しようとしているのかを正確に把握していれば、最初からランドスケープをナビゲートするのがずっと簡単になります。 常にその自由があるとは限りませんが、幸いなことに、私たちの場合、アプリに必要なすべての機能を知っています. 技術的な観点からすべての機能を系統的にリストする前に、一般的な製品の観点からそれらを見てみましょう。
AMAセッション
アプリのユーザーは、複数の AMA セッションをホストできる必要があります。 ただし、同じ日に複数のセッションをホストするのは意味がないので、セッションの期間を丸 1 日に制限し、ユーザーごとに XNUMX 日あたり XNUMX つのセッションのみを許可します。
質問と回答
アプリのユーザーは、実行中の AMA セッション中にホストに質問できる必要があります。 排他性を構築するために、セッション終了後にユーザーが質問できないようにしましょう。 もちろん、セッションの主催者は、セッションで尋ねられた質問に答えることができる必要があります。
コメント
より多くのエンゲージメントを構築し、従来の Q&A よりも少し楽しくするために、すべてのユーザーが質問にコメントを追加できるコメント スレッド機能を追加しましょう。 これは、すでに尋ねられた質問にさらにコンテキストを追加したり、ホストなどから提供された回答について議論したりするために使用できます。
それでは、それらをどのように実装するかを説明しましょう。
認証 – ユーザーは、AMA セッションをホストしたり、ホストに質問したり、スレッドでコメントしたりするには、登録できる必要があります。 ただし、認証されていないユーザーが既に実行中のセッションを表示することを妨げないようにしましょう。 認証にはメールアドレスとパスワードを使いましょう。 さらに、サインアップするときに、アプリ内のどこでも使用できるフルネームを入力するようにユーザーに依頼しましょう。 User エンティティは、認証関連のデータを格納するために使用されます。
セッションズ – 現在および過去のすべてのセッションのリストをすべての (認証済み/未認証) ユーザーにインデックス ページに表示し、各セッションをクリックして質問/回答/コメントなどを表示できるようにします。認証されたユーザーは、既にある場合は新しいセッションを開始できます。その日のためのものではありません。 セッションを開始するときに、各セッションにコンテキスト/詳細を提供するようホストに依頼しましょう。 各セッションは、ユーザーに属するエンティティです。
質問 – 個々のセッションごとに、主催者を除く登録ユーザーから複数の質問を受けることができます。 質問エンティティには、データベース内のホストからの回答も含まれ、作成者がセッションのホストであることを確認するために、すべての回答入力が検証されます。 エンティティはセッションとユーザーに属します。 ユーザーがセッションごとに XNUMX つの質問しかできないようにしましょう。そのため、ユーザーが質問するまで、すべてのユーザーにテキスト入力を表示しましょう。 すべての回答済みの質問の下に、回答を追加するテキスト入力をホストに表示しましょう。
コメント – すべての質問 (回答されているかどうかに関係なく) には、複数のコメントを含めることができます。 複雑さを軽減するために、今のところコメントにスレッドを追加しないでください。 すべてのユーザーは質問の下に複数のコメントを投稿できるため、すべての質問の下で常にすべてのユーザーにコメント テキスト入力を表示しましょう。 UI を簡素化するために、デフォルトでセッション ページに質問 (および回答) のリストを表示し、サイドバーにコメント スレッドを開くためのリンクを追加しましょう。
リミックスを始める
Remix には多くの優れた品質がありますが、ドキュメントがおそらくトップの座を占めています。 大規模な開発が行われているフレームワークには、メンテナーによって常に進化している多くの可動部分が含まれているため、機能が優先されるにつれてドキュメントが遅れることになります。 ただし、Remix チームは、ドキュメンテーションを最新の状態に保ち、プッシュされる驚くべき変更の絶え間ない流れと同期するよう細心の注意を払っています。 だから、もちろん、始めるために、 公式ドキュメント が最初のエントリーポイントになります。
別の Web サイトにアクセスして別のテキストの壁を読むのが面倒な場合でも、心配する必要はありません。 Remix をインストールするために必要なことは次のとおりです。
- Node.js 開発環境がセットアップされていることを確認してください。
- ターミナル ウィンドウを開き、次のコマンドを実行します。
npx create-remix@latest
. - 完了しました。
Remix は、たくさんのツールを提供し、あなたのものを構築するように依頼するだけではありません。 スタック. スタックは基本的にテンプレート/スターター キットであり、すぐに使用できる完全なプロジェクトの基礎を提供します。 私たちのプロジェクトでは、 ブルース スタック これにより、Prisma、Tailwind、およびこれらのツールを使用して CRUD 機能を構築する方法を示すモジュール全体を含む、完全に構成された Remix プロジェクトが得られます。 正直なところ、テンプレートがすべての作業を既に行っているため、この投稿を書くべきではないと感じています。 うーん…私は今、あまりにも深いので、それを終えたほうがよいでしょう。
コマンドを実行するだけです npx create-remix --template remix-run/blues-stack ama
ターミナルで Remix を実行すると、プロジェクト全体が次の名前の新しいフォルダーにドロップされます。 ama
いくつかの質問に答えた後。
では、 ama
フォルダーを開き、その中のコンテンツに少し慣れてください。 ルートにはたくさんの設定ファイルがありますが、それらのほとんどには触れません。 私たちが主に興味を持っているのは プリズム, 公共 および アプリ ディレクトリ。 Prisma ディレクトリには、データベース スキーマと移行が含まれます。 パブリック ディレクトリには、アプリが必要とするアイコンや画像などのアセットが含まれます。最後に、アプリ ディレクトリには、クライアントとサーバーの両方のすべてのコードが格納されます。 はい、あなたはその権利を読んで、 クライアントとサーバーの両方. これが主要なレガシー コードベースのフラッシュバックを引き起こしている場合は、あなただけではないことを知っておいてください。
独自のアプリのコードを書く前に、すべてを git にチェックインして、remix blues stack によって既に行われたものから変更を追跡できるようにしましょう。
cd ama
git init
git add .
git commit -am ":tada: Remix blues stack app"
最後に、何かに触れる前に、アプリを実行して、どのように見えるかを確認しましょう。 README.md ファイルには、これを行うのに役立つすべての詳細な手順が既に含まれています。これらは頻繁に変更されるため、ここに書き留める代わりに手順にリンクします。 https://github.com/remix-run/blues-stack#development
手順を正確に実行すると、アプリにアクセスできるはずです http://localhost:3000
スタックには、電子メールとパスワードで登録した後に操作できるデフォルトのメモ モジュールが付属しています。
データベーススキーマ
通常、私はデータベース スキーマから機能/エンティティを考え始めて、データがさまざまな方法で解釈、表示、操作される UI に至るまで作業するのが好きです。 スキーマが完成したら、その実装をすばやく進めるのがずっと簡単になります。
上記のアプリ仕様で説明したように、データベースにはセッション、質問、コメントの 3 つのエンティティが必要です。 登録された各ユーザーを格納する User エンティティも必要ですが、Remix の blues スタックには既に含まれています。 追加するために少し変更する必要があります name
桁。 ファイルを開いてみましょう prisma/schema.prisma
ファイルの最後に次の行を追加します。
model Session { id String @id @default(cuid()) content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String questions Question[]
} model Question { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt content String answer String? user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade, onUpdate: Cascade) sessionId String comments Comment[]
} model Comment { id String @id @default(cuid()) content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
そして、この行をの定義に追加します User
モデル:
model User { … name String sessions Session[] questions Question[] comments Comment[] …
}
ここで開梱すべきことがたくさんありますが、そのほとんどはこの投稿の範囲外です。 Prisma が、必要な 3 つの新しいエンティティの適切な列を含むすべてのテーブルを構築するために必要なのは、このスキーマ定義だけです。 このリンクに向かう必要がある定義と構文の仕組み https://www.prisma.io/docs/concepts/components/prisma-schema そして少し読んでください。 高レベルの要約は次のとおりです。
- エンティティ/テーブル定義は次で始まります
model <EntityName> {}
中括弧内には、エンティティのすべての列/プロパティと、他のエンティティとの関係が入ります。 したがって、コメントの表は次のようになりますmodel Comment {}
- 列の定義は通常次のようになります
<columnName> <columnType> <default/relationship/other specifiers>
. したがって、コメント エンティティが、ユーザーが入力したコメントの内容を格納するための列を必要とする場合、次のようになります。
model Comment { content String
}
- 2 つのテーブル/エンティティ間の関係は通常、外部キー列を介して定義されるため、これらは他の列と一緒に定義されます。 通常、定義には 2 行が必要です。 外部キー ID を含む列と、関連するエンティティへのアクセスに使用される名前を指定する列は、通常次のようになります。
<entity> <entityName> @relation(fields: [<foreignKeyColumnName>], references: [id], onDelete: Cascade, onUpdate: Cascade)
. したがって、comment エンティティを question エンティティに XNUMX 対多の関係で関連付けるには、次のように定義する必要があります。
model Comment { content String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
上記はプリズマである氷山の一角さえカバーしていないので、どうぞどうぞ、公式ドキュメントから読んでください。そうすれば、その真の力がわかります. このブログ投稿のために、上記のプリズマ スキーマが必要な理由については、上記の説明から理解できるはずです。
データベースに関連する最後の調整を行う必要があります。 blues スタックには、認証システム全体に加えて、テスト目的でデータベースにダミー ユーザーを入力する初期データ シーダーも含まれています。 新しいコラムを導入したので name
ユーザー テーブルでは、ユーザーにダミーの名前を追加するようにシーダーを調整する必要もあります。 ファイルを開く prisma/seed.js
ユーザー挿入コードを次のように変更します。
const user = await prisma.user.create({ data: { Email, name: 'Rachel Remix', password: { create: { hash: hashedPassword, }, }, }, });
これで、最終的にこれらすべての変更をデータベースと同期する準備が整いました。 ただし、データベースは以前に作成されたスキーマといくつかのシードされたデータで既にスピンアップされており、それ以来、データベースが変更されているため、すべての変更をすぐに同期することはできません. 代わりに、移行を少し調整する必要があります。 Prisma は、この種の調整のためのコマンドを提供します 幸いなことに、既存のデータとスキーマは本番環境などにはないため、現時点では、データベースを削除して現在のスキーマで新たに開始する方が簡単です。 それでは、より簡単な方法で次のコマンドを実行してみましょう。
./node_modules/.bin/prisma migrate reset
./node_modules/.bin/prisma migrate dev
最初のコマンドはデータベースをリセットし、XNUMX 番目のコマンドは現在のスキーマ定義を使用してすべてのテーブルでデータベースを再作成し、シードされたデータを入力します。
それでは、実行中のアプリ サーバーを停止し、アプリを再セットアップして元に戻します。
npm run setup
npm run dev
ユーザー登録の更新
user テーブルに新しい名前列を追加したので、サインアップ時にユーザーに名前を入力するように要求することから始めましょう。 反応の通常のアプリケーション構築方法にほとんど慣れている場合、これは大きなショックを与えることなく、物事を行うリミックス方法への良い入り口になります。
ユーザー サインアップのコードは次の場所にあります。 ./app/routes/join.tsx
ファイル。 それを開いて、 <Form>
次のコードを構成して、名前の入力フィールドを追加します。
<Form method="post" className="space-y-6" noValidate> <div> <label htmlFor="name" className="block text-sm font-medium text-gray-700" > Full Name </label> <div className="mt-1"> <input ref={nameRef} id="name" required autoFocus={true} name="name" type="text" aria-invalid={actionData?.errors?.name ? true : undefined} aria-describedby="name-error" className="w-full rounded border border-gray-500 px-2 py-1 text-lg" /> {actionData?.errors?.name && ( <div className="pt-1 text-red-700" id="name-error"> {actionData.errors.name} </div> )} </div> </div>
基本的に、既存の電子メール フィールドを模倣します。 ここで、名前の入力が正しく処理されるように、ここでさらにいくつか調整する必要があります。 まず、名前フィールドへの参照を作成しましょう。名前入力の処理でエラーが発生した場合は、フォームの他のフィールドと同様に、そのフィールドにオート フォーカスを設定します。
const emailRef = React.useRef<HTMLInputElement>(null); const nameRef = React.useRef<HTMLInputElement>(null); const passwordRef = React.useRef<HTMLInputElement>(null); React.useEffect(() => { if (actionData?.errors?.email) { emailRef.current?.focus(); } else if (actionData?.errors?.password) { passwordRef.current?.focus(); } else if (actionData?.errors?.name) { nameRef.current?.focus(); } }, [actionData]);
今何ですか actionData
? これは単に、送信要求からサーバーから返された応答です。 フォーム送信アクションは、ブラウザーからサーバーに投稿要求を送信し、リミックスはそれを介して処理します action
コンポーネントのすぐ上で定義された関数。 この関数は、ブラウザーから送信されたデータにアクセスするための非常に便利なメソッドを提供する request プロパティを持つオブジェクトを受け取ります。この関数から、ブラウザー コードが適切に処理できる応答を返すことができます。 この場合、送信されたデータを検証し、名前フィールドが実際に入力されていることを確認します。 action
関数:
const email = formData.get("email"); const name = formData.get("name"); const password = formData.get("password"); if (typeof name !== "string" || name.length === 0) { return json<ActionData>( { errors: { name: "Name is required" } }, { status: 400 } ); }
要約すると、フォーム送信リクエストから名前入力を取得し、名前が入力されていない場合はエラーメッセージを返します。戻りデータは ActionData
タイプの場合、定義を調整して name プロパティを追加する必要があります。
interface ActionData { errors: { email?: string; name?: string; password?: string; };
}
間違った入力のケースのみを処理したので、先に進み、正しい入力の場合に、行を更新してユーザーの名前が列プロパティに挿入されることを確認しましょう。 const user = await createUser(email, password);
〜へ const user = await createUser(email, password, name);
したがって、次の定義を調整する必要があります。 createUser
セクションに app/models/user.server.ts
ファイル:
export async function createUser(email: User["email"], password: string, name: string) { const hashedPassword = await bcrypt.hash(password, 10); return prisma.user.create({ data: { email, name, password: { create: { hash: hashedPassword, }, }, }, });
}
ここで注意すべき点がいくつかあります。
- サーバー固有のコードを分離してクライアントから遠ざけるために、ファイルに接尾辞を付けることができます
.server.ts
. - 非常に表現力豊かで直感的なプリズマ API を使用して、新しい行をデータベースに簡単に挿入します。 これは通常、次の形式をとります。
prisma.<entityName>.<actionName>({})
コラボレーentityName
は小文字のテーブル名で、actionName
は、create、update、findOne などの db 操作です。これらの使用はすぐに増えます。
これで、ユーザーがヒットしたときに検証される新しい名前入力を追加しました Create Account
.
これはおそらく、変更を git にチェックインするための適切な停止ポイントなので、コードをコミットしましょう。 git add . && git commit -am “:sparkles: Add name field to the sign up form”
セッションズ
これまでのところ、Remix がどのように機能するかについての洞察を得るために、あちこちで既存のコードをほとんど調整してきました。 ここで、独自のモジュールをゼロから構築することに飛び込みます。 最初に構築するのは、ユーザーが最初のアプリ仕様の定義に従って AMA セッションをホストする方法です。
リミックスでは、URL ルートはファイル ベースです。 つまり、まったく新しいパラダイムを発明するので、単純化して file based routing
おそらくあまり正確でも公正でもありませんが、ゆっくりと説明していきます。 セッションを開始するには、
- 現在および過去のすべてのセッションが一覧表示される一覧ページ
- すべての質問、回答、コメント スレッドが表示されるセッションごとの専用ページ
- ログインしているユーザーの新しいセッションを開始するページ
まずは一覧ページから。 で新しいファイルを作成します。 app/routes/sessions/index.tsx
その中に次のコードを入れます。
import { Link, useLoaderData } from "@remix-run/react";
import { getSessions } from "~/models/session.server";
import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Header } from "~/components/shared/header";
import { Button } from "~/components/shared/button"; type LoaderData = { sessions: Awaited<ReturnType<typeof getSessions>>;
}; export const loader: LoaderFunction = async () => { const sessions = await getSessions(); if (!sessions?.length) { throw new Response("No sessions found", { status: 404 }); } return json<LoaderData>({ sessions });
} export function CatchBoundary() { return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className="rounded bg-red-100 p-5"> <h4 className="text-lg font-bold">No sessions found</h4> <p className="mb-4">Why don't you start one... could be fun!</p> <Button isLink to="new" className="bg-blue-600 text-white"> Start AMA session! </Button> </div> </div> </> );
} export default function SessionIndexPage() { const data = useLoaderData<LoaderData>(); const dateFormatter = new Intl.DateTimeFormat("en-GB"); return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div> {data.sessions?.map((session) => ( <div key={`session_list_item_${session.id}`} className="mt-4 p-4 shadow-sm" > <div className="flex flex-row"> <Link className="underline" to={session.id}> {session.user.name} -{" "} {dateFormatter.format(new Date(session.createdAt))} </Link> <span className="px-2">|</span> <div className="flex flex-row"> <img width={18} height={18} alt="Question count icon" src="/icons/question.svg" /> <span className="ml-1">{session._count.questions}</span> </div> </div> <p className="pt-2 text-sm text-gray-700">{session.content}</p> </div> ))} </div> </div> </> );
}
反応に精通している場合、これはほとんどの場合、見慣れているはずです。 ただし、少しずつ分解してみましょう。 Remix は、デフォルトのエクスポートされたコンポーネントをレンダリングします。 コンポーネント定義の上に、 loader
関数。 これは、ルート/ファイルごとに 1 つのみ持つことができる特別な関数であり、ページの読み込み時に Remix がこの関数を呼び出して、ページが必要とするデータを取得します。 次に、コンポーネントをデータでハイドレートし、レンダリングされた HTML をネットワーク経由で応答として送信します。これは、マジック ビヘイビアまたは Remix の XNUMX つです。 これにより、ブラウザの JS コードが API リクエストからデータをロードするときに、ユーザーがロード状態を確認する必要がなくなります。 アクション関数の本体は、 getSessions()
からインポートされる関数 ~/models/session.server
. ここでは、db 操作をサーバーのみのファイルに配置するという、既に説明した戦略に従っています。 新しいファイルを作成しましょう app/models/session.server.ts
そしてそれに次のコードを入れてください:
import { prisma } from "~/db.server"; export type { Session, Question, Comment } from "@prisma/client"; export const getSessions = () => { return prisma.session.findMany({ include: { user: true, _count: { select: { questions: true }, }, }, });
};
UI でホストの情報を使用するため、セッション テーブルからすべてのエントリとそれらに関連するすべてのユーザー エントリをフェッチするだけで、各セッションの質問の総数も含まれます。 アプリが成長するにつれて、数十万の AMA セッションが存在する可能性があり、それらすべてを取得することはうまくスケーリングできないため、これは非常にスケーラブルではありません。 ただし、この記事の目的上、ここではページネーションを省略します。
私たちの話に戻りましょう sessions/index.tsx
ルートファイル。 データベースにセッションがない場合は、 Response
Remix のヘルパー。 それ以外の場合は、セッションの配列を含む JSON 応答を返します。 json
Remix のヘルパー。
const data = useLoaderData<LoaderData>();
から返された応答のデータにアクセスできる特別な Remix フックを呼び出しています。 action
. エラー応答をどのように処理しているのか疑問に思われるかもしれません。 それは間違いなくの本体で処理されていません SessionIndexPage
関数。 Remix は長く利用可能な ErrorBoundary
エラー ビューを処理するための機能。 必要なのは、という名前の反応コンポーネントをエクスポートすることだけです CatchBoundary
ルート ファイルおよびルート (クライアントまたはサーバー) のレンダリングからスローされたエラーから、 CatchBoundary
コンポーネントがレンダリングされます。 これを本当に簡単に定義しましょう SessionIndexPage
成分:
export function CatchBoundary() { return ( <> <Header /> <div className="mx-auto px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className="rounded bg-red-100 p-5"> <h4 className="text-lg font-bold">No sessions found</h4> <p className="mb-4">Why don't you start one... could be fun!</p> <Button isLink to="new" className="bg-blue-600 text-white"> Start AMA session! </Button> </div> </div> </> );
} export default function SessionIndexPage() {
…
これは、共有ヘッダー コンポーネントと、新しいセッションを開始するためのリンクをレンダリングするだけです。 また、共有を使用しています Button
成分。 これらの共有コンポーネントを構築しましょう。 それらを app/components/shared/
ディレクトリ。 から始めましょう app/components/shared/header.tsx
ファイル:
import { Link } from "@remix-run/react"; export const HeaderText = () => { return ( <h1 className="text-center text-3xl font-cursive tracking-tight sm:text-5xl lg:text-7xl"> <Link to="/sessions" className="block uppercase drop-shadow-md"> AMA </Link> </h1> );
}; export const Header = () => { return ( <div className="flex flex-row justify-between items-center px-6 md:w-5/6 lg:w-4/5 xl:w-2/3 mx-auto py-4"> <HeaderText /> </div> );
};
これは、いくつかの追い風のスタイリングが散りばめられた基本的な反応コンポーネントです。 私たちは、 Link
Remix のコンポーネント (基本的には、 Link
反応ルーターのコンポーネント) を使用して、セッションのリスト ページにリンクします。 ここでもう XNUMX つ注目すべきことは、 font-cursive
ヘッダー テキストのスタイルを変更して、ロゴのように見せます。 筆記体フォント スタイルは、デフォルトの tailwind 構成に含まれていないため、自分で構成する必要があります。 を開きます tailwind.config.js
プロジェクトのルートからファイルを開き、 theme
以下のようなプロパティ:
module.exports = { content: ["./app/**/*.{ts,tsx,jsx,js}"], theme: { extend: { fontFamily: { cursive: ["Pinyon Script", "cursive"], }, }, }, plugins: [],
};
追加のビットがテーマを拡張して、新しい fontFamily という名前を追加することに注意してください。 cursive
値は Pinyon Script
私はこれをグーグルフォントから選びましたが、あなた自身のフォントを自由に選んでください. 追い風にあまり慣れていない場合、これは、 font-cursive
ヘルパー クラスが必要ですが、Web ページにフォント自体をロードする必要があります。 外部アセットを Remix に追加するのは非常に簡単です。 開く app/root.tsx
ファイルを作成して更新します links
配列に 3 つの新しいオブジェクトを追加する定義:
export const links: LinksFunction = () => { return [ { rel: "stylesheet", href: tailwindStylesheetUrl }, { rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.gstatic.com", }, { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Pinyon+Script&display=swap", }, ];
};
上記のすべてのリンクは、Google から取得されます フォントページはこちら.
私たちの歩みを sessions/index.tsx
ファイル、他の共有コンポーネントはボタン コンポーネントです。 早速作ってみましょう app/components/shared/button.tsx
:
import React from "react";
import { Link } from "@remix-run/react";
import type { LinkProps } from "@remix-run/react"; export type ButtonProps = { isAction?: boolean; isLink?: boolean;
}; export const Button: React.FC< ButtonProps & (ButtonProps["isLink"] extends true ? LinkProps : React.ButtonHTMLAttributes<HTMLButtonElement>)
> = ({ children, isLink, isAction, className, ...props }) => { let classNames = `${className || ""} px-3 py-2 rounded`; if (isAction) { classNames += " bg-green-300 text-gray-600 text-sm font-semi-bold"; } if (isLink) { return ( <Link className={classNames} {...(props as LinkProps)}> {children} </Link> ); } return ( <button className={classNames} {...props}> {children} </button> );
};
これは、アプリのさまざまな場所でリンクまたはアクション ボタンのいずれかであるボタンのルック アンド フィールを統一するのに役立つシンプルなボタン コンポーネントです。 ボタンとリンクの props を受け入れながらコンポーネント タイプを安全にするために、typescript マジックを props とレンダリングに適用します。
最後に、実際のページ コンポーネント コード自体を確認します。 このページはすべてのセッション エントリをマップし、セッションの日付、セッションの主催者の名前、セッションの主催者によって追加された前提/詳細、および質問の総数を示します。 日付をレンダリングするために、ブラウザの組み込みを使用しています ロケールベースのフォーマットをサポートする Intl モジュール. 問題数の横に小さな SVG アイコンを使用しています。 アプリで使用されているすべてのアセットをここで見つけることができます https://github.com/foysalit/remix-ama/tree/main/public/icons しかし、好きなように独自のアイコンを自由に使用してください。 すべてのパブリック アセットを /public
すべてのアイコンをまとめるために、icons ディレクトリを作成しました。
上記のすべてで、あなたは今行くことができるはずです http://localhost:3000/sessions まだセッションを作成していないため、404 エラー ページが表示されます。
それでは、新しいセッション ページを作成して、セッションをホストし、リスト ページで確認できるようにしましょう。 ユーザーが簡単にアクセスできるように、別のページにそれを配置します /sessions/new
私たちのアプリで、セッションのホスティングを開始します。 新しいファイルを作成する routes/sessions/new.tsx
次のコードで:
import { Form, useActionData, useTransition } from "@remix-run/react";
import { ActionFunction, json, LoaderFunction, redirect,
} from "@remix-run/node";
import { startSessionsForUser } from "~/models/session.server";
import { requireUserId } from "~/session.server";
import { Header } from "~/components/shared/header";
import { Button } from "~/components/shared/button"; export type ActionData = { errors?: { content?: string; alreadyRunning?: string; };
}; export const action: ActionFunction = async ({ request }) => { const userId = await requireUserId(request); const formData = await request.formData(); try { const content = formData.get("content"); if (typeof content !== "string" || content.length < 90) { return json<ActionData>( { errors: { content: "Content is required and must be at least 90 characters.", }, }, { status: 400 } ); } const session = await startSessionsForUser(userId, content); return redirect(`/sessions/${session.id}`); } catch (err: any) { if (err?.message === "already-running-session") { return json<ActionData>( { errors: { alreadyRunning: "You already have a session running." }, }, { status: 400 } ); } return json({ error: err?.message }); }
}; // A simple server-side check for authentication to ensure only logged in users can access this page
export const loader: LoaderFunction = async ({ request }) => { await requireUserId(request); return json({ success: true });
}; export default function SessionNewPage() { const transition = useTransition(); const actionData = useActionData(); return ( <> <Header /> <div className="p-5 bg-gray-50 px-6 md:w-5/6 lg:w-4/5 xl:w-2/3 mx-auto rounded"> <h4 className="font-bold text-lg"> Sure you want to start a new AMA session? </h4> <p className="mb-4"> An AMA session lasts until the end of the day regardless of when you start the session. During the session, any user on the platform can ask you any question. You always have the option to not answer. <br /> <br /> Please add a few lines to give everyone some context for the AMA session before starting. </p> <Form method="post"> <textarea rows={5} autoFocus name="content" className="w-full block rounded p-2" placeholder="Greetings! I am 'X' from 'Y' TV show and I am delighted to be hosting today's AMA session..." /> {actionData?.errors?.content && ( <p className="text-red-500 text-sm">{actionData.errors.content}</p> )} <Button className="px-3 py-2 rounded mt-3" disabled={transition.state === "submitting"} type="submit" isAction > {transition.state === "submitting" ? "Starting..." : "Start Session"} </Button> </Form> </div> {actionData?.errors?.alreadyRunning && ( <div className="mt-4 p-5 bg-red-500 mx-auto min-w-[24rem] max-w-3xl rounded"> <p>{actionData.errors.alreadyRunning}</p> </div> )} </> );
}
いつものように、この大きなコードの塊を分解してみましょう。
- アクション – ユーザーがセッションの詳細とヒットを入力したとき
Start Session
フォーム データを POST リクエストとして受け取り、現在ログインしているユーザーの新しいセッションを作成します。 したがって、アクションはrequireUserId(request)
小切手。 これはスタックに付属するヘルパー メソッドであり、承認されていないユーザーをログイン ページに再ルーティングするか、承認されたユーザーの ID を返すだけです。 次に、セッションのユーザー入力を取得していますcontent
使用する列request.formData()
これにより、すべての POST データにアクセスできます。 コンテンツが入力されていないか、特定の長さを超えている場合は、エラー メッセージが返されます。 それ以外の場合は、セッションを開始し、ユーザーを新しく作成されたセッション ページにルーティングします。 - startSessionsForUser – これは、データベースに新しいセッション エントリを作成するサーバー専用の関数です。 これを追加しましょう
models/session.server.ts
ファイル:
import type { User, Session } from "@prisma/client";
import startOfDay from "date-fns/startOfDay";
import endOfDay from "date-fns/endOfDay"; export const startSessionsForUser = async ( userId: User["id"], content: Session["content"]
) => { const runningSession = await prisma.session.findFirst({ where: { createdAt: { lte: endOfDay(new Date()), gte: startOfDay(new Date()), }, userId, }, }); if (runningSession) { throw new Error("already-running-session"); } return prisma.session.create({ data: { userId, content } });
};
この関数は、userId とセッションのコンテンツを受け取ります。 今日の境界内にユーザーによって作成されたセッションが既にある場合は、エラーがスローされます。それ以外の場合は、新しいセッション エントリが作成されます。 日付の操作は JS ではちょっと変なので、日付を処理するためにプロジェクトにライブラリをドロップすることを好みます。 この場合、私は使用しています 日付-fns ライブラリ ただし、好みのライブラリを自由に使用してください。
- ローダー: 許可されたユーザーのみがこのページを表示できるようにするため、ローダーは単純に
requireUserId()
認証されていないユーザーをログアウトし、セッション作成フォームが表示されないようにする機能。 - Transition – Remix には非常に便利な機能が付属しています
useTransition()
ページのさまざまな状態にアクセスできるようにするフック。 ページからフォームを送信すると、データがサーバーに送信され、応答が返されるのを待ちます。transition.state
〜に変わりますsubmitting
その期間中。 これを使用して、送信ボタンを無効にして、ユーザーが誤って複数のセッションを作成しようとするのを防ぎます。 - エラー処理 – ユーザーがセッションを開始しようとすると、コンテンツ フィールドの検証エラーが返されるか、実行中のセッションが既に存在する場合は特定のエラーが返されます。
useActionData()
. - フォーム コンポーネント –
Form
remix のコンポーネントは、ブラウザーのフォーム コンポーネントの上にある小さな構文糖衣にすぎません。 フォームのすべてのデフォルトの動作を維持します。 ここでさらに詳しく読むことができます: https://remix.run/docs/en/v1/guides/data-writes#plain-html-forms
上記のすべての手順に従っている場合は、 http://localhost:3000/sessions/new ブラウザで、上記のようなページが表示されるはずです。 ただし、入力フィールドに入力して [セッションの開始] をクリックすると、404 not found ページが表示されますが、それはボタンが機能しなかったという意味ではありません。 手動で戻ることができます http://localhost:3000/sessions リストページで、自分で新しく作成したセッションを確認します。 このようなもの:
質問と回答
セッションのリストとページの作成がうまく機能するようになったので、セッションごとに Q&A を作成できるようになりました。 各セッションは次の方法でアクセスできる必要があります sessions/:sessionId
URLどこ :sessionId
セッションの ID に置き換えられる変数です。 ダイナミック ルート パラメータを Remix のルート ファイルにマップするには、ファイル名を次の文字列で始める必要があります。 $
パラメータの名前の後に記号が付きます。 この場合、新しいファイルを作成しましょう routes/sessions/$sessionId.tsx
次のコードで:
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useCatch, useLoaderData, Outlet, useParams,
} from "@remix-run/react";
import invariant from "tiny-invariant"; import { addAnswerToQuestion, addQuestionToSession, getSession,
} from "~/models/session.server";
import { getUserId, requireUserId } from "~/session.server";
import { Button } from "~/components/shared/button";
import { QuestionAnswer } from "~/components/sessions/question-answer";
import { Header } from "~/components/shared/header"; type ActionData = { errors?: { title?: string; body?: string; };
}; type LoaderData = { session: Awaited<ReturnType<typeof getSession>>; currentUserId?: string;
}; export type OutletContext = LoaderData; export const loader: LoaderFunction = async ({ request, params }) => { invariant(params.sessionId, "sessionId not found"); const session = await getSession(params.sessionId); if (!session) { throw new Response("Not Found", { status: 404 }); } const currentUserId = await getUserId(request); return json<LoaderData>({ session, currentUserId });
}; export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); invariant(params.sessionId, "sessionId not found"); const formData = await request.formData(); const questionId = formData.get("answer_to_question"); if (typeof questionId === "string") { const answer = formData.get("answer"); if (typeof answer !== "string" || answer?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Answer is required" } }, { status: 400 } ); } await addAnswerToQuestion({ id: questionId, userId, answer }); return redirect(`/sessions/${params.sessionId}/questions/${questionId}`); } const content = formData.get("content"); if (typeof content !== "string" || content?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Question is required" } }, { status: 400 } ); } const question = await addQuestionToSession({ userId, sessionId: params.sessionId, content, }); return redirect(`/sessions/${params.sessionId}/questions/${question.id}`);
}; export default function SessionDetailsPage() { const params = useParams(); const data = useLoaderData() as LoaderData; const dateFormatter = new Intl.DateTimeFormat("en-GB"); return ( <> <Header /> <div className="mx-auto flex w-full flex-row justify-between px-6 md:w-5/6 lg:w-4/5 xl:w-2/3"> <div className={params.questionId ? "w-1/2" : "w-full"}> <h3 className="flex flex-row items-center justify-between"> <span className="text-2xl font-bold"> {data.session?.user.name} </span> <span> {dateFormatter.format( new Date(data.session?.createdAt || Date.now()) )} </span> </h3> <p className="py-6">{data.session?.content}</p> {data.currentUserId !== data.session?.userId && ( <div className="mb-4 rounded bg-gray-100 p-3"> <Form method="post"> <div> <label htmlFor="question" className="block"> <div className="mb-2 flex flex-row items-center"> <img alt="Question logo" src="/icons/question.svg" width={45} height={45} /> <span className="ml-2 leading-4"> Ask your question <br /> <i className="text-xs text-gray-800"> Please be concise and expressive. No explicit content allowed! </i> </span> </div> <textarea rows={5} name="content" className="block w-full rounded p-2" /> </label> </div> <div className="mt-2 flex justify-end"> <Button type="submit" isAction> Ask Question </Button> </div> </Form> </div> )} {!!data.session?.questions?.length && ( <ul> {data.session.questions.map((q) => ( <QuestionAnswer question={q} key={`question_${q.id}`} canAnswer={data.currentUserId === data.session?.userId} isSelected={params.questionId === q.id} /> ))} </ul> )} </div> <Outlet context={data} /> </div> </> );
} export function ErrorBoundary({ error }: { error: Error }) { console.error(error); return <div>An unexpected error occurred: {error.message}</div>;
} export function CatchBoundary() { const caught = useCatch(); if (caught.status === 404) { return <div>Session not found</div>; } throw new Error(`Unexpected caught response with status: ${caught.status}`);
}
これにより、すでに説明したいくつかの概念をざっとざっと見て、新しいビットにさらに焦点を当てます。
- ローダー: セッション エントリと現在のユーザーの ID を返します。 への呼び出しを呼び出します
invariant
これは、変数が真であるかどうかを簡単にチェックし、そうでない場合はエラーをスローするための外部ライブラリです。 - getSession: sessionId を唯一の引数として受け取ります。 弊社で実装してみましょう
models/session.server.ts
ファイル:
export const getSession = (id: Session["id"]) => prisma.session.findFirst({ where: { id }, include: { questions: { include: { user: true, }, }, user: true, }, });
通知 セッションに属するすべての質問と、それらの質問をしたユーザーもどのように含まれているか。
- アクション: このページは、閲覧者に基づいて 2 つのことを行うことができます。 セッションの主催者は質問に答えることができますが、質問することはできません。 他のすべてのユーザーは、反対のことしかできません。 したがって、アクションは両方のアクションを処理する必要があり、XNUMX つのアクションを区別する方法は、
formData.get("answer_to_question")
入力。 クライアント側からは、ホストが質問への回答を送信しているときにのみ送信されます。 通知 ユーザーをリダイレクトする方法/sessions/${params.sessionId}/questions/${questionId}
どちらのアクションの場合ですか? これが、ネストされたルーティングへの入り口です。 これは後で頭の片隅に置いておいてください。 - addAnswerToQuestion: このヘルパーは、質問の ID と回答の入力を含むオブジェクトを引数として取り込んで、ホストの回答を質問に追加します。 これを実装しましょう
models/session.server.ts
:
import type { User, Session, Question } from "@prisma/client"; export const addAnswerToQuestion = async ({ id, userId, answer,
}: Pick<Question, "id" | "userId" | "answer">) => { const existingQuestion = await prisma.question.findFirst({ where: { id }, include: { session: true }, }); if (!existingQuestion) { throw new Error("question-not-found"); } if (existingQuestion.session.userId !== userId) { throw new Error("not-session-author"); } return prisma.question.update({ where: { id }, data: { answer } });
};
リクエストを行ったユーザーが実際にセッションのホストであるかどうかを実装がチェックし、そうでない場合は特定のエラーをスローすることに注意してください。
- addQuestionToSession: これは、ユーザーとセッションの ID と質問入力を含むオブジェクト引数を取得することにより、非ホスト ユーザーの質問をセッションに追加します。 これは、それが実装されている方法です
models/session.server.ts
:
export const addQuestionToSession = async ({ userId, sessionId, content,
}: Pick<Question, "userId" | "sessionId" | "content">) => { const existingQuestion = await prisma.question.findFirst({ where: { userId, sessionId, content, }, }); if (existingQuestion) { throw new Error("already-asked"); } const isSessionHost = await prisma.session.findFirst({ where: { userId, id: sessionId, }, }); if (isSessionHost) { throw new Error("host-can-not-ask-questions"); } return prisma.question.create({ data: { sessionId, userId, content } });
};
ユーザーがセッションごとに同じ質問を複数回投稿するのをブロックしていることに注意してください。
- useParams フック: このフックは、ルーターに反応する別のプロキシであり、この場合、sessionId などの任意のルート パラメーターへのアクセスを単純に提供します。
- 質問フォーム: すべてのホスト以外の認証済みユーザーに対して、以前に投稿された質問のリストの上に、すべてのセッションで質問入力フォームが表示されます。
- QuestionAnswer コンポーネント: コードの大部分を共有および分離しておくために、XNUMX つの質問を共有コンポーネント ファイルに入れます。 その理由は後ほど説明しますが、最初にこのコンポーネントの実装を見てみましょう。 新しいファイルを作成する
app/components/sessions/question-answer.tsx
そこに次のコードを挿入します。
import { Form, Link } from "@remix-run/react";
import React from "react"; import type { Question } from "~/models/session.server";
import type { User } from "~/models/user.server";
import { Button } from "~/components/shared/button"; export const QuestionAnswer: React.FC<{ question: Question & { user: User }; isSelected?: boolean; as?: React.ElementType; canAnswer: boolean; hideCommentsLink?: boolean;
}> = ({ question, hideCommentsLink, isSelected, as: Component = "li", canAnswer, ...rest
}) => { const dateFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", }); return ( <Component className={`mb-4 rounded p-2 ${isSelected ? "bg-gray-50" : ""}`} {...rest} > <div className="flex flex-row"> <div className="max-w-40 mr-2"> <img width={50} height={50} alt="Question icon" src="/icons/question.svg" /> </div> <p> <span className="font-semi-bold text-xs text-gray-500"> {question.user?.name} at{" "} {dateFormatter.format(new Date(question.createdAt))} {!hideCommentsLink && ( <> {" "} |{" "} <Link className="underline" to={`questions/${question.id}`}> Comments </Link> </> )} </span> <br /> {question.content} </p> </div> {question.answer ? ( <div className="mt-2 pl-10"> <div className="flex flex-row p-2 shadow-sm"> <img width={50} height={50} alt="Question icon" src="/icons/answer.svg" /> <p> <span className="font-semi-bold text-xs text-gray-500"> {dateFormatter.format(new Date(question.updatedAt))} </span> <br /> {question.answer} </p> </div> </div> ) : ( canAnswer && ( <div className="mt-4 px-4"> <Form method="post"> <textarea rows={5} name="answer" className="block w-full rounded p-2" /> <div className="mt-2 flex justify-end"> <Button name="answer_to_question" value={question.id} isAction> Answer </Button> </div> </Form> </div> ) )} </Component> );
};
このコンポーネントは内部にフォームを埋め込んでいることに注意してください。つまり、すべての質問がホストに対してこのフォームをレンダリングし、ホストがまだ回答していない質問への回答を簡単に追加できるようにし、フォームの送信ボタンが name="answer_to_question" value={question.id}
ホストによる回答入力として、このフォーム送信に取り組む必要があることをバックエンド (アクション) に通知するのに役立つ小道具。
お気づきかもしれませんが、すべての質問は to={
質問/${question.id}}
これにより、ネストされたルーティングのトピックにたどり着きます。 それを見てみましょう。
ネストされたルーティング
従来の反応アプリでは、ページを複数のコンポーネントに分割し、コンポーネントは内部的に独自のデータをロードするか、データを渡すグローバル データ ストアから供給されます。 Remix では、データ ローダー、アクション、エラー バウンダーなどの独自のライフサイクルを持つ別のページをページに埋め込むことができるネストされたルーティングを介してこれを行います。これは信じられないほど強力で、UX にまったく新しいレベルの信頼性と速度を追加します。 . これを使用して、セッション内の質問ごとにコメント スレッドを表示します。
これを容易にするために、 <Outlet context={data.session} />
セッションの詳細ページのコンポーネント。 Outlet
ネストされたページ コンテンツのコンテナであり、親レベルで子ページのレイアウトを構築する機能を提供します。 ユーザーがネストされたルートに入ると、ネストされたページ ルートの最下位レベルでレンダリングされた html に置き換えられます。
ここで、コメント スレッドにアクセスするために、ユーザーを session/:sessionId/questions/:questionId
ファイルシステムでそれに一致するようにルーティングするには、内部に新しいディレクトリを作成する必要があります routes/sessions/$sessionId/questions
という名前のファイルを作成します $questionId.tsx
その中に。 という名前のファイルがあることに注意してください $sessionId.tx
およびという名前のディレクトリ $sessionId
. これは紛らわしいかもしれませんが、設計どおりです。 これは Remix に sessionId.tsx ファイルを親ページとしてレンダリングし、sessionId.tsx ファイルからネストされたルートを親ページとしてレンダリングし、`セッションIDdirectory. Now let’s put in the following code in the
$questionId.tsx` ファイル:
import type { LoaderFunction, ActionFunction } from "@remix-run/node"; // or "@remix-run/cloudflare"
import { Form, Link, useLoaderData, useOutletContext, useParams, useTransition,
} from "@remix-run/react";
import type { Comment } from "~/models/session.server";
import { addCommentToAnswer, getCommentsForQuestion,
} from "~/models/session.server";
import invariant from "tiny-invariant";
import { json, redirect } from "@remix-run/node"; import type { OutletContext } from "../../$sessionId";
import { requireUserId } from "~/session.server";
import type { User } from "~/models/user.server";
import { QuestionAnswer } from "~/components/sessions/question-answer";
import { Button } from "~/components/shared/button";
import React, { useEffect, useRef } from "react"; type LoaderData = { comments: Awaited<ReturnType<typeof getCommentsForQuestion>>;
}; type ActionData = { errors?: { title?: string; body?: string; };
}; export const loader: LoaderFunction = async ({ params }) => { invariant(params.questionId); const data: LoaderData = { comments: await getCommentsForQuestion(params.questionId), }; return json(data);
}; export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); invariant(params.sessionId, "sessionId not found"); invariant(params.questionId, "questionId not found"); const formData = await request.formData(); const content = formData.get("content"); if (typeof content !== "string" || content?.trim()?.length < 3) { return json<ActionData>( { errors: { title: "Comment is required" } }, { status: 400 } ); } await addCommentToAnswer({ userId, content, questionId: params.questionId, }); return redirect( `/sessions/${params.sessionId}/questions/${params.questionId}` );
}; export default function SessionQuestion() { const params = useParams(); const commentFormRef = useRef<HTMLFormElement>(null); const transition = useTransition(); const outletData = useOutletContext<OutletContext>(); const data = useLoaderData(); const question = outletData?.questions.find( (q) => q.id === params.questionId ); const isCommenting = transition.state === "submitting"; useEffect(() => { if (!isCommenting) { commentFormRef?.current?.reset(); } }, [isCommenting]); if (!question) return null; const dateFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", }); return ( <div className="w-1/2"> <div className="pl-8"> <Link to={`/sessions/${params.sessionId}`} className="bg-gray-500 rounded-sm px-2 py-1 text-white flex flex-row justify-between" > <span>Thread</span> <span>✕</span> </Link> <QuestionAnswer question={question} as="div" hideCommentsLink /> <div className="bg-gray-100 p-3 mb-4 rounded"> <Form method="post" ref={commentFormRef}> <label htmlFor="comment" className="block"> <div className="flex flex-row mb-2 items-center"> <img alt="Question logo" src="/icons/comment.svg" width={45} height={45} /> <span className="ml-2 leading-4"> Add a comment <br /> <i className="text-xs text-gray-800"> Please be polite. No explicit content allowed! </i> </span> </div> <textarea rows={5} className="w-full block rounded p-2" name="content" /> </label> <div className="mt-2 flex justify-end"> <Button type="submit" isAction> Comment </Button> </div> </Form> </div> <ul> {data.comments?.map((comment: Comment & { user: User }) => ( <li key={`comment_${comment.id}`} className="mt-4"> <div className="flex flex-row"> <div> <img width={40} height={40} alt="Question icon" className="mr-2" src="/icons/comment.svg" /> </div> <p> <span className="font-semi-bold text-xs text-gray-500"> {comment.user?.name} at{" "} {dateFormatter.format(new Date(comment.createdAt))} </span> <br /> <span className="text-gray-800 text-sm">{comment.content}</span> </p> </div> </li> ))} </ul> </div> </div> );
}
ここでは、それを使用しています question-answer.tsx
コンポーネントを使用して、セッションの下に表示するのと同じ UI コンポーネントを表示しますが、この場合はコメント スレッドの上部に表示し、読者にコメントのコンテキストを提供します。 また、認証されたユーザーがコメントを投稿できるフォームを内部に配置しています。 ローダーで使用している 2 つの新しいサーバー関数を確認してから、このページのアクションを models/session.server.ts
:
import type { User, Session, Question, Comment } from "@prisma/client"; export const addCommentToAnswer = async ({ questionId, userId, content,
}: Pick<Comment, "questionId" | "userId" | "content">) => { return prisma.comment.create({ data: { questionId, userId, content } });
}; export const getCommentsForQuestion = async (questionId: string) => { return prisma.comment.findMany({ where: { questionId }, include: { user: true }, });
};
このコンポーネントの注目すべき点は次のとおりです。
- useOutletContext フック: これにより、子ページに渡されたすべての小道具にアクセスできます。
<Outlet … />
親ページのコンポーネント。 したがって、ここでは、すべての質問を含むセッション全体にアクセスできます。スレッドの単一の質問を照会する代わりに、既に渡されたデータからそれを選択するだけです。 - コメントの読み込み: ページネーションなしで質問のすべてのコメントを読み込みますが、これは本番アプリにとって良い考えではありません。
包む
前の手順をすべて実行したら、シークレット ウィンドウでアプリを開き、新しいアカウントを作成します。 次に、以前に作成したセッションをクリックすると、質問するための入力フィールドが表示されます。
質問を入力してその新しいアカウントから投稿すると、次のように表示されます。
あなたのコメントを表示し、右側にコメントをスレッドとして開き、あなたまたは他のユーザーがスレッドにコメントを追加できるようにします。
最後に、セッションのホストとしてログインしている別のブラウザ ウィンドウに戻り、セッション ページを更新すると、回答を投稿するための入力がすぐ下にあるコメントが表示されます。
次は何ですか?
ここまで頑張ってきたので、拍手をお願いします! あなたが私のようで、光沢のある新しい JS のことを十分に理解できない場合は、「これは素晴らしいですが、これはユーザーとして使用するものでしょうか?」と疑問に思うかもしれません。 あなたが自分自身に正直なら、答えは大きな脂肪になるでしょう NO
. というわけで、このおもちゃのアプリを、現実の世界で注目を集める可能性のある本番環境対応のアプリにすばやくまとめることができるいくつかのアイデアを紹介します。
- リアルタイムのデータ同期 – AMA セッションはタイミングがすべてです。 少なくとも良いものはそうです。 それらをホストしている人々は、ぶらぶらして新しいコメント/質問などを探すために10秒ごとに更新する時間がありません。そのため、それらはすべてリアルタイムで同期され、ホストに強調表示される必要があります. 参加者も同様。
- ページネーション – 投稿全体で述べたように、実際のアプリでは確実にスケーリングされないデータ読み込みのいくつかの手抜きを行いました。 すべてのクエリにページネーションを追加することも、良い学習体験になります。
- セッション タイマーと今後のセッション: このアプリのセッションは XNUMX 日ごとにタイム ボックス化されているため、セッションが終了するタイマーを表示すると、エクスペリエンスにスリルの要素が追加される場合があります。 別のキラー機能は、主催者が将来のセッションをスケジュールし、ホームページで次のセッションをより強調した方法で紹介することで、その周りに誇大広告を作成できるようにすることです.
リソース
- SEO を活用したコンテンツと PR 配信。 今日増幅されます。
- Platoblockchain。 Web3メタバースインテリジェンス。 知識の増幅。 こちらからアクセスしてください。
- 情報源: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- 能力
- できる
- 私たちについて
- 上記の.
- アクセス
- アクセス可能な
- アクセス
- 従った
- それに応じて
- 正確な
- Action
- 行動
- 実際に
- 追加されました
- NEW
- さらに
- 住所
- 追加
- 調整
- 後
- 古くから
- 先んじて
- すべて
- 許可
- 一人で
- 並んで
- 既に
- 常に
- AMA
- AMAセッション
- 驚くべき
- 間で
- および
- 別の
- 回答
- 回答
- API
- アプリ
- 申し込む
- アプリ
- 引数
- 周りに
- 配列
- 資産
- 資産
- 試みる
- 認証
- 認証された
- 認証
- 著者
- オート
- 利用できます
- 待つ
- バック
- 支持された
- バックエンド
- ベース
- ベース
- 基本
- 基本的に
- の基礎
- なぜなら
- になる
- 背後に
- さ
- 以下
- の間に
- ビッグ
- ビット
- ブロック
- ブロッキング
- ブログ
- ボディ
- 国境
- ボトム
- 境界
- ボックス
- ブレーク
- もたらす
- ブラウザ
- ビルド
- 建物
- 内蔵
- 束
- (Comma Separated Values) ボタンをクリックして、各々のジョブ実行の詳細(開始/停止時間、変数値など)のCSVファイルをダウンロードします。
- コール
- 呼び出し
- コール
- これ
- 滝
- 場合
- レスリング
- キャッチ
- 一定
- 確かに
- 変化する
- 変更
- 文字
- チェック
- 点検
- 小切手
- 子
- 子供達
- 選択する
- class
- クリア
- クライアント
- コード
- コードベース
- コラム
- コラム
- COM
- コメント
- 注釈
- コミット
- コミュニティ
- コンペ
- コンプリート
- 複雑さ
- コンポーネント
- コンポーネント
- コンセプト
- コンセプト
- 紛らわしい
- その結果
- 領事
- 定数
- 絶えず
- コンテナ
- 含まれています
- コンテンツ
- コンテキスト
- 納得させる
- コーナー
- 可能性
- カップル
- ここから
- カバー
- 作ります
- 作成した
- 作成します。
- クリエイティブ
- CSS
- 電流プローブ
- 現在
- カット
- データ
- データベース
- 日付
- 試合日
- 日付時刻
- 中
- 専用の
- 深いです
- デフォルト
- 絶対に
- 喜んで
- 深さ
- 設計
- 詳細な
- 細部
- デベロッパー
- Developer
- 開発者
- 開発
- DID
- 区別する
- ディレクトリ
- 議論する
- 議論
- ディスプレイ
- ドキュメント
- そうではありません
- すること
- ドント
- ダウン
- Drop
- 落ちる
- 間に
- ダイナミック
- 各
- 早い
- 容易
- 簡単に
- どちら
- 終了
- 婚約
- 十分な
- 確保
- 確実に
- 全体
- エンティティ
- エンティティ
- エントリ
- エラー
- エラー
- 本質的に
- 等
- エーテル(ETH)
- さらに
- 誰も
- すべてのもの
- 進化
- 正確に
- 例
- 除く
- 既存の
- 体験
- export
- 輸出
- 表現力豊かな
- 伸ばす
- 外部
- 目
- 容易にする
- フェア
- 秋
- おなじみの
- 慣れる
- 家族
- ファッション
- スピーディー
- 脂肪
- 特徴
- 特徴
- FRBは
- 足
- 少数の
- フィールド
- フィールズ
- File
- 埋める
- 埋め
- 最後に
- もう完成させ、ワークスペースに掲示しましたか?
- 名
- ファーストルック
- フォーカス
- 続いて
- フォロー中
- フォント
- 外国の
- フォーム
- 発見
- フレームワーク
- 無料版
- 頻繁な
- 新鮮な
- 優しい
- から
- フル
- 完全に
- 楽しいです
- function
- 機能
- 未来
- 利得
- 取得する
- Gitの
- GitHubの
- 与える
- 与える
- 与え
- グローバル
- Go
- ゴエス
- 行く
- 良い
- でログイン
- Googleのフォント
- 素晴らしい
- 下地
- 育ちます
- ハンドル
- ハンドリング
- ハンディ
- ハング
- ハッシュ
- 健康
- 助けます
- ことができます
- こちら
- ハイ
- より高い
- 強調表示された
- 歴史的
- ヒット
- ヒット
- ホーム
- 希望
- host
- ホスティング
- お家の掃除
- 認定条件
- How To
- しかしながら
- HTML
- HTTPS
- 何百
- 誇大広告
- ICON
- アイデア
- 考え
- 識別する
- 画像
- 実装する
- 実装
- 実装
- import
- in
- include
- 含まれました
- 含ま
- 含めて
- 信じられないほど
- index
- 個人
- info
- 初期
- 洞察力
- install
- インストールする
- を取得する必要がある者
- 興味がある
- 導入
- 直観的な
- 投資
- 呼び出す
- 分離された
- IT
- 自体
- ジョブ
- JSON
- ジャンプ
- キープ
- キー
- 種類
- 知っている
- ラベル
- 風景
- 大
- 姓
- 昨年
- レイアウト
- つながる
- 学習
- コメントを残す
- Legacy
- 長さ
- ことができます
- レベル
- LG
- 自由
- 図書館
- LINE
- ライン
- LINK
- リンク
- リスト
- 少し
- 負荷
- ローダ
- ローディング
- 負荷
- ロゴ
- 長い
- 見て
- のように見える
- LOOKS
- たくさん
- 最低レベル
- マジック
- 維持
- 主要な
- make
- 作成
- 操作
- 操作する
- 手動で
- 多くの
- 地図
- ゲレンデマップ
- 一致
- 手段
- 言及した
- メッセージ
- 方法
- メソッド
- かもしれない
- 移動します
- 移行
- モジュール
- 他には?
- 最も
- 移動する
- モジラ
- の試合に
- 名
- 名前付き
- ナビゲート
- 必要
- ニーズ
- 新作
- 次の
- Next.js
- Node.js
- 通常の
- 注目すべき
- 注目に値する
- 数
- オブジェクト
- オブジェクト
- 発生した
- 公式
- ONE
- 開いた
- 開きます
- 操作
- 業務執行統括
- 反対
- オプション
- 注文
- その他
- さもないと
- アウトライン
- 外側
- 自分の
- ページネーション
- パラダイム
- パラメーター
- 部
- 参加者
- 渡された
- パス
- パスワード
- 過去
- のワークプ
- 視点
- 選ぶ
- ピース
- ピース
- 場所
- プラットフォーム
- プラトン
- プラトンデータインテリジェンス
- プラトデータ
- プレイ
- お願いします
- プラグイン
- ポイント
- 視点
- ポスト
- 掲示
- Postgresql
- 電力
- 強力な
- 好む
- 優先
- かなり
- 防ぐ
- 前
- 前に
- 主要な
- 優先順位付けされた
- プリズム
- 多分
- 問題
- プロダクト
- 生産
- プロジェクト
- 財産
- 提供します
- 提供
- は、大阪で
- 代理
- 公共
- 公表
- 目的
- 目的
- プッシュ
- 置きます
- パッティング
- 質問と回答
- 資質
- 質問
- 質問
- クイック
- すぐに
- 反応する
- 読む
- リーダー
- 読者
- リーディング
- 準備
- リアル
- 現実の世界
- リアルタイム
- 受け取ります
- 受け取り
- 推奨する
- リダイレクト
- 減らします
- リファレンス
- 関係なく
- 登録
- 登録された
- 登録
- 関連する
- 関係
- の関係
- 相対的に
- 信頼性
- 信頼性のある
- リミックスします
- レンダリング
- 置き換え
- 要求
- リクエスト
- の提出が必要です
- 必要
- リソース
- 応答
- REST
- 制限する
- return
- 収益
- 革命を起こした
- ルート
- 円形
- ルート
- ルータ
- ルート
- 行
- ラン
- ランニング
- 安全な
- 酒
- 同じ
- ド電源のデ
- 規模
- スケジュール
- スコープ
- 二番
- 見ること
- センス
- セッション
- セッション
- shared
- シフト
- ショート
- すべき
- 表示する
- 展示の
- 作品
- 符号
- シグナル
- 署名
- 簡単な拡張で
- 簡素化する
- 単純化
- 単に
- から
- ゆっくり
- 小さい
- So
- 解決する
- 一部
- 何か
- SPA
- スペース
- 特別
- 特定の
- スピード
- スピン
- split
- Spot
- 回転した
- スタック
- スタック
- 標準
- start
- 開始
- 起動
- 開始
- 都道府県
- 米国
- Status:
- ステップ
- まだ
- Force Stop
- 停止
- 店舗
- 戦略
- 流れ
- 提出
- 提出する
- 提出された
- 成功
- そのような
- 概要
- スーパー
- サポート
- サポート
- SVG
- 構文
- テーブル
- 追い風
- 取る
- 取り
- 取得
- チーム
- 技術的
- 伝える
- template
- ターミナル
- テスト
- 基礎
- 風景
- アプリ環境に合わせて
- テーマ
- もの
- 物事
- 考え
- 数千
- 介して
- 全体
- 投げる
- 時間
- タイミング
- 先端
- 役職
- 〜へ
- 今日
- 一緒に
- あまりに
- 豊富なツール群
- top
- トピック
- トータル
- touch
- トレース
- 牽引力
- 伝統的な
- 遷移
- true
- チュートリアル
- tv
- タイプスクリプト
- ui
- 下
- 予期しない
- 今後の
- アップデイト
- 更新
- URL
- us
- つかいます
- ユーザー
- users
- 通常
- ux
- 検証
- 検証済み
- 値
- さまざまな
- 、
- 詳しく見る
- ビュー
- wait
- 方法
- ウェブ
- ウェブ開発
- ウェブサイト
- この試験は
- 何ですか
- which
- while
- 誰
- 意志
- ワイヤー
- 以内
- 無し
- 不思議に思います
- 仕事
- 働いていました
- ワーキング
- 世界
- やりがいのある
- でしょう
- 書き込み
- X
- 年
- あなたの
- あなた自身
- ゼファーネット