Skip to content

Commit

Permalink
[Feat] 주제 설정 페이지 퍼블리싱 및 폼 연동 (#95)
Browse files Browse the repository at this point in the history
* chore(apps/web): motion 추가

* chore(packages/ui): styles export 변경

* feat(packages/theme): 색상 추가

* feat(packages/theme): spacing 추가

* feat(apps/web): KeywordChip 컴포넌트 추가

* feat(apps/web): ImageManager 컴포넌트 추가

* feat(apps/web): 주제 설정 페이지 퍼블리싱

* refactor(apps/web): 컴포넌트 분리 및 자잘한 수정

* fix(apps/web): react-hook-form watch를 통한 조건부 렌더링

* feat(apps/web): react-hook-form을 통한 폼 관리

* fix(apps/web): ImageManager 컴포넌트 packages/ui로 이동 예정

* fix(apps/web): ImageManager TypeA css 수정

* fix(apps/web): GradientAnimatedTitle로 수정

* fix(apps/web): GradientAnimatedTitle로 수정

* feat(apps/web): isEmptyStringOrNil 유틸 추가

* fix(apps/web): ImageManager TypeA 제어형으로 사용 가능하도록 변경

* refactor(apps/web): 컴포넌트 분리

* chore(apps/web): px->rem 변경

* fix(app/web): defaultValues 수정

* fix(apps/web): css 수정

* fix(apps/web): placeholder 수정
  • Loading branch information
minseong0324 authored Feb 1, 2025
1 parent f2633ce commit d1fe2ca
Show file tree
Hide file tree
Showing 32 changed files with 819 additions and 43 deletions.
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",
"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);
};

const isSubmitDisabled = isEmptyStringOrNil(topic);

return (
<div className={styles.mainStyle}>
<div className={styles.headerStyle}>
<Breadcrumb>
<Breadcrumb.Item>
<MainBreadcrumbItem href="/create" />
</Breadcrumb.Item>
</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>
)}
/>
</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

0 comments on commit d1fe2ca

Please sign in to comment.