원래 게시 됨 여기에서 지금 확인해 보세요.
Remix는 다음과 같은 JS 커뮤니티의 일부 거물들이 지원하는 비교적 새로운 전체 스택 JS 프레임워크입니다. 켄트 C. 도즈, 라이언 T. 플로렌스 및 마이클 잭슨. Next.js가 등장하기 전까지 다양한 도구를 결합하여 SPA를 구축하는 것이 사실상 JS 앱을 구축하는 방법이었습니다. Next.js는 이를 어느 정도 혁신했고 한동안 타의 추종을 불허했습니다. 그러나 RedwoodJs, BlitzJs 및 현재 Remix의 건전한 경쟁으로 작년 정도에 풍경이 빠르게 변화하고 있습니다. 이러한 모든 도구는 웹 개발의 오래된 문제 중 일부를 보다 창의적이고 안정적이며 가장 중요한 것은, 개발자 친화적인 방법으로 성능이 뛰어난 웹 앱을 빌드하는 것이 JS 개발자의 기본값이 됩니다.
이 공간에서 이러한 모든 도구 중에서 확실한 승자를 식별하는 것은 확실히 매우 이르지만 Remix는 확실히 가치 있는 경쟁자처럼 보입니다. 따라서 Remix라는 놀라운 기능에 아직 발을 담그지 않았다면 이 튜토리얼이 시작하는 데 도움이 되고 다음 빌드에 사용할 Remix를 선택하도록 설득하는 데 도움이 되기를 바랍니다.
조감도
이 게시물에서는 Remix를 사용하여 AMA(무엇이든 물어보세요) 앱을 구축하는 방법을 안내해 드리겠습니다. 다음은 이 앱을 빌드하는 데 사용할 기본 도구 목록입니다. 독자가 일부 도구의 기본 사항(물론 Remix 제외)에 익숙하다면 따라가기가 더 쉬울 것이지만 그렇지 않더라도 너무 걱정하지 마십시오.
- 리믹스 – 기본 프레임워크
- React – UI 프레임워크
- Prisma – 데이터베이스 ORM
- PostgreSQL – 데이터베이스
- TailwindCSS – CSS 프레임워크
이것은 긴 게시물이 될 것이므로 여러 번 따라하고 전체 내용을 읽는 것이 가치 있는 투자인지 여부를 더 쉽게 결정할 수 있도록 하기 위해 여기에서 우리가 수행/배울 내용에 대한 개요가 있습니다. 전체, 시간 순서:
- 앱 사양 – 더 높은 수준에서 구축할 앱의 기능을 간략하게 설명합니다.
- Remix 시작하기 – 대부분 공식 문서를 따르고 몇 가지를 설치합니다.
- 데이터베이스 스키마 – 앱에 필요한 모든 동적 콘텐츠를 지원할 수 있는 데이터베이스 스키마를 설정합니다.
- CRUD – 표준 Remix 방식의 기본 CRUD 작업입니다.
- UI/UX – Tailwind를 약간 뿌려서 사물을 멋지고 아름답게 보이게 합니다.
알 수 있듯이 우리는 다룰 것이 많으니 바로 들어가 보겠습니다. 아, 그 전에 저처럼 참을성이 없고 코드를 보고 싶다면 여기 github에 있는 전체 앱이 있습니다. https://github.com/foysalit/remix-ama
앱 사양
어떤 프로젝트에서든 무엇을 만들지 정확히 알고 있다면 처음부터 풍경을 탐색하는 것이 훨씬 쉬워집니다. 항상 그런 자유가 있는 것은 아니지만 운 좋게도 우리의 경우 앱에 필요한 모든 기능을 알고 있습니다. 기술적 관점에서 모든 기능을 체계적으로 나열하기 전에 일반적인 제품 관점에서 살펴보겠습니다.
AMA 세션
우리 앱의 사용자는 여러 AMA 세션을 호스팅할 수 있어야 합니다. 그러나 같은 날에 여러 세션을 호스팅하는 것은 의미가 없으므로 세션 기간을 하루 종일으로 제한하고 사용자당 하루에 한 세션만 허용하겠습니다.
질문 게시판
우리 앱의 사용자는 AMA 세션을 실행하는 동안 호스트에게 질문할 수 있어야 합니다. 독점성을 구축하기 위해 세션이 끝난 후 사용자가 질문하지 못하도록 차단합니다. 물론 세션 주최자는 세션에서 묻는 질문에 답할 수 있어야 합니다.
코멘트
기존 Q&A보다 더 많은 참여를 유도하고 좀 더 재미있게 만들기 위해 모든 사용자가 질문에 댓글을 추가할 수 있는 댓글 스레드 기능을 추가해 보겠습니다. 이것은 이미 질문한 질문에 더 많은 컨텍스트를 추가하거나 호스트가 제공한 답변에 대해 토론하는 데 사용할 수 있습니다.
이제 구현 방법을 분석해 보겠습니다.
인증 – 사용자는 AMA 세션을 호스트하거나 호스트에게 질문하거나 스레드에 댓글을 달기 위해 등록할 수 있어야 합니다. 그러나 인증되지 않은 사용자가 이미 실행 중인 세션을 보는 것을 방지하지 맙시다. 인증을 위해 이메일 주소와 비밀번호를 사용합시다. 또한, 가입 시 사용자에게 앱의 모든 곳에서 사용할 전체 이름을 입력하도록 요청합시다. 사용자 엔터티는 인증 관련 데이터를 저장하는 데 사용됩니다.
세션 – 인덱스 페이지의 모든 현재 및 과거 세션 목록을 모든(인증된/비인증된) 사용자에게 표시하여 각 세션을 클릭하고 질문/답변/코멘트 등을 볼 수 있도록 합니다. 인증된 사용자는 이미 있는 경우 새 세션을 시작할 수 있습니다. 그날을 위한 것이 아니다. 호스트에게 세션을 시작할 때 각 세션에 대한 컨텍스트/세부 정보를 제공하도록 요청합시다. 각 세션은 사용자에게 속한 엔터티입니다.
문의 – 모든 개별 세션에는 호스트를 제외한 등록된 사용자의 여러 질문이 있을 수 있습니다. 질문 엔터티에는 데이터베이스에 있는 호스트의 답변도 포함되며 모든 답변 입력은 작성자가 세션의 호스트인지 확인하기 위해 유효성이 검사됩니다. 엔터티는 세션 및 사용자에 속합니다. 사용자가 세션당 하나의 질문만 할 수 있도록 하여 질문을 할 때까지 모든 사용자에게 텍스트 입력을 표시하도록 합시다. 답변된 모든 질문 아래에 호스트에게 텍스트 입력을 표시하여 답변을 추가해 보겠습니다.
코멘트 – 모든 질문(답변 여부)에는 여러 의견이 있을 수 있습니다. 복잡성을 줄이기 위해 지금은 주석에 스레딩을 추가하지 않습니다. 모든 사용자는 질문 아래에 여러 개의 댓글을 게시할 수 있으므로 항상 모든 질문 아래의 모든 사용자에게 댓글 텍스트 입력을 표시하도록 합시다. UI를 단순화하기 위해 기본적으로 세션 페이지에 질문(및 답변) 목록을 표시하고 사이드바에서 댓글 스레드를 여는 링크를 추가해 보겠습니다.
리믹스 시작하기
Remix에는 많은 훌륭한 품질이 있지만 문서화가 아마도 최고의 자리를 차지할 것입니다. 과중한 개발 중인 프레임워크에는 유지 관리자에 의해 지속적으로 발전되는 많은 움직이는 부분이 있어야 하므로 기능의 우선 순위에 따라 문서가 뒤쳐질 수밖에 없습니다. 그러나 Remix 팀은 문서를 최신 상태로 유지하고 계속해서 발표되는 놀라운 변경 사항과 동기화되도록 세심한 주의를 기울입니다. 따라서 시작하려면 당연히 공식 문서 우리의 첫 번째 진입점이 될 것입니다.
다른 웹사이트로 이동하여 다른 텍스트 벽을 읽기에 너무 게으르더라도 걱정하지 마십시오. 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에 모든 것을 확인하여 이미 리믹스 블루스 스택에 의해 수행된 변경 사항을 추적할 수 있도록 합시다.
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가지 엔터티가 필요합니다. 또한 등록된 각 사용자를 저장할 사용자 엔터티가 필요하지만 Remix의 블루스 스택에는 이미 포함되어 있습니다. 추가하려면 약간만 수정하면 됩니다. 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[] …
}
이제 여기에서 풀어야 할 것이 많지만 대부분은 이 게시물의 범위를 벗어납니다. 이 스키마 정의는 우리가 필요로 하는 3개의 새 엔터티에 대해 올바른 열이 있는 모든 테이블을 prisma가 작성하는 데 필요한 모든 것입니다. 이 링크로 이동해야 하는 정의 및 구문 작동 방식 https://www.prisma.io/docs/concepts/components/prisma-schema 그리고 조금 읽어보세요. 높은 수준의 요약은 다음과 같습니다.
- 엔터티/테이블 정의는
model <EntityName> {}
중괄호 안에 엔터티의 모든 열/속성 및 다른 엔터티와의 관계가 들어갑니다. 따라서 주석 테이블은 다음과 같습니다.model Comment {}
- 열 정의는 일반적으로 다음과 같습니다.
<columnName> <columnType> <default/relationship/other specifiers>
. 따라서 주석 엔터티가 사용자가 입력한 주석 내용을 저장하기 위해 열이 필요한 경우 다음과 같습니다.
model Comment { content String
}
- 두 테이블/엔티티 간의 관계는 일반적으로 외래 키 열을 통해 정의되므로 다른 열과 함께 정의됩니다. 정의에는 일반적으로 2줄이 필요합니다. 외래 키 ID를 포함하는 열과 일반적으로 다음과 같은 관련 엔터티에 액세스하는 데 사용되는 이름을 지정하는 다른 열:
<entity> <entityName> @relation(fields: [<foreignKeyColumnName>], references: [id], onDelete: Cascade, onUpdate: Cascade)
. 따라서 댓글 엔터티를 일대다 관계로 질문 엔터티와 연결하려면 다음과 같이 정의해야 합니다.
model Comment { content String question Question @relation(fields: [questionId], references: [id], onDelete: Cascade, onUpdate: Cascade) questionId String
}
위의 내용은 프리즘인 빙산의 일각도 다루지 않았으므로 공식 문서에서 읽어보면 진정한 힘을 볼 수 있습니다. 이 블로그 게시물을 위해 위의 내용은 위의 프리즈마 스키마가 필요한 이유에 대한 아이디어를 제공해야 합니다.
데이터베이스와 관련된 마지막 조정이 필요합니다. 전체 인증 시스템과 함께 블루스 스택에는 테스트 목적으로 더미 사용자로 데이터베이스를 채우는 초기 데이터 시더도 포함되어 있습니다. 새로운 칼럼을 도입한 이후로 name
사용자 테이블에서 사용자에게 더미 이름을 추가하기 위해 시더를 조정해야 합니다. 파일 열기 prisma/seed.js
사용자 삽입 코드를 아래와 같이 수정합니다.
const user = await prisma.user.create({ data: { Email, name: 'Rachel Remix', password: { create: { hash: hashedPassword, }, }, }, });
이로써 우리는 마침내 이러한 모든 변경 사항을 데이터베이스와 동기화할 준비가 되었습니다. 그러나 데이터베이스가 이미 이전에 생성된 스키마와 일부 시드 데이터로 회전되었고 그 이후로 db가 변경되었기 때문에 모든 변경 사항을 즉시 동기화할 수 없습니다. 대신 마이그레이션을 약간 조정해야 합니다. Prisma는 이러한 종류의 조정을 위한 명령을 제공합니다. 하지만 다행스럽게도 기존 데이터와 스키마는 프로덕션 단계에 있지 않으므로 이 시점에서 db를 핵으로 만들고 현재 스키마로 새로 시작하는 것이 더 쉽습니다. 더 쉬운 경로로 이동하여 다음 명령을 실행해 보겠습니다.
./node_modules/.bin/prisma migrate reset
./node_modules/.bin/prisma migrate dev
첫 번째 명령은 db를 재설정하고 두 번째 명령은 현재 스키마 정의를 사용하여 모든 테이블로 db를 재생성하고 시드 데이터로 채웁니다.
이제 실행 중인 앱 서버를 중지하고 앱을 다시 설정하고 다시 실행해 보겠습니다.
npm run setup
npm run dev
사용자 등록 업데이트
사용자 테이블에 새 이름 열을 추가했으므로 먼저 사용자가 가입할 때 이름을 입력하도록 합시다. 이것은 앱을 빌드하는 react의 일반적인 방법에 대부분 익숙하다면 큰 충격을 주지 않으면서 작업을 수행하는 리믹스 방법에 대한 좋은 진입점을 제공합니다.
사용자 가입을 위한 코드는 다음에서 찾을 수 있습니다. ./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
? 단순히 제출 요청에서 서버에서 반환된 응답입니다. 모든 양식 제출 작업은 브라우저에서 서버로 게시 요청을 보내고 remix는 이를 통해 처리합니다. 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
FBI 증오 범죄 보고서 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
. - 우리는 db에 새로운 행을 쉽게 삽입하기 위해 매우 표현적이고 직관적인 prisma 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> </> );
}
react에 익숙하다면 대부분 익숙할 것입니다. 그러나 조각으로 분해합시다. Remix는 기본 내보낸 구성 요소를 렌더링합니다. 구성 요소 정의 위에는 loader
기능. 이것은 경로/파일당 하나만 가질 수 있는 특수 기능이며 페이지 로드 시 Remix는 이 기능을 호출하여 페이지에 필요한 데이터를 검색합니다. 그런 다음 구성 요소를 데이터로 수화하고 마술 동작 또는 Remix 중 하나인 응답으로 와이어를 통해 렌더링된 HTML을 보냅니다. 이렇게 하면 브라우저 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
경로 파일. 데이터베이스에 세션이 없으면 다음을 사용하여 404 오류 응답을 반환합니다. Response
리믹스의 도우미. 그렇지 않으면 다음을 사용하여 세션 배열을 포함하는 JSON 응답을 반환합니다. json
Remix의 도우미.
XNUMXD덴탈의 const data = useLoaderData<LoaderData>();
에서 다시 전송된 응답의 데이터에 액세스할 수 있는 특별한 Remix 후크를 호출하고 있습니다. action
. 오류 응답을 어떻게 처리하고 있는지 궁금할 것입니다. 그것은 확실히 몸에서 처리되지 않습니다. SessionIndexPage
기능. 리믹스는 사용 가능한 긴 시간을 사용합니다. 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
react-router의 구성 요소)를 사용하여 세션 목록 페이지에 연결합니다. 여기서 또 다른 주목할만한 점은 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
나는 구글 글꼴에서 이것을 선택했지만 자신의 글꼴을 자유롭게 선택하십시오. tailwind에 익숙하지 않은 경우 이 글꼴 모음을 사용하여 텍스트에 적용할 수 있는 기능만 제공합니다. font-cursive
도우미 클래스이지만 여전히 웹 페이지에서 글꼴 자체를 로드해야 합니다. 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를 수락하는 동안 구성 요소 유형을 안전하게 만들기 위해 props 및 렌더링에 일부 typescript 마술을 적용합니다.
마지막으로 실제 페이지 구성 요소 코드 자체를 살펴봅니다. 페이지는 모든 세션 항목을 매핑하고 세션 날짜, 세션 호스트 이름, 세션에 대해 호스트가 추가한 전제/세부 사항 및 총 질문 수를 표시합니다. 날짜를 렌더링하기 위해 브라우저에 내장된 로케일 기반 서식을 지원하는 Intl 모듈. 질문 수 옆에 작은 svg 아이콘을 사용하고 있습니다. 여기에서 앱에 사용된 모든 자산을 찾을 수 있습니다. https://github.com/foysalit/remix-ama/tree/main/public/icons 그러나 원하는 대로 자신의 아이콘을 자유롭게 사용할 수 있습니다. 모든 공공 자산은 /public
폴더를 만들고 모든 아이콘을 함께 유지하기 위해 icons 디렉토리를 만들었습니다.
위의 모든 항목을 사용하여 이제 다음으로 이동할 수 있습니다. http://localhost:3000/sessions 아직 세션을 생성하지 않았기 때문에 url로 이동하고 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()
인증되지 않은 사용자를 로그아웃하고 세션 생성 양식을 볼 수 없도록 하는 기능입니다. - 전환 – 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 찾을 수 없음 페이지로 이동하지만 버튼이 작동하지 않았다는 의미는 아닙니다. 수동으로 다음으로 돌아갈 수 있습니다. 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가지 작업을 수행할 수 있습니다. 세션의 호스트는 모든 질문에 답변할 수 있지만 질문할 수는 없습니다. 다른 모든 사용자는 반대 작업만 할 수 있습니다. 따라서 작업은 두 작업을 모두 처리해야 하며 둘을 구별하는 방법은
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 구성 요소: 많은 양의 코드를 공유 가능하고 격리되도록 유지하기 위해 공유 구성 요소 파일에 단일 질문을 넣습니다. 잠시 후에 그 이유를 알게 되지만 먼저 이 구성 요소의 구현을 살펴보겠습니다. 새 파일 만들기
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}
이 양식 제출을 호스트가 입력한 응답으로 처리해야 함을 백엔드(작업)에 신호를 보내는 데 도움이 되는 props.
또한 모든 질문이 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 후크: 이를 통해 자식 페이지에 전달된 모든 props에 액세스할 수 있습니다.
<Outlet … />
상위 페이지의 구성 요소. 따라서 여기에서 모든 질문이 포함된 전체 세션에 액세스할 수 있으며 스레드의 단일 질문을 쿼리하는 대신 이미 전달된 데이터에서 간단히 선택합니다. - 댓글 로드: 페이지 매김 없이 질문에 대한 모든 댓글을 로드하고 있습니다. 이는 모든 프로덕션 앱에 좋은 아이디어가 아닙니다.
시공 종합
이전 단계를 모두 수행했다면 시크릿 창에서 앱을 열고 새 계정을 만드세요. 그런 다음 이전에 생성된 세션을 클릭하면 질문을 할 수 있는 입력 필드가 표시됩니다.
이제 질문을 입력하고 해당 새 계정에서 게시하면 다음과 같이 표시됩니다.
귀하의 댓글을 표시하고, 댓글을 오른쪽에 스레드로 열고, 귀하 또는 다른 사용자가 스레드에 댓글을 추가할 수 있도록 합니다.
마지막으로 세션 호스트로 로그인한 다른 브라우저 창으로 돌아가 세션 페이지를 새로 고치면 답변을 게시하기 위해 바로 아래에 입력이 있는 댓글이 표시되어야 합니다.
무엇 향후 계획?
여기까지 잘 따라오셨습니다. 스스로에게 박수를 보내주세요! 당신이 나와 같아서 반짝이는 새로운 JS를 충분히 얻을 수 없다면 "이것은 훌륭하지만 이것이 내가 사용자로 사용할 것입니까?"라고 궁금해 할 것입니다. 그리고 당신이 자신에게 진실하다면 그 대답은 큰 뚱뚱한 것이 될 것입니다. NO
. 따라서 장난감 앱을 실제 세계에서 관심을 끌 수 있는 프로덕션 준비 앱으로 신속하게 통합할 수 있는 몇 가지 아이디어를 남겨 드리겠습니다.
- 실시간 데이터 동기화 – AMA 세션은 타이밍에 관한 것입니다. 적어도 좋은 것은 있습니다. 호스팅하는 사람들은 10초마다 새로고침을 눌러 새로운 댓글/질문 등을 찾을 시간이 없습니다. 따라서 이 모든 내용은 실시간으로 동기화되고 호스트에게 강조 표시되어야 합니다. 참가자들도 마찬가지입니다.
- 페이지 매김 – 게시물 전체에서 언급했듯이 실제 앱에서는 확실히 확장되지 않는 데이터 로드의 일부 모서리를 잘라냈습니다. 모든 쿼리에 페이지 매김을 추가하는 것도 좋은 학습 경험이 될 것입니다.
- 세션 타이머 및 향후 세션: 이 앱의 세션은 하루에 시간이 정해져 있으므로 세션이 종료될 때 타이머를 표시하면 경험에 스릴을 더할 수 있습니다. 또 다른 킬러 기능은 호스트가 미래에 대한 세션을 예약하고 홈 페이지에서 예정된 세션을 더 강조 표시하여 주변에 과대 광고를 만들 수 있게 하는 것입니다.
제품 자료
- SEO 기반 콘텐츠 및 PR 배포. 오늘 증폭하십시오.
- 플라토 블록체인. Web3 메타버스 인텔리전스. 지식 증폭. 여기에서 액세스하십시오.
- 출처: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- 능력
- 할 수 있는
- 소개
- 위의
- ACCESS
- 얻기 쉬운
- 액세스
- 에 따르면
- 따라서
- 계정
- 정확한
- 동작
- 행위
- 실제로
- 추가
- 추가
- 또한
- 주소
- 추가
- 조정
- 후
- 오래된
- 앞으로
- All
- 허용
- 혼자
- 함께
- 이미
- 항상
- AMA
- AMA 세션
- 놀라운
- 중
- 및
- 다른
- 답변
- 답변
- API를
- 앱
- 신청
- 앱
- 논의
- 약
- 배열
- 유산
- 자산
- 시도 중
- 인증
- 인증 된
- 인증
- 저자
- 자동
- 가능
- 기다리다
- 뒤로
- 뒷받침 된
- 백엔드
- 기지
- 기반으로
- 기본
- 원래
- 기초
- 때문에
- 된다
- 전에
- 뒤에
- 존재
- 이하
- 사이에
- 큰
- 비트
- 블록
- 블로킹
- 블로그
- 몸
- 경계
- 바닥
- 경계
- 보물상자
- 흩어져
- 돋보이게
- 브라우저
- 빌드
- 건물
- 내장
- 다발
- 단추
- 전화
- 부름
- 통화
- 한
- 폭포
- 케이스
- 잡아라
- 체포
- 어떤
- 확실히
- 이전 단계로 돌아가기
- 변경
- 문자
- 검사
- 확인
- 확인하는 것이 좋다.
- 아이
- 어린이
- 왼쪽 메뉴에서
- 수업
- 선명한
- 클라이언트
- 암호
- 코드베이스
- 단
- 열
- COM
- 본문
- 댓글
- 범하다
- 커뮤니티
- 경쟁
- 완전한
- 복잡성
- 구성 요소
- 구성 요소들
- 개념
- 개념
- 혼란
- 따라서
- 콘솔에서
- 상수
- 끊임없이
- 컨테이너
- 이 포함되어 있습니다
- 함유량
- 문맥
- 납득시키다
- 모서리
- 수
- 두
- 코스
- 엄호
- 만들
- 만든
- 생성
- 창조적 인
- CSS
- Current
- 현재
- 절단
- 데이터
- 데이터베이스
- 날짜
- 날짜
- 날짜 시간
- 일
- 전용
- 깊은
- 태만
- 명확히
- 기쁜
- 깊이
- 설계
- 상세한
- 세부설명
- 데브
- 개발자
- 개발자
- 개발
- DID
- 구별 짓다
- 디렉토리
- 논의 된
- 토론
- 디스플레이
- 선적 서류 비치
- 하지 않습니다
- 하기
- 말라
- 아래 (down)
- 드롭
- 적하
- ...동안
- 동적
- 마다
- 초기의
- 쉽게
- 용이하게
- 중
- 이메일
- 종료
- 약혼
- 충분히
- 확인
- 보장
- 전체의
- 엔티티
- 실재
- 항목
- 오류
- 오류
- 본질적으로
- 등
- 에테르 (ETH)
- 조차
- 사람
- 모두
- 진화
- 정확하게
- 예
- 외
- 현존하는
- 경험
- 수출
- 수출
- 나타내는
- 확장
- 외부
- 눈
- 용이하게하다
- 공정한
- 떨어지다
- 익숙한
- 친하게 하다
- 가족
- 패션
- FAST
- 지방
- 특색
- 특징
- 연방 준비 은행
- 발
- 를
- 들
- Fields
- 입양 부모로서의 귀하의 적합성을 결정하기 위해 미국 이민국에
- 파일
- 채우기
- 가득
- 최종적으로
- Find
- 먼저,
- 먼저보세요
- 초점
- 따라
- 다음에
- 수행원
- 글꼴
- 외국의
- 형태
- 발견
- 뼈대
- 무료
- 빈번한
- 신선한
- 친절한
- 에
- 가득 찬
- 충분히
- 장난
- 기능
- 기능
- 미래
- 이득
- 일반
- 얻을
- 힘내
- GitHub의
- 주기
- 제공
- 기부
- 글로벌
- Go
- 간다
- 가는
- 좋은
- 구글
- 구글 글꼴
- 큰
- 기초
- 성장
- 핸들
- 처리
- 능숙한
- 다루는 법
- 해시
- 머리
- 건강은 물론, 경제성까지!
- 도움
- 도움이
- 여기에서 지금 확인해 보세요.
- 높은
- 더 높은
- 강조
- 역사적인
- 히트
- 조회수
- 홈
- 기대
- 주인
- 호스팅
- 집
- 방법
- How To
- 그러나
- HTML
- HTTPS
- 수백
- 과대 광고
- ICON
- 생각
- 아이디어
- 확인
- 형상
- 구현
- 이행
- 구현
- import
- in
- 포함
- 포함
- 포함
- 포함
- 엄청나게
- 색인
- 개인
- 정보
- 처음에는
- 입력
- 통찰력
- 설치
- 설치
- 를 받아야 하는 미국 여행자
- 관심있는
- 소개
- 직관적인
- 투자
- 호출
- 외딴
- IT
- 그 자체
- 일
- JSON
- 도약
- 유지
- 키
- 종류
- 알아
- 라벨
- 경치
- 넓은
- 성
- 작년
- 레이아웃
- 리드
- 배우기
- 휴가
- 유산
- 길이
- 수
- 레벨
- LG
- 자유
- 도서관
- 라인
- 라인
- LINK
- 모래밭
- 명부
- 작은
- 하중
- 짐을 싣는 사람
- 로드
- 잔뜩
- 심벌 마크
- 긴
- 보기
- 같이
- 봐라.
- 롯
- 가장 낮은 단계
- 마법
- 유지
- 주요한
- 확인
- 유튜브 영상을 만드는 것은
- 조작
- 조작하는
- 수동으로
- .
- 지도
- 지도
- 경기
- 방법
- 말하는
- 메시지
- 방법
- 방법
- 수도
- 이전
- 이주
- 모델
- 모듈
- 배우기
- 가장
- 움직임
- 움직이는
- 모질라
- 여러
- name
- 이름
- 이동
- 필요
- 요구
- 신제품
- 다음 것
- 다음 .js
- 노드
- Node.js를
- 표준
- 주목할 만한
- 주목할만한
- 번호
- 대상
- 사물
- 발생
- 공무원
- ONE
- 열 수
- 열립니다
- 조작
- 행정부
- 반대
- 선택권
- 주문
- 기타
- 그렇지 않으면
- 개요
- 외부
- 자신의
- 쪽수 매기기
- 패러다임
- 매개 변수
- 부품
- 참가자
- 합격
- 패스
- 비밀번호
- 과거
- 사람들
- 관점
- 선택
- 조각
- 개
- 장소
- 자본 매출
- 플랫폼
- 플라톤
- 플라톤 데이터 인텔리전스
- 플라토데이터
- 연극
- 부디
- 플러그인
- 포인트 적립
- 관점
- 게시하다
- 게시
- Postgresql
- 힘
- 강한
- 취하다
- 선호하는
- 예쁜
- 예방
- 너무 이른
- 이전에
- 일차
- 우선 순위가 매겨진
- 프리즘
- 아마
- 문제
- 프로덕트
- 생산
- 프로젝트
- 재산
- 제공
- 제공
- 제공
- 대리
- 공개
- 출판
- 목적
- 목적
- 밀
- 놓다
- 입고
- 질문 게시판
- 자질
- 문제
- 문의
- 빠른
- 빨리
- 반응
- 읽기
- 리더
- 독자들
- 읽기
- 준비
- 현실
- 현실 세계
- 실시간
- 받다
- 수신
- 권하다
- 리디렉션
- 감소
- 참조
- 관계없이
- 회원가입
- 등록된
- 등록
- 관련
- 관계
- 관계
- 상대적으로
- 신뢰성
- 신뢰할 수있는
- 리믹스
- 렌더링
- 대체
- 의뢰
- 요청
- 필수
- 필요
- 제품 자료
- 응답
- REST
- 얽매다
- return
- 반품
- 혁명적 인
- 뿌리
- 반올림
- 길
- 라우터
- 노선
- 열
- 달리기
- 달리는
- 가장 안전한 따뜻함
- 술
- 같은
- 확장성
- 규모
- 예정
- 범위
- 둘째
- 보고
- 감각
- 세션
- 세션
- 설치
- 공유
- 이동
- 짧은
- 영상을
- 표시
- 선보이는
- 쇼
- 기호
- 신호
- 로그인
- 단순, 간단, 편리
- 단순화
- 단순화
- 간단히
- 이후
- 단일
- 천천히
- 작은
- So
- 풀다
- 일부
- 무언가
- SPA
- 스페이스 버튼
- 특별한
- 구체적인
- 속도
- 회전
- 분열
- Spot
- 잡아 늘인
- 스택
- 스택
- 표준
- 스타트
- 시작
- 시작 중
- 시작
- 주 정부
- 미국
- Status
- 단계
- 아직도
- 중지
- 멎는
- 저장
- 전략
- 흐름
- 스타일
- 제출
- 제출
- 제출
- 성공
- 이러한
- 개요
- 감독자
- SUPPORT
- 지원
- SVG
- 구문
- 체계
- 테이블
- 꼬리 날개
- 받아
- 소요
- 복용
- 팀
- 테크니컬
- 말하다
- 이 템플릿
- 단말기
- 지원
- XNUMXD덴탈의
- 기초
- 풍경
- 그들의
- 테마
- 맡은 일
- 일
- 사고력
- 수천
- 을 통하여
- 도처에
- 던지기
- 시간
- 타이밍
- 팁
- Title
- 에
- 오늘
- 함께
- 너무
- 검색을
- 상단
- 화제
- 금액
- 터치
- 더듬다
- 견인
- 전통적인
- 전이
- 참된
- 지도 시간
- tv
- 타이프 스크립트
- ui
- 아래에
- 예기치 않은
- 곧 출시
- 업데이트
- 업데이트
- URL
- us
- 사용
- 사용자
- 사용자
- 보통
- ux
- 유효 기간
- 검증 된
- 확인
- 가치
- 여러
- 를 통해
- 관측
- 보기
- 기다리다
- 방법
- 웹
- 웹 개발
- 웹 사이트
- 뭐
- 어느
- 동안
- 누구
- 의지
- 철사
- 이내
- 없이
- 궁금해하는
- 작업
- 일
- 일하는
- 세계
- 할 보람 있는
- 겠지
- 쓰기
- X
- year
- 너의
- 당신 자신
- 제퍼 넷