פורסם במקור כאן
Remix הוא מסגרת חדשה יחסית של JS ב-full-stack, המגובה על ידי כמה מהענקים בקהילת JS כגון קנט סי דודס, ריאן טי פלורנס ו מייקל ג'קסון. עד שהגיע Next.js, חיבור כלים שונים לבניית ה-SPA שלך הייתה הדרך דה פקטו לבנות אפליקציות JS. Next.js חוללה מהפכה בזה במידה מסוימת והפכה ללא תחרות לזמן מה. עם זאת, הנוף משתנה במהירות בשנה האחרונה בערך עם תחרות בריאה של RedwoodJs, BlitzJs ועכשיו Remix. כל הכלים הללו מנסים לפתור חלק מהבעיות העתיקות בפיתוח אתרים בצורה יותר יצירתית, אמינה ו הכי חשוב, דרך ידידותית למפתחים כך שבניית אפליקציית אינטרנט בעלת ביצועים הופכת לברירת המחדל עבור מפתחי JS.
זה בהחלט מוקדם מאוד לזהות מנצח ברור בין כל הכלים האלה במרחב הזה אבל רמיקס בהחלט נראה כמו מתמודד ראוי. אז, אם עוד לא הרטבת את הרגליים במדהימה שהיא רמיקס, אני מקווה שהמדריך הזה יעזור לך להתחיל ולשכנע אותך לבחור בו עבור מה שאתה בונה הבא!
מבט ממעוף הציפור
בפוסט הזה, אני אדריך אותך בבניית אפליקציית AMA (שאל אותי כל דבר) באמצעות Remix. להלן רשימה של הכלים העיקריים שבהם נשתמש לבניית אפליקציה זו. זה בהחלט יהיה קל יותר לקורא לעקוב אחריו אם הוא מכיר את היסודות של חלק מהכלים (פרט לרמיקס, כמובן) אבל אל תדאג יותר מדי אם לא.
- רמיקס – מסגרת ראשית
- React – מסגרת ממשק משתמש
- Prisma – Database ORM
- PostgreSQL - מסד נתונים
- TailwindCSS – מסגרת CSS
זה הולך להיות פוסט ארוך אז אני ממליץ לעקוב אחריו בישיבות מרובות וכדי להקל עליך להחליט אם לקרוא את כולו היא השקעה משתלמת או לא, הנה מתווה של מה שנעשה/נלמד על הכל בסדר כרונולוגי:
- מפרט אפליקציה - תאר את התכונות של האפליקציה שאנו הולכים לבנות מרמה גבוהה יותר.
- התחל עם Remix - בעיקר לעקוב אחר המסמכים הרשמיים שלהם ולהתקין כמה דברים.
- סכימת מסד נתונים - הגדר את סכימת מסד הנתונים שיכולה לתמוך בכל התוכן הדינמי הדרוש לאפליקציה שלנו.
- CRUD - פעולות CRUD בסיסיות בדרך רמיקס סטנדרטית.
- UI/UX - פזרו מעט Tailwind כדי לגרום לדברים להיראות נחמדים ויפים.
כפי שאתה יכול לראות, יש לנו הרבה מה לכסות, אז, בוא נצלול פנימה. אה, לפני כן, אם אתה חסר סבלנות כמוני ורק רוצה לראות את הקוד, הנה כל האפליקציה ב-github: https://github.com/foysalit/remix-ama
מפרט האפליקציה
בכל פרויקט, אם אתה יודע בדיוק מה אתה הולך לבנות, זה הופך להיות הרבה יותר קל לנווט בנוף מההתחלה. אמנם לא תמיד יש לך את החופש הזה אבל למרבה המזל, במקרה שלנו, אנחנו יודעים את כל התכונות שאנחנו צריכים עבור האפליקציה שלנו. לפני שנפרט באופן שיטתי את כל המאפיינים מנקודת מבט טכנית, בואו נסתכל עליהם מנקודת מבט כללית של המוצר.
מושב AMA
משתמש באפליקציה שלנו אמור להיות מסוגל לארח מספר הפעלות AMA. עם זאת, אין זה הגיוני לארח מספר מפגשים באותו יום, אז בוא נגביל את משך ההפעלה ליום שלם ונאפשר רק הפעלה אחת למשתמש ליום.
שאלות ותשובות
משתמש באפליקציה שלנו אמור להיות מסוגל לשאול שאלה למארח במהלך הפעלת AMA. כדי לבנות בלעדיות, בואו נחסום משתמשים מלשאל שאלות לאחר סיום ההפעלה. כמובן שמנחה המפגש אמור להיות מסוגל לענות על השאלות שנשאלו במפגשים שלו.
תגובות
כדי לבנות יותר מעורבות ולהפוך דברים לקצת יותר מהנים מאשר שאלות ותשובות מסורתיות, בואו נוסיף תכונת שרשור הערות המאפשרת לכל משתמש להוסיף הערה לשאלה. זה יכול לשמש כדי להוסיף הקשר נוסף לשאלה שכבר נשאלה או לקיים דיון על התשובה שסופקה על ידי המארח וכו'.
עכשיו בואו נפרט כיצד ניישם אותם:
אימות - משתמשים צריכים להיות מסוגלים להירשם כדי לארח מפגש AMA, לשאול שאלה למארח או להגיב בשרשור. עם זאת, בואו לא נמנע ממשתמש לא מאומת לצפות בהפעלה שכבר פועלת. לצורך אימות, בוא נשתמש בכתובת דוא"ל וסיסמה. בנוסף, בעת ההרשמה, בואו נבקש מהמשתמש להזין את שמו המלא כדי שישמש בכל מקום באפליקציה. ישות משתמש תשמש לאחסון נתונים הקשורים לאימות.
מפגשים - הצג רשימה של כל ההפעלות הנוכחיות והקודמות בדף אינדקס לכל המשתמשים (מאומת/לא מאומת) שתאפשר להם ללחוץ לכל סשן ולראות שאלות/תשובות/הערות וכו'. משתמשים מאומתים יכולים להתחיל הפעלה חדשה אם כבר יש הוא לא אחד לאותו היום. הבה נבקש מהמארח לספק הקשר/פרטים לכל מפגש בעת תחילתו. כל הפעלה היא ישות ששייכת למשתמש.
שאלות - לכל מפגש בודד יכולות להיות שאלות מרובות מכל משתמש רשום למעט המארח. ישות השאלה תכיל גם את התשובה מהמארח במסד הנתונים וכל קלט תשובה יאומת כדי לוודא שהכותב הוא המארח של הפגישה. הישות שייכת להפעלה ולמשתמש. בואו נוודא שמשתמש יכול לשאול שאלה אחת בלבד בכל הפעלה, אז עד שהוא ישאל שאלה, בואו נראה קלט טקסט לכל משתמש. מתחת לכל שאלה שנענה, בואו נראה קלט טקסט למארח כדי להוסיף את התשובה שלו.
תגובות - לכל שאלה (שנענו או לא) יכולות להיות הערות מרובות. כדי להפחית את המורכבות, לא נוסיף שרשור בתגובות לעת עתה. כל משתמש יכול לפרסם תגובות מרובות מתחת לשאלה, אז בואו תמיד נראה את קלט טקסט ההערה לכל המשתמשים מתחת לכל שאלה. כדי לפשט את ממשק המשתמש, הבה נציג את רשימת השאלות (והתשובות) בדף הפגישה כברירת מחדל ונוסיף קישור לפתיחת שרשור ההערות בסרגל צדדי.
התחל עם רמיקס
לרמיקס יש הרבה איכויות נהדרות אבל תיעוד כנראה תופס את המקום הראשון. מסגרת בפיתוח כבד חייבת לכלול הרבה חלקים נעים שמתפתחים כל הזמן על ידי המתחזקים, כך שהתיעוד ייפול מאחור ככל שהתכונות מקבלות עדיפות. עם זאת, צוות Remix מקפיד לשמור על התיעוד מעודכן ומסונכרן עם הזרם המתמיד של שינויים מדהימים שנדחפים החוצה. אז, כדי להתחיל, כמובן, את מסמכים רשמיים תהיה נקודת הכניסה הראשונה שלנו.
אם אתה עצלן מכדי ללכת לאתר אחר ולקרוא עוד קיר של טקסט, אל תדאג. הנה כל מה שאתה צריך לעשות כדי להתקין Remix:
- ודא שיש לך את התקנת ה-env פיתוח של Node.js.
- פתח את חלון המסוף שלך והפעל את הפקודה הבאה
npx create-remix@latest
. - בוצע.
רמיקס לא רק נותן לך חבורה של כלים ומבקש ממך ללכת לבנות את הדבר שלך, הם מובילים דוגמה וזו הסיבה שיש להם את הרעיון של ערימות. ערימות הן בעצם תבניות/ערכות התחלה שנותנות לך את הבסיס לפרויקט שלם ישירות מהקופסה. עבור הפרויקט שלנו, נשתמש ב- ערימת בלוז מה שנותן לנו פרויקט Remix מוגדר במלואו עם Prisma, Tailwind ומודול שלם שמראה כיצד להשתמש בכלים האלה כדי לבנות תכונת CRUD. אני מתכוון בכנות, אני מרגיש שאני אפילו לא צריך לכתוב את הפוסט הזה מכיוון שהתבנית כבר עשתה את כל העבודה. נו טוב... אני עמוק מדי עכשיו אז אולי כדאי לסיים את זה.
כל מה שאתה צריך לעשות הוא להפעיל את הפקודה npx create-remix --template remix-run/blues-stack ama
בטרמינל שלך ורמיקס יוריד את כל הפרויקט בתיקייה חדשה בשם ama
לאחר שתענה על כמה שאלות.
עכשיו בואו נפתח את ama
תיקייה ולהכיר קצת את התוכן בפנים. יש חבורה של קבצי תצורה בשורש ולא ניכנס לרובם. אנחנו מתעניינים בעיקר ב פריזמה, ציבורי ו האפליקציה ספריות. ספריית פריזמה תכיל את סכימת מסד הנתונים וההגירה שלנו. הספרייה הציבורית תכיל כל נכס שהאפליקציה צריכה כמו אייקונים, תמונות וכו'. לבסוף, ספריית האפליקציה תכיל את כל הקוד שלנו, גם הלקוח וגם השרת. כן, קראתם נכון, גם לקוח וגם שרת. אם זה נותן לך פלאשבקים גדולים של בסיס קוד מדור קודם, אנא דע שאתה לא לבד.
לפני שנצלול לכתיבת הקוד של האפליקציה שלנו, בואו נבדוק הכל ב-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
המחסנית מגיעה עם מודול הערות ברירת מחדל שאתה יכול לשחק איתו לאחר ההרשמה עם הדוא"ל והסיסמה שלך.
סכימת מסד נתונים
בדרך כלל, אני אוהב להתחיל לחשוב על תכונה/ישות מתוך סכימת מסד הנתונים שלה ולעלות את דרכי עד ממשק המשתמש שבו הנתונים מתפרשים, מוצגים ועושים מניפולציות בדרכים שונות. לאחר שתסדר את הסכימה, יהיה הרבה יותר קל לעבור את היישום הזה במהירות.
כפי שנדון לעיל במפרט האפליקציה, אנו זקוקים ל-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 הישויות החדשות שאנחנו צריכים. כיצד פועלות ההגדרות והתחביר, עליך לעבור לקישור הזה 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 שורות. עמודה שתכיל את מזהה המפתח הזר והשנייה כדי לציין את השם המשמש לגישה לישות קשורה שנראית בדרך כלל כך:
<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
בטבלת המשתמשים, עלינו גם להתאים את ה-Seder כדי להוסיף שם דמה למשתמש. פתח את הקובץ 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
? זו פשוט התגובה שהוחזרה מהשרת מבקשת הגשה. כל פעולה של שליחת טופס תשלח את בקשת הפוסט מהדפדפן לשרת והרמיקס יטפל בה דרך 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
סוג, עלינו להתאים את ההגדרה ולהוסיף את מאפיין השם:
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 פריזמה אקספרסיבי ואינטואיטיבי כדי להכניס בקלות שורה חדשה ל-db. זה בדרך כלל לובש צורה של
prisma.<entityName>.<actionName>({})
איפהentityName
הוא שם הטבלה באותיות קטנות וactionName
היא פעולת db כגון create, update, findOne וכו'. בקרוב נראה שימוש נוסף בהם.
עם זה פשוט הוספנו קלט שם חדש שיאומת כאשר המשתמש יפגע 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
פוּנקצִיָה. זוהי פונקציה מיוחדת שתוכל לקבל רק 1 לכל מסלול/קובץ ובטעינת עמוד, Remix יקרא לפונקציה הזו כדי לאחזר את הנתונים שהדף שלך צריך. לאחר מכן, הוא ירחה את הרכיב שלך עם הנתונים וישלח את ה-HTML המעובד על החוט כתגובה שהיא אחת מהתנהגויות הקסם או הרמיקס. זה מבטיח שמשתמשים לא יצטרכו לראות מצב טעינה מכיוון שקוד ה-JS של הדפדפן שלך טוען נתונים מבקשות API. גוף פונקציית הפעולה קורא ל-a 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 }, }, }, });
};
זה פשוט מביא את כל הערכים מטבלת הפגישות ואת כל ערכי המשתמש הקשורים אליהם, מכיוון שאנו נשתמש במידע של המארח על ממשק המשתמש והוא כולל גם את המספר הכולל של השאלות שיש לכל סשן. זה לא ניתן להרחבה במיוחד מכיוון שככל שהאפליקציה שלנו גדלה, ייתכן שיהיו מאות אלפי הפעלות של AMA ואחזור של כולן לא הולך להתרחב היטב. עם זאת, לצורך פוסט זה, נדלג על עימוד לעת עתה.
בואו נקפוץ חזרה לתוך שלנו sessions/index.tsx
קובץ מסלול. אם אין הפעלות במסד הנתונים, אנו מחזירים תגובת שגיאה 404 באמצעות ה Response
עוזר מ-Remix. אחרת, אנו מחזירים תגובת JSON המכילה את מערך הפעלות המשתמש ב- json
עוזר מ-Remix.
השמיים const data = useLoaderData<LoaderData>();
מתקשר ל-Remix הוק מיוחד שנותן לנו גישה לנתונים בתגובה שנשלחה חזרה action
. אולי אתה תוהה, איך אנחנו מטפלים בתגובת השגיאה? זה בהחלט לא מטופל בגוף של SessionIndexPage
פוּנקצִיָה. רמיקס משתמש ב-long זמין 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() {
…
זה פשוט עיבוד של רכיב כותרת משותף וקישור לתחילת הפעלה חדשה. זה גם משתמש ב-shared 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
רכיב מרמיקס (שהוא בעצם רק פרוקסי ל- Link
רכיב מ-react-router) כדי לקשר לדף רשימת ההפעלות. דבר בולט נוסף כאן הוא שאנו משתמשים ב-a font-cursive
סגנון על טקסט הכותרת כדי לגרום לו להיראות קצת כמו לוגו. סגנון גופן סתמי אינו כלול בתצורת ברירת המחדל של tailwind ולכן נצטרך להגדיר אותו בעצמנו. פתח את tailwind.config.js
קובץ מהשורש של הפרויקט והתאם את theme
נכס כמו להלן:
module.exports = { content: ["./app/**/*.{ts,tsx,jsx,js}"], theme: { extend: { fontFamily: { cursive: ["Pinyon Script", "cursive"], }, }, }, plugins: [],
};
שימו לב שהסיבית הנוספת מרחיבה את ערכת הנושא כדי להוסיף גופן חדש עם השם cursive
והערך הוא Pinyon Script
בחרתי את זה מחוץ לגוגל גופנים אבל אל תהסס לבחור גופן משלך. אם אינך מכיר במיוחד את רוח הגב, זה רק נותן לנו את היכולת להחיל את משפחת הגופנים הזו על טקסט באמצעות font-cursive
כיתת עוזר אבל אנחנו עדיין צריכים לטעון את הגופן עצמו בדף האינטרנט שלנו. הוספת נכסים חיצוניים לרמיקס היא די פשוטה. פתח את ה 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", }, ];
};
כל הקישורים שלמעלה מאוחזרים מגוגל דף גופנים כאן.
עוקבים אחר צעדינו בחזרה אל 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> );
};
זהו רכיב כפתור פשוט שיעזור לנו לאחד את המראה והתחושה של כפתורים שהם כפתורי קישור או פעולה במקומות שונים של האפליקציה. על מנת להפוך את סוג הרכיב לבטוח בזמן קבלת אביזרים עבור כפתור וקישור, אנו מיישמים קצת קסם של כתב כתיבה על האביזרים והעיבוד.
לבסוף, אנו מסתכלים על קוד רכיב העמוד עצמו עצמו. העמוד ממפה את כל ערכי הפגישה ומציג את תאריך הפגישה, שם המארח של הפגישה, הנחת היסוד/פירוט שנוספה על ידי המארח עבור הפגישה וספירה כוללת של כמה שאלות יש. כדי להציג תאריכים, אנו משתמשים בדפדפן המובנה מודול Intl התומך בעיצוב מבוסס מקומי. אנו משתמשים בסמל svg קטן ליד ספירת השאלות. אתה יכול למצוא את כל הנכסים שבהם נעשה שימוש באפליקציה כאן https://github.com/foysalit/remix-ama/tree/main/public/icons אבל אל תהסס להשתמש בסמלים שלך כרצונך. יש להוסיף את כל הנכסים הציבוריים ל- /public
תיקייה וכדי לשמור את כל האייקונים ביחד, יצרנו ספריית אייקונים.
עם כל האמור לעיל, כעת אתה אמור להיות מסוגל ללכת אל 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)
חשבון. זוהי שיטת עוזר שמגיעה עם המחסנית ופשוט מנתבת מחדש משתמשים לא מורשים לעמוד הכניסה או מחזירה את המזהה של המשתמש המורשה. לאחר מכן אנו מאחזרים את קלט המשתמש עבור ההפעלה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 אז אני מעדיף להכניס ספרייה לפרויקט שלי לטיפול בתאריכים. במקרה הזה אני משתמש date-fns lib אבל אל תהסס להשתמש ב-lib המועדף עליך.
- טוען: אנו רוצים שרק משתמשים מורשים יראו את הדף הזה, כך שהמטען פשוט מפעיל את
requireUserId()
פונקציה שתתנתק משתמשים לא מאומתים ותמנע מהם לראות את הטופס ליצור הפעלה. - מעבר - רמיקס מגיע עם שימושי מאוד
useTransition()
hook שנותן לך גישה למצבים שונים של דף. בזמן שאתה שולח טופס מדף, שלח נתונים לשרת והמתן לתגובה,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 וראה את ההפעלה החדשה שנוצרה בעצמך בדף הרשימה. משהו כזה:
שאלות ותשובות
עם רשימת הפעלות ויצירת דפים שעובדים היטב, אנו יכולים כעת לבנות שאלות ותשובות לכל סשן. כל מפגש צריך להיות נגיש באמצעות sessions/:sessionId
כתובת אתר איפה :sessionId
הוא משתנה שיוחלף במזהי הפעלות. על מנת למפות את param המסלול הדינמי לקובץ מסלול ברמיקס, עלינו להתחיל את שם הקובץ עם $
סימן עם סיומת שם הפרמטר. אז, במקרה שלנו, בואו ניצור קובץ חדש 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}`);
}
עם זה, נרחף במהירות על כמה מהמושגים שכבר דנו בהם ונתמקד יותר בקטעים החדשים:
- Loader: מחזיר את ערך ההפעלה ואת מזהה המשתמש הנוכחי. זה מעורר קריאה ל
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: עוזר זה מוסיף את תשובת המארח לשאלה על ידי קבלת אובייקט כארגומנט המכיל את מזהה השאלה ואת קלט התשובה. בואו ליישם את זה ב
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: זה מוסיף כל שאלה של משתמש שאינו מארח להפעלה על ידי נטילת ארגומנט אובייקט המכיל את מזהה המשתמש וההפעלה ואת קלט השאלה. כך זה מיושם ב
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 hook: hook זה הוא עוד פרוקסי לנתב תגובה אשר פשוט נותן לנו גישה לכל פרמטר מסלול כגון sessionId במקרה שלנו.
- טופס שאלה: לכל המשתמשים המאומתים שאינם מארחים, אנו מציגים טופס קלט שאלות בכל מפגש מעל רשימת השאלות שפורסמו בעבר.
- רכיב שאלה תשובה: כדי לשמור על נתח גדול של קוד ניתן לשיתוף ומבודד, שמנו שאלה בודדת בקובץ רכיב משותף. נראה למה עוד מעט אבל בואו נראה תחילה את היישום של הרכיב הזה. צור קובץ חדש
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}}
מה שמביא אותנו לנושא הניתוב המקנן. בואו נסתכל על זה עכשיו.
ניתוב מקונן
באפליקציית React מסורתית, היית מפצל עמוד במספר רכיבים והרכיבים טוענים באופן פנימי את הנתונים שלהם או מוזנים ממאגר נתונים גלובלי שמעביר אליו את הנתונים. ברמיקס, תעשה זאת באמצעות ניתוב מקונן שבו דף יכול להטמיע עמוד אחר בתוכו שיש לו מחזור חיים משלו, כגון טוען נתונים, פעולה, מגביל שגיאות וכו'. זה חזק להפליא ומוסיף רמה חדשה לגמרי של אמינות ומהירות ב-UX . אנחנו הולכים להשתמש בזה כדי להציג שרשור הערות לכל שאלה בפגישה.
כדי להקל על זה, הוספנו א <Outlet context={data.session} />
רכיב בדף פרטי הפגישה. Outlet
הוא המיכל לתוכן עמוד מקונן והוא נותן לנו את היכולת לבנות את הפריסה עבור עמוד צאצא ברמת האב. כאשר המשתמש נכנס למסלול מקונן, זה יוחלף ב-html המעובד ברמה הנמוכה ביותר של מסלול העמוד המקונן.
כעת, כדי לגשת לשרשור התגובות, אנו מנתבים משתמשים אליו session/:sessionId/questions/:questionId
נתיב כך כדי להתאים את זה במערכת הקבצים, עלינו ליצור ספרייה חדשה בפנים routes/sessions/$sessionId/questions
וליצור קובץ בשם $questionId.tsx
בתוכו. שימו לב שעכשיו יש לנו קובץ עם השם $sessionId.tx
וספרייה בשם $sessionId
. זה עשוי להיות מבלבל אבל הוא כפי שתוכנן. זה אומר לרמיקס להשתמש ב sessionId.tsxfilleastheparentpageוראה מסלולים מסודרים מקובץ sessionId.tsx של ה-sessionId.tsx כדף האב ועבד את כל המסלולים המקוננים מתוך `זיהוי sessiondirectory. 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
רכיב כדי להציג את אותו רכיב ממשק משתמש שאנו מציגים מתחת לסשן, אך במקרה זה בראש שרשור ההערות, כדי לתת לקוראים הקשר להערות. אנחנו גם מציבים בתוכו טופס שדרכו כל משתמש מאומת יכול לכתוב תגובה. בואו נבדוק את 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 hook: זה נותן לנו גישה לכל האביזרים המועברים לדף הילד דרך
<Outlet … />
רכיב בדף האב. אז הנה, יש לנו גישה לכל הסשן עם כל השאלות שבתוכו ובמקום לשאול את השאלה הבודדת של השרשור, אנחנו פשוט בוחרים אותה מתוך הנתונים שכבר עברו. - טוען הערות: אנו טוענים את כל ההערות לשאלה ללא עימוד, וזה לא רעיון מצוין עבור אפליקציית ייצור כלשהי.
לעטוף
אם ביצעת את כל השלבים הקודמים, פתח את האפליקציה בחלון גלישה בסתר וצור חשבון חדש. לאחר מכן, אם תלחץ על ההפעלה שנוצרה קודם לכן, אתה אמור לראות שדה קלט כדי לשאול שאלה:
עכשיו אם תקליד שאלה ותפרסם אותה מהחשבון החדש הזה, אתה אמור לראות משהו כזה:
מה שמציג את התגובה שלך, פותח את התגובה בשרשור בצד ימין ומאפשר לך או לכל משתמש אחר להוסיף הערה לשרשור.
לבסוף, אם תחזור לחלון הדפדפן האחר שבו אתה מחובר כמארח ההפעלה ותרענן את דף ההפעלה, אתה אמור לראות את התגובה שם עם קלט ממש מתחת כדי לפרסם את התשובה שלך:
מה הלאה?
עשית עבודה מדהימה עד כאן אז בבקשה תמחיאות כפיים לעצמך! אם אתה כמוני ולעולם לא יכול לקבל מספיק מדברים חדשים ומבריקים של JS, אולי אתה תוהה: "זה נהדר אבל האם זה משהו שהייתי משתמש בו כמשתמש?" ואם אתה נאמן לעצמך אז התשובה תהיה שמנה גדולה NO
. אז אני אשאיר לכם כמה רעיונות שיכולים להפוך את אפליקציית הצעצועים הזו להרכיב במהירות לאפליקציה מוכנה לייצור שעשויה לקבל משיכה בעולם האמיתי:
- סנכרון נתונים בזמן אמת - מפגשי AMA עוסקים בתזמון. לפחות הטובים הם. לאנשים המארחים אותם אין זמן להסתובב וללחוץ על רענן כל 10 שניות כדי לחפש הערות/שאלות חדשות וכו'. אז כל אלה צריכים להיות מסונכרנים בזמן אמת ולהדגיש למארח. כנ"ל לגבי המשתתפים.
- עימוד - כפי שצוין לאורך הפוסט, חתכנו כמה פינות בטעינת הנתונים שבוודאי לא יתאמו באפליקציה בעולם האמיתי. הוספת עימוד לכל השאילתות תהיה גם חווית למידה טובה.
- טיימר הפעלה והפעלה עתידית: מכיוון שההפעלות באפליקציה זו מוקפות בזמן ליום, הצגת טיימר למתי ההפעלה מסתיימת עשויה להוסיף אלמנט של ריגוש לחוויה. תכונה קטלנית נוספת תהיה לאפשר למארחים לתזמן הפעלות לעתיד וליצור קצת הייפ סביבו על ידי הצגת הפגישה הקרובה בדף הבית בצורה מודגשת יותר
משאבים
- הפצת תוכן ויחסי ציבור מופעל על ידי SEO. קבל הגברה היום.
- Platoblockchain. Web3 Metaverse Intelligence. ידע מוגבר. גישה כאן.
- מקור: https://www.codementor.io/foysalit/build-a-fullstack-ama-app-with-remix-prisma-postgresql-1vsbmepsp3
- 1
- a
- יכולת
- יכול
- אודות
- מֵעַל
- גישה
- נגיש
- גישה
- פי
- לפיכך
- חֶשְׁבּוֹן
- מדויק
- פעולה
- פעולות
- למעשה
- הוסיף
- נוסף
- בנוסף
- כתובת
- מוסיף
- התאמה
- לאחר
- ותיק
- קדימה
- תעשיות
- מאפשר
- לבד
- בַּצַד
- כְּבָר
- תמיד
- AMA
- מפגשי AMA
- מדהים
- בין
- ו
- אחר
- לענות
- תשובות
- API
- האפליקציה
- החל
- אפליקציות
- טענה
- סביב
- מערך
- נכס
- נכסים
- מנסה
- תודה
- מאומת
- אימות
- מחבר
- המכונית
- זמין
- לחכות
- בחזרה
- מגובה
- קצה אחורי
- בסיס
- מבוסס
- בסיסי
- בעיקרון
- יסודות
- כי
- הופך להיות
- לפני
- מאחור
- להיות
- להלן
- בֵּין
- גָדוֹל
- קצת
- לחסום
- חסימה
- בלוג
- גוּף
- גבול
- תַחתִית
- גבולות
- אריזה מקורית
- לשבור
- מביא
- דפדפן
- לִבנוֹת
- בִּניָן
- נבנה
- צרור
- לַחְצָן
- שיחה
- קוראים
- שיחות
- אשר
- מפלה
- מקרה
- היאבקות
- נתפס
- מסוים
- בהחלט
- שינוי
- שינויים
- תווים
- לבדוק
- בדיקה
- בדיקות
- ילד
- ילדים
- בחרו
- בכיתה
- ברור
- לקוחות
- קוד
- בסיס קוד
- טור
- עמודות
- COM
- הערה
- הערות
- לבצע
- קהילה
- תחרות
- להשלים
- מורכבות
- רְכִיב
- רכיבים
- מושג
- מושגים
- מבלבל
- כתוצאה מכך
- קונסול
- קבוע
- תמיד
- מכולה
- מכיל
- תוכן
- הקשר
- לְשַׁכְנֵעַ
- פינות
- יכול
- זוג
- קורס
- לכסות
- לִיצוֹר
- נוצר
- יוצר
- יְצִירָתִי
- CSS
- נוֹכְחִי
- כיום
- חותך
- נתונים
- מסד נתונים
- תַאֲרִיך
- תאריכים
- datetime
- יְוֹם
- מוקדש
- עמוק
- בְּרִירַת מֶחדָל
- בהחלט
- שַׂמֵחַ
- עומק
- מעוצב
- מְפוֹרָט
- פרטים
- dev
- מפתח
- מפתחים
- צעצועי התפתחות
- DID
- להבחין
- ספריות
- נָדוֹן
- דיון
- לְהַצִיג
- תיעוד
- לא
- עושה
- לא
- מטה
- ירידה
- נשמט
- בְּמַהֲלָך
- דינמי
- כל אחד
- מוקדם
- קל יותר
- בקלות
- או
- אמייל
- מסתיים
- התעסקות
- מספיק
- לְהַבטִיחַ
- מבטיח
- שלם
- ישויות
- ישות
- כניסה
- שגיאה
- שגיאות
- למעשה
- וכו '
- Ether (ETH)
- אֲפִילוּ
- כולם
- הכל
- התפתח
- בדיוק
- דוגמה
- אלא
- קיימים
- ניסיון
- יצוא
- היצוא
- אֶקְסְפּרֶסִיבִי
- להאריך
- חיצוני
- עין
- לְהַקֵל
- הוגן
- ליפול
- מוכר
- להכיר
- משפחה
- אופנה
- מהר
- שומן
- מאפיין
- תכונות
- הפד
- רגליים
- מעטים
- שדה
- שדות
- שלח
- קבצים
- למלא
- ממולא
- בסופו של דבר
- ראשון
- מבט ראשון
- להתמקד
- לעקוב
- בעקבות
- הבא
- גופנים
- זר
- טופס
- מצא
- מסגרת
- חופשי
- תכוף
- טרי
- ידידותי
- החל מ-
- מלא
- לגמרי
- כֵּיף
- פונקציה
- פונקציות
- עתיד
- לְהַשִׂיג
- כללי
- לקבל
- Git
- GitHub
- לתת
- נותן
- נתינה
- גלוֹבָּלִי
- Go
- Goes
- הולך
- טוב
- גופני Google
- גדול
- עבודות קרקע
- גדל
- לטפל
- טיפול
- שימושי
- לִתְלוֹת
- שירים
- ראש
- בריא
- לעזור
- עוזר
- כאן
- גָבוֹהַ
- גבוה יותר
- מודגש
- היסטורי
- מכה
- להיטים
- עמוד הבית
- לקוות
- המארח
- אירוח
- בית
- איך
- איך
- אולם
- HTML
- HTTPS
- מאות
- התלהבות
- ICON
- רעיון
- רעיונות
- לזהות
- תמונות
- ליישם
- הפעלה
- יושם
- לייבא
- in
- לכלול
- כלול
- כולל
- כולל
- בצורה מדהימה
- מדד
- בנפרד
- מידע
- בתחילה
- קלט
- תובנה
- להתקין
- התקנה
- במקום
- מעוניין
- הציג
- אינטואיטיבי
- השקעה
- מעורר
- מְבוּדָד
- IT
- עצמו
- עבודה
- ג'סון
- לקפוץ
- שמור
- מפתח
- סוג
- לדעת
- תווית
- נוף
- גָדוֹל
- אחרון
- שנה שעברה
- מערך
- עוֹפֶרֶת
- למידה
- יציאה
- מוֹרֶשֶׁת
- אורך
- מאפשר לי
- רמה
- LG
- חוֹפֶשׁ
- סִפְרִיָה
- קו
- קווים
- קשר
- קישורים
- רשימה
- קְצָת
- לִטעוֹן
- מטעין
- טוען
- המון
- סֵמֶל
- ארוך
- נראה
- נראה כמו
- נראה
- מגרש
- הרמה הנמוכה ביותר
- קסם
- שומר
- גדול
- לעשות
- עשייה
- מניפולציות
- מניפולציה
- באופן ידני
- רב
- מַפָּה
- מפות
- להתאים
- אומר
- מוּזְכָּר
- הודעה
- שיטה
- שיטות
- יכול
- נודד
- הֲגִירָה
- מודל
- מודול
- יותר
- רוב
- המהלך
- נע
- מוזילה
- מספר
- שם
- שם
- נווט
- צורך
- צרכי
- חדש
- הבא
- Next.js
- צומת
- Node.js
- נוֹרמָלִי
- יַקִיר
- ראוי לציון
- מספר
- אובייקט
- אובייקטים
- התרחשה
- רשמי
- ONE
- לפתוח
- נפתח
- מבצע
- תפעול
- מול
- אפשרות
- להזמין
- אחר
- אַחֶרֶת
- מתווה
- בחוץ
- שֶׁלוֹ
- דִפּוּף
- פרדיגמה
- פרמטר
- חלק
- המשתתפים
- עבר
- מעברי
- סיסמה
- עבר
- אֲנָשִׁים
- פרספקטיבה
- לבחור
- לְחַבֵּר
- חתיכות
- מקומות
- הַצָבָה
- פלטפורמה
- אפלטון
- מודיעין אפלטון
- אפלטון נתונים
- לְשַׂחֵק
- אנא
- תוספים
- נקודה
- נקודת מבט
- הודעה
- פורסם
- פוסטגרסל
- כּוֹחַ
- חזק
- לְהַעֲדִיף
- מועדף
- יפה
- למנוע
- קודם
- קוֹדֶם
- יְסוֹדִי
- לפי סדר עדיפויות
- פריזמה
- כנראה
- בעיות
- המוצר
- הפקה
- פּרוֹיֶקט
- רכוש
- לספק
- ובלבד
- מספק
- פרוקסי
- ציבורי
- לאור
- מטרה
- למטרות
- דחף
- גם
- מכניס
- שאלות ותשובות
- תכונות
- שאלה
- שאלות
- מָהִיר
- מהירות
- להגיב
- חומר עיוני
- קורא
- הקוראים
- קריאה
- מוכן
- ממשי
- עולם אמיתי
- זמן אמת
- לקבל
- מקבל
- להמליץ
- הפניה
- להפחית
- אזכור
- ללא קשר
- הירשם
- רשום
- רישום
- קָשׁוּר
- קשר
- מערכות יחסים
- יחסית
- אמינות
- אָמִין
- Remix
- טיוח
- החליף
- לבקש
- בקשות
- נדרש
- דורש
- משאבים
- תגובה
- REST
- לְהַגבִּיל
- לַחֲזוֹר
- החזרות
- חוללה מהפכה
- שורש
- עגול
- מסלול
- נתב
- נתיבים
- שׁוּרָה
- הפעלה
- ריצה
- בטוח
- טוֹבָה
- אותו
- להרחבה
- סולם
- לוח זמנים
- היקף
- שְׁנִיָה
- ראות
- תחושה
- מושב
- הפעלות
- התקנה
- משותף
- הסטה
- קצר
- צריך
- לְהַצִיג
- לראווה
- הופעות
- סִימָן
- לאותת
- חתימה
- פָּשׁוּט
- לפשט
- מפשט
- בפשטות
- since
- יחיד
- לאט
- קטן
- So
- לפתור
- כמה
- משהו
- ספָּא
- מֶרחָב
- מיוחד
- ספציפי
- מְהִירוּת
- לְסוֹבֵב
- לפצל
- מסחרי
- הסתובב
- לערום
- ערימות
- תֶקֶן
- התחלה
- החל
- החל
- התחלות
- מדינה
- הברית
- מצב
- צעדים
- עוד
- עצור
- סְתִימָה
- חנות
- אִסטרָטֶגִיָה
- זרם
- סגנון
- כְּנִיעָה
- להגיש
- הוגש
- הצלחה
- כזה
- סיכום
- סוּפֶּר
- תמיכה
- תומך
- SVG
- תחביר
- מערכת
- שולחן
- רוח גבית
- לקחת
- לוקח
- נטילת
- נבחרת
- טכני
- אומר
- תבנית
- מסוף
- בדיקות
- השמיים
- היסודות
- הנוף
- שֶׁלָהֶם
- נושא
- דבר
- דברים
- חושב
- אלפים
- דרך
- בכל
- זורק
- זמן
- תזמון
- טיפ
- כותרת
- ל
- היום
- יַחַד
- גַם
- כלים
- חלק עליון
- נושא
- סה"כ
- לגעת
- להתחקות
- כוח משיכה
- מסורתי
- מַעֲבָר
- נָכוֹן
- הדרכה
- tv
- כתב כתיבה
- ui
- תחת
- לא צפוי
- בקרוב ב
- עדכון
- עדכון
- כתובת האתר
- us
- להשתמש
- משתמש
- משתמשים
- בְּדֶרֶך כְּלַל
- ux
- לְאַמֵת
- תוקף
- אימות
- ערך
- שונים
- באמצעות
- לצפיה
- נופים
- לחכות
- דרכים
- אינטרנט
- בניית אתרים
- אתר
- מה
- מה
- אשר
- בזמן
- מי
- יצטרך
- חוט
- בתוך
- לְלֹא
- תוהה
- תיק עבודות
- עבד
- עובד
- עוֹלָם
- כדאי
- היה
- כתיבה
- X
- שנה
- עצמך
- זפירנט