-
Notifications
You must be signed in to change notification settings - Fork 37
[김찬기] Sprint 10 #297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "Next-\uAE40\uCC2C\uAE30-sprint10"
[김찬기] Sprint 10 #297
Changes from 27 commits
c2a39fd
76d0ec2
a22e42c
55172c7
75e9bfc
a9f61a0
d4dc45c
b754f36
ee13623
5dcc2f1
1a73a67
22032fb
f3607ed
d7f0cd3
f82f654
099a884
fea5f65
ce97f3e
89fa285
3def569
1c14751
871c1cd
42b5ffe
6d2b65d
4721252
c1aa081
e346262
b3d1b3c
9adef1f
81f8663
d55cbf5
9438b13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| "use client"; | ||
|
|
||
| import { Section } from "@components/Section"; | ||
| import { | ||
| FieldItem, | ||
| Form, | ||
| ImageUpload, | ||
| Input, | ||
| Textarea, | ||
| } from "@components/Field"; | ||
| import { Button } from "@components/ui"; | ||
| import useFormWithError from "@hooks/useFormWithError"; | ||
| import { zodResolver } from "@hookform/resolvers/zod"; | ||
| import { Article } from "@/types/article"; | ||
| import { FieldAdapter } from "@components/adaptor/rhf"; | ||
| import { useRouter } from "next/navigation"; | ||
| import useArticleActions from "./useArticleActions"; | ||
| import { ArticleFormSchema, ArticleFormType } from "@/schemas/article"; | ||
|
|
||
| interface ArticleFormProps { | ||
| initialData?: Article; | ||
| mode?: "add" | "edit"; | ||
| articleId?: number; | ||
| } | ||
|
|
||
| export default function ArticleForm({ | ||
| initialData, | ||
| mode = "add", | ||
| articleId, | ||
| }: ArticleFormProps) { | ||
| const router = useRouter(); | ||
| const { handleArticleAdd, handleArticleModify } = | ||
| useArticleActions(articleId); | ||
| const onFormSubmit = mode === "add" ? handleArticleAdd : handleArticleModify; | ||
|
Comment on lines
+32
to
+34
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이건 좀 취향을 탈수도 있는데... 저는 컴포넌트에서 API 통신하는걸 그다지 선호하진 않거든요... 왜냐하면 이 ArticleForm은 단순 Form인데, 결과적으로 이게 submit 되었을떄 add할지 edit할지는 이 컴포넌트를 가져다가 사용하는 사람이 결정하는게 좋다고 생각해서요ㅎㅎ;; 그래서 차라리 ArticleFormProps 에 onSubmit 메소드를 받는게 어떨까 싶어요!
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. form컴포넌트를 불러서 사용하는쪽에서 원하는 submit기능을 주입하도록 변경하는거네요. 감사합니다. ^^ |
||
|
|
||
| const { | ||
| control, | ||
| formError, | ||
| handleSubmit, | ||
| formState: { isSubmitting, isValid }, | ||
| } = useFormWithError<ArticleFormType>({ | ||
| mode: "onBlur", | ||
| resolver: zodResolver(ArticleFormSchema), | ||
| defaultValues: initialData || { | ||
| title: "", | ||
| content: "", | ||
| }, | ||
| }); | ||
|
|
||
| async function onSubmit(data: ArticleFormType) { | ||
| try { | ||
| const result = await onFormSubmit(data); | ||
| const id = result?.id; | ||
|
|
||
| alert( | ||
| mode === "add" ? "성공적으로 작성했습니다." : "성공적으로 수정했습니다." | ||
| ); | ||
| router.replace(id ? `/boards/${id}` : "boards"); | ||
| } catch (err) { | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <Section> | ||
| <Form | ||
| isLoading={isSubmitting} | ||
| error={formError} | ||
| onSubmit={handleSubmit(onSubmit)} | ||
| > | ||
| <Section.Header title="게시글 쓰기"> | ||
| <Button type="submit" size="sm" disabled={!isValid}> | ||
| {mode === "add" ? "등록" : "수정"} | ||
| </Button> | ||
| </Section.Header> | ||
| <Section.Content> | ||
| <FieldItem> | ||
| <FieldItem.Label htmlFor="title">제목</FieldItem.Label> | ||
| <FieldAdapter | ||
| name="title" | ||
| control={control} | ||
| render={(props) => ( | ||
| <Input | ||
| type="text" | ||
| placeholder="제목을 입력해주세요" | ||
| {...props} | ||
| /> | ||
| )} | ||
| /> | ||
| </FieldItem> | ||
| <FieldItem> | ||
| <FieldItem.Label htmlFor="content">내용</FieldItem.Label> | ||
| <FieldAdapter | ||
| name="content" | ||
| control={control} | ||
| render={(props) => ( | ||
| <Textarea placeholder="내용을 입력해주세요" {...props} /> | ||
| )} | ||
| /> | ||
| </FieldItem> | ||
| <FieldItem> | ||
| <FieldItem.Label htmlFor="image">이미지</FieldItem.Label> | ||
| <FieldAdapter | ||
| name="image" | ||
| control={control} | ||
| render={(props) => <ImageUpload {...props} />} | ||
| /> | ||
| </FieldItem> | ||
| </Section.Content> | ||
| </Form> | ||
| </Section> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| .detail { | ||
| margin-bottom: 3.2rem; | ||
| } | ||
|
|
||
| .header { | ||
| margin-bottom: 1.6rem; | ||
| display: flex; | ||
| justify-content: space-between; | ||
|
|
||
| .title { | ||
| font-size: 2rem; | ||
| font-weight: 700; | ||
| word-break: break-all; | ||
| } | ||
| } | ||
|
|
||
| .meta { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| border-bottom: 1px solid var(--color-secondary-200); | ||
| padding-bottom: 1.6rem; | ||
| margin-bottom: 2.4rem; | ||
|
|
||
| .controls { | ||
| margin-left: 3.2rem; | ||
| padding-left: 3.2rem; | ||
| border-left: 1px solid var(--color-secondary-200); | ||
| } | ||
| } | ||
|
|
||
| .image { | ||
| max-width: 24rem; | ||
| margin-bottom: 2.4rem; | ||
| } | ||
|
|
||
| .content { | ||
| font-size: 1.8rem; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| "use client"; | ||
|
|
||
| import { Article } from "@/types/article"; | ||
| import { useSession } from "next-auth/react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import useArticleActions from "./useArticleActions"; | ||
| import { More } from "@/components/Button"; | ||
| import { Author, Fullscreen, LikeButton, Thumbnail } from "@/components/ui"; | ||
| import styles from "./BoardDetail.module.scss"; | ||
|
|
||
| interface BoardDetailProps { | ||
| detail: Article; | ||
| } | ||
|
|
||
| export default function BoardDetail({ detail }: BoardDetailProps) { | ||
| const { | ||
| id, | ||
| image, | ||
| title, | ||
| content, | ||
| writer: { nickname, id: ownerId }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| updatedAt, | ||
| likeCount, | ||
| isLiked, | ||
| } = detail; | ||
|
|
||
| const { data: session } = useSession(); | ||
| const router = useRouter(); | ||
| const { handleLike, handleArticleDelete } = useArticleActions(id); | ||
| const isOwner = ownerId === Number(session?.user?.id); | ||
|
|
||
| async function handleToggleLike() { | ||
| if (!session?.user) { | ||
| return alert("로그인이 필요합니다."); | ||
| } | ||
| await handleLike(!isLiked); | ||
| router.refresh(); | ||
| } | ||
|
|
||
| function handleModify() { | ||
| if (!isOwner) { | ||
| return alert("작성자만 수정이 가능합니다."); | ||
| } | ||
|
|
||
| router.push(`/modifyBoard/${id}`); | ||
| } | ||
|
|
||
| async function handleDelete() { | ||
| if (!isOwner) { | ||
| return alert("작성자만 삭제가 가능합니다."); | ||
| } | ||
|
|
||
| if (confirm("정말 삭제할까요?")) { | ||
| try { | ||
| await handleArticleDelete(); | ||
| alert("상품을 삭제했습니다."); | ||
| router.replace("/boards"); | ||
| } catch (err) { | ||
| console.log(err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div className={styles.detail}> | ||
| <header className={styles.header}> | ||
| <h2 className={styles.title}>{title}</h2> | ||
| <div className={styles.controls}> | ||
| <More | ||
| options={[ | ||
| { label: "수정하기", action: handleModify }, | ||
| { label: "삭제하기", action: handleDelete }, | ||
| ]} | ||
| /> | ||
| </div> | ||
| </header> | ||
| <div className={styles.meta}> | ||
| <Author nickname={nickname} updatedAt={updatedAt} /> | ||
| <div className={styles.controls}> | ||
| <LikeButton | ||
| count={likeCount} | ||
| isLiked={isLiked} | ||
| onClick={handleToggleLike} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <div> | ||
| {image && ( | ||
| <div className={styles.image}> | ||
| <Fullscreen> | ||
| <Thumbnail src={image} alt={title} /> | ||
| </Fullscreen> | ||
| </div> | ||
| )} | ||
| <div className={styles.content}>{content}</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { ArticleFormType } from "@/schemas/article"; | ||
| import { | ||
| addArticle, | ||
| deleteArticle, | ||
| modifyArticle, | ||
| toggleLike, | ||
| uploadArticleImage, | ||
| } from "@service/article"; | ||
|
|
||
| export default function useArticleActions(articleId?: number) { | ||
| async function handleLike(flag: boolean) { | ||
| if (!articleId) return; | ||
|
|
||
| return toggleLike(articleId, flag); | ||
| } | ||
|
|
||
| async function handleArticleAdd(formData: ArticleFormType) { | ||
| try { | ||
| if (formData.image instanceof File) { | ||
| const { url } = await uploadArticleImage(formData.image); | ||
| formData.image = url; | ||
| } | ||
|
|
||
| return await addArticle(formData); | ||
| } catch (err) { | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| async function handleArticleModify(formData: ArticleFormType) { | ||
| if (!articleId) return; | ||
|
|
||
| try { | ||
| if (formData.image instanceof File) { | ||
| const { url } = await uploadArticleImage(formData.image); | ||
| formData.image = url; | ||
| } | ||
|
|
||
| return await modifyArticle(articleId, formData); | ||
| } catch (err) { | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| async function handleArticleDelete() { | ||
| if (!articleId) return; | ||
|
|
||
| return deleteArticle(articleId); | ||
| } | ||
|
|
||
| return { | ||
| handleLike, | ||
| handleArticleAdd, | ||
| handleArticleModify, | ||
| handleArticleDelete, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| import { Temporary } from "@/components/ui"; | ||
| import { PageWrapper } from "@/components/Page"; | ||
| import ArticleForm from "../_components/ArticleForm"; | ||
|
|
||
| export default function AddBoardPage() { | ||
| return <Temporary title="게시글 작성 페이지" />; | ||
| return ( | ||
| <PageWrapper> | ||
| <ArticleForm mode="add" /> | ||
| </PageWrapper> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { Message } from "@/components/ui"; | ||
|
|
||
| export default function Loading() { | ||
| return <Message>코멘트를 가져오는중입니다...</Message>; | ||
| } | ||
|
Comment on lines
+3
to
+5
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 오 loading.tsx 도 쓸줄아시는군요...! 👍 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import Comments from "@/components/Comment/Comments"; | ||
| import { getComments } from "@/service/comments"; | ||
|
|
||
| export default async function ArticleCommentsPage({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ id: string }>; | ||
| }) { | ||
| const id = (await params).id; | ||
| const comments = await getComments("articles", { | ||
| id: Number(id), | ||
| limit: 5, | ||
| }); | ||
|
|
||
| return <Comments name="articles" data={comments} />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { Message } from "@/components/ui"; | ||
|
|
||
| export default function Loading() { | ||
| return <Message>게시물 정보를 불러오는중입니다...</Message>; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
articleId 값은 initialData 에 포함되어있지 않을까요...?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
요거는 타입 추론을 이용하면 좋을것같아요.
mode가 add 이면 딱히 initialData나 articleId가 필요없지 않나요...?
이런식으로 합성해서 사용할 수 있어요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오 이거 엄청 고민하던건데 감사합니다.;;;