Skip to content
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

[Feat] 주제 설정 페이지 퍼블리싱 및 폼 연동 #95

Merged
merged 22 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6a76712
chore(apps/web): motion 추가
minseong0324 Jan 29, 2025
19acc4d
chore(packages/ui): styles export 변경
minseong0324 Jan 29, 2025
6d8c4b0
feat(packages/theme): 색상 추가
minseong0324 Jan 29, 2025
f980e5f
feat(packages/theme): spacing 추가
minseong0324 Jan 29, 2025
40d4c21
feat(apps/web): KeywordChip 컴포넌트 추가
minseong0324 Jan 29, 2025
eee1c1c
feat(apps/web): ImageManager 컴포넌트 추가
minseong0324 Jan 29, 2025
9a36f61
feat(apps/web): 주제 설정 페이지 퍼블리싱
minseong0324 Jan 29, 2025
50d14a6
refactor(apps/web): 컴포넌트 분리 및 자잘한 수정
minseong0324 Jan 29, 2025
54783d0
fix(apps/web): react-hook-form watch를 통한 조건부 렌더링
minseong0324 Jan 29, 2025
c4710ef
feat(apps/web): react-hook-form을 통한 폼 관리
minseong0324 Jan 29, 2025
d20667a
fix(apps/web): ImageManager 컴포넌트 packages/ui로 이동 예정
minseong0324 Jan 30, 2025
35f5825
Merge branch 'develop' of https://github.com/YAPP-Github/25th-Web-Tea…
minseong0324 Jan 31, 2025
e9fa937
fix(apps/web): ImageManager TypeA css 수정
minseong0324 Jan 31, 2025
b2f1e7e
fix(apps/web): GradientAnimatedTitle로 수정
minseong0324 Jan 31, 2025
ae68537
fix(apps/web): GradientAnimatedTitle로 수정
minseong0324 Jan 31, 2025
25a65ee
feat(apps/web): isEmptyStringOrNil 유틸 추가
minseong0324 Jan 31, 2025
efd6a6b
fix(apps/web): ImageManager TypeA 제어형으로 사용 가능하도록 변경
minseong0324 Jan 31, 2025
7b32506
refactor(apps/web): 컴포넌트 분리
minseong0324 Jan 31, 2025
fac8081
chore(apps/web): px->rem 변경
minseong0324 Jan 31, 2025
a5a0bc6
fix(app/web): defaultValues 수정
minseong0324 Jan 31, 2025
351838f
fix(apps/web): css 수정
minseong0324 Jan 31, 2025
3b38884
fix(apps/web): placeholder 수정
minseong0324 Jan 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@repo/theme": "workspace:^",
"@repo/ui": "workspace:^",
"@vanilla-extract/css": "^1.17.0",
"motion": "^11.17.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4;
모션 라이브러리 ui, web 모두에서 쓰이니까 최상위로 올려도 될 것 같아요! api 로직 추가 시 고쳐도 될 것 같습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theme 패키지에서 안쓰여서 motion 의존성은 현재대로 유지하는 게 좋지 않을까 생각됩니다!

"next": "14.2.21",
"overlay-kit": "^1.4.1",
"react": "^18",
Expand Down
235 changes: 235 additions & 0 deletions apps/web/src/app/create/Create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
'use client';

import {
TextField,
Label,
Spacing,
RadioCards,
Breadcrumb,
Icon,
Button,
} from '@repo/ui';
import { ImageManager, MainBreadcrumbItem } from '@web/components/common';
import { KeywordChipGroup } from './_components/KeywordChip/KeywordChipGroup';
import { GradientAnimatedTitle } from './_components/GradientAnimatedTitle/GradientAnimatedTitle';
import { AnimatedContainer } from './_components/AnimatedContainer/AnimatedContainer';
import { useForm, Controller } from 'react-hook-form';
import { isEmptyStringOrNil } from '@web/utils';
import { CreateFormValues } from './types';
import {
REFERENCE_TYPE,
PURPOSE_OPTIONS,
REFERENCE_OPTIONS,
LENGTH_OPTIONS,
} from './constants';
import * as styles from './pageStyle.css';

export default function Create() {
const { watch, control, handleSubmit } = useForm<CreateFormValues>({
defaultValues: {
topic: '',
purpose: 'INFORMATION',
reference: 'NONE',
newsCategory: '투자', // TODO: 백엔드로부터 받는 데이터 타입으로 수정
imageUrls: [], // TODO: presigned url 받아서 첨부
length: 'SHORT',
content: '',
},
mode: 'onChange',
});

const topic = watch('topic');
const reference = watch('reference');

const onSubmit = (data: CreateFormValues) => {
//TODO: 임시 로직. 이런 식으로 요청해야 함
// // 1. presigned URL 요청
// const presignedUrls = await fetchPresignedUrls(data.imageUrls); // 🔹 presigned URL 요청

// // 2. 파일을 presigned URL로 업로드
// await Promise.all(
// data.imageUrls.map((file, index) =>
// uploadFileToPresignedUrl(presignedUrls[index], file)
// )
// );

const presignedUrls = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
];

const requestData = {
...data,
newsCategory: data.reference === 'NEWS' ? data.newsCategory : null,
imageUrls: data.reference === 'IMAGE' ? presignedUrls : null,
};

console.log('폼 데이터:', requestData);
};
Comment on lines +44 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

임시 로직 개선 및 에러 처리 추가 필요

현재 구현된 임시 로직에 다음 개선사항이 필요합니다:

  1. 실제 API 호출 구현
  2. 로딩 상태 처리
  3. 에러 처리 및 사용자 피드백
-const onSubmit = (data: CreateFormValues) => {
+const onSubmit = async (data: CreateFormValues) => {
+  try {
+    setIsLoading(true);
+    
     const presignedUrls = await fetchPresignedUrls(data.imageUrls);
     
     await Promise.all(
       data.imageUrls.map((file, index) =>
         uploadFileToPresignedUrl(presignedUrls[index], file)
       )
     );
     
     const requestData = {
       ...data,
       newsCategory: data.reference === 'NEWS' ? data.newsCategory : null,
       imageUrls: data.reference === 'IMAGE' ? presignedUrls : null,
     };
     
-    console.log('폼 데이터:', requestData);
+    await createContent(requestData);
+    toast.success('컨텐츠가 성공적으로 생성되었습니다');
+  } catch (error) {
+    toast.error('컨텐츠 생성 중 오류가 발생했습니다');
+    console.error(error);
+  } finally {
+    setIsLoading(false);
+  }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onSubmit = (data: CreateFormValues) => {
//TODO: 임시 로직. 이런 식으로 요청해야 함
// // 1. presigned URL 요청
// const presignedUrls = await fetchPresignedUrls(data.imageUrls); // 🔹 presigned URL 요청
// // 2. 파일을 presigned URL로 업로드
// await Promise.all(
// data.imageUrls.map((file, index) =>
// uploadFileToPresignedUrl(presignedUrls[index], file)
// )
// );
const presignedUrls = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
];
const requestData = {
...data,
newsCategory: data.reference === 'NEWS' ? data.newsCategory : null,
imageUrls: data.reference === 'IMAGE' ? presignedUrls : null,
};
console.log('폼 데이터:', requestData);
};
const onSubmit = async (data: CreateFormValues) => {
try {
setIsLoading(true);
const presignedUrls = await fetchPresignedUrls(data.imageUrls);
await Promise.all(
data.imageUrls.map((file, index) =>
uploadFileToPresignedUrl(presignedUrls[index], file)
)
);
const requestData = {
...data,
newsCategory: data.reference === 'NEWS' ? data.newsCategory : null,
imageUrls: data.reference === 'IMAGE' ? presignedUrls : null,
};
await createContent(requestData);
toast.success('컨텐츠가 성공적으로 생성되었습니다');
} catch (error) {
toast.error('컨텐츠 생성 중 오류가 발생했습니다');
console.error(error);
} finally {
setIsLoading(false);
}
};


const isSubmitDisabled = isEmptyStringOrNil(topic);

return (
<div className={styles.mainStyle}>
<div className={styles.headerStyle}>
<Breadcrumb>
<Breadcrumb.Item>
<MainBreadcrumbItem href="/create" />
</Breadcrumb.Item>
Comment on lines +76 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4;
MainBreadcrumbItem을 사용할 때 <Breadcrumb.Item/>을 감싸지 않고 사용할 수 있도록 해도 좋을 것 같은데 어떨까요?.?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 Breadcrumb의 의도한 사용법이어서요! 유지하도록 하겠습니다!

</Breadcrumb>
<Button
type="submit"
size="large"
variant="primary"
leftAddon={<Icon name="twinkle" />}
onClick={handleSubmit(onSubmit)}
disabled={isSubmitDisabled}
>
생성하기
</Button>
</div>

<Spacing size={80} />

<GradientAnimatedTitle>어떤 글을 생성할까요?</GradientAnimatedTitle>

<AnimatedContainer>
<form className={styles.contentStyle}>
{/* 주제 */}
<section className={styles.sectionStyle}>
<TextField id="topic">
<TextField.Label variant="required">주제</TextField.Label>
<Controller
name="topic"
control={control}
render={({ field }) => (
<TextField.Input
{...field}
placeholder="주제를 적어주세요"
maxLength={5000}
/>
)}
/>
</TextField>
</section>

{/* 목적 */}
<section className={styles.sectionStyle}>
<Label variant="default">목적</Label>
<Controller
name="purpose"
control={control}
render={({ field: { onChange, value } }) => (
<RadioCards value={value} onChange={onChange} columns={4}>
{PURPOSE_OPTIONS.map(({ value, icon, label }) => (
<RadioCards.Item
key={value}
value={value}
leftAddon={<RadioCards.Icon name={icon} size={24} />}
>
<RadioCards.Label>{label}</RadioCards.Label>
</RadioCards.Item>
))}
</RadioCards>
)}
/>
</section>

{/* 생성 방식 */}
<section className={styles.sectionStyle}>
<Label>생성 방식</Label>
<Controller
name="reference"
control={control}
render={({ field: { onChange, value } }) => (
<RadioCards value={value} onChange={onChange} columns={3}>
{REFERENCE_OPTIONS.map(
({ value, icon, label, description }) => (
<RadioCards.Item
key={value}
value={value}
leftAddon={<RadioCards.Icon name={icon} size={24} />}
>
<RadioCards.Label>{label}</RadioCards.Label>
<RadioCards.Description>
{description}
</RadioCards.Description>
</RadioCards.Item>
)
)}
</RadioCards>
)}
/>
{reference === REFERENCE_TYPE.IMAGE && (
<Controller
name="imageUrls"
control={control}
render={({ field: { value, onChange } }) => (
<ImageManager.TypeA value={value || []} onChange={onChange} />
)}
/>
)}
</section>

{/* 조건부 렌더링 섹션들 */}
{reference === REFERENCE_TYPE.NEWS && (
<section className={styles.sectionStyle}>
<Label variant="required">뉴스 카테고리</Label>
<Controller
name="newsCategory"
control={control}
render={({ field: { value, onChange } }) => (
<KeywordChipGroup onChange={onChange} defaultValue={value}>
{['투자', '패션', '피트니스', '헬스케어']}
</KeywordChipGroup>
Comment on lines +182 to +184
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

뉴스 카테고리 하드코딩 개선 필요

뉴스 카테고리가 컴포넌트 내부에 하드코딩되어 있습니다. 이는 유지보수성을 저하시키고 카테고리 변경 시 코드 수정이 필요합니다.

다음과 같이 상수로 분리하는 것을 제안드립니다:

+const NEWS_CATEGORIES = ['투자', '패션', '피트니스', '헬스케어'] as const;
+
 <KeywordChipGroup onChange={onChange} defaultValue={value}>
-  {['투자', '패션', '피트니스', '헬스케어']}
+  {NEWS_CATEGORIES}
 </KeywordChipGroup>

추후에는 이 카테고리 목록을 API를 통해 받아오는 것을 고려해보시기 바랍니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<KeywordChipGroup onChange={onChange} defaultValue={value}>
{['투자', '패션', '피트니스', '헬스케어']}
</KeywordChipGroup>
+const NEWS_CATEGORIES = ['투자', '패션', '피트니스', '헬스케어'] as const;
<KeywordChipGroup onChange={onChange} defaultValue={value}>
- {['투자', '패션', '피트니스', '헬스케어']}
+ {NEWS_CATEGORIES}
</KeywordChipGroup>

)}
/>
</section>
)}

{/* 본문 길이 */}
<section className={styles.sectionStyle}>
<Label>본문 길이</Label>
<Controller
name="length"
control={control}
render={({ field: { onChange, value } }) => (
<RadioCards value={value} onChange={onChange} columns={3}>
{LENGTH_OPTIONS.map(
({ value, label, description, badge }) => (
<RadioCards.Item key={value} value={value}>
<RadioCards.Badge>{badge}</RadioCards.Badge>
<RadioCards.Label>{label}</RadioCards.Label>
<RadioCards.Description>
{description}
</RadioCards.Description>
</RadioCards.Item>
)
)}
</RadioCards>
)}
/>
</section>

{/* 핵심 내용 */}
<section className={styles.sectionStyle}>
<TextField id="content">
<TextField.Label variant="optional">핵심 내용</TextField.Label>
<Controller
name="content"
control={control}
render={({ field }) => (
<TextField.Input
{...field}
placeholder="본문에 꼭 포함되어야 하는 문구나 요구 사항을 적어주세요"
maxLength={5000}
/>
)}
/>
</TextField>
</section>
</form>
</AnimatedContainer>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { vars } from '@repo/theme';
import { style } from '@vanilla-extract/css';

export const containerStyle = style({
maxWidth: '92rem',
margin: '0 auto',
width: '100%',
padding: `${vars.space[32]} ${vars.space[32]} 12rem ${vars.space[32]}`,

borderRadius: '2.4rem 2.4rem 0 0',
backgroundColor: vars.colors.grey,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { motion } from 'motion/react';
import { ReactNode } from 'react';
import * as styles from './AnimatedContainer.css';

type AnimatedContainerProps = {
children: ReactNode;
};

export function AnimatedContainer({ children }: AnimatedContainerProps) {
return (
<motion.div
className={styles.containerStyle}
initial={{ y: '100%' }}
animate={{ y: 0 }}
transition={{
type: 'spring',
duration: 0.6,
bounce: 0.22,
}}
>
{children}
</motion.div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { vars } from '@repo/theme';
import { keyframes, style } from '@vanilla-extract/css';

const flowingGradient = keyframes({
'0%': {
backgroundPosition: '0% 50%',
},
'100%': {
backgroundPosition: '200% 50%',
},
});

export const gradientTitleStyle = style({
background:
'linear-gradient(90deg, #1F3761 0%, #2646C5 10%, #615BE7 30%, #615BE7 70%, #2646C5 93%, #1F3761 97%, #1F3761 100%)',
backgroundSize: '200% 100%',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: 'center',
padding: `${vars.space[24]} 0`,
animation: `${flowingGradient} 4s linear infinite`,
backgroundRepeat: 'repeat',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { motion } from 'motion/react';
import * as styles from './GradientAnimatedTitle.css';
import { ReactNode } from 'react';

type GradientAnimatedTitleProps = {
children: ReactNode;
};

export function GradientAnimatedTitle({
children,
}: GradientAnimatedTitleProps) {
return (
<motion.h1
className={styles.gradientTitleStyle}
initial={{
y: '35vh',
scale: 2,
x: '-50%',
left: '50%',
position: 'absolute',
}}
animate={{
y: 0,
scale: 1,
x: 0,
left: 'auto',
position: 'relative',
}}
transition={{
type: 'spring',
duration: 0.6,
bounce: 0.22,
}}
>
{children}
</motion.h1>
);
}
37 changes: 37 additions & 0 deletions apps/web/src/app/create/_components/KeywordChip/KeywordChip.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { vars } from '@repo/theme';
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';

export const KeywordChipRecipe = recipe({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
padding: '0 1.6rem',
height: '4rem',
width: 'fit-content',
borderRadius: '2.4rem',
backgroundColor: vars.colors.grey25,
color: vars.colors.grey500,
cursor: 'pointer',
transition: 'all 0.2s ease',
border: 'none',
':hover': {
opacity: 0.8,
},
},
variants: {
isSelected: {
true: {
backgroundColor: vars.colors.primary200,
color: vars.colors.primary800,
},
},
},
});

export const keywordChipGroupWrapper = style({
display: 'flex',
gap: vars.space[10],
});
Loading
Loading