diff --git a/apps/web/package.json b/apps/web/package.json index 95dd905f..dc757068 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/create/Create.tsx b/apps/web/src/app/create/Create.tsx new file mode 100644 index 00000000..55850491 --- /dev/null +++ b/apps/web/src/app/create/Create.tsx @@ -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({ + 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 ( +
+
+ + + + + + +
+ + + + 어떤 글을 생성할까요? + + +
+ {/* 주제 */} +
+ + 주제 + ( + + )} + /> + +
+ + {/* 목적 */} +
+ + ( + + {PURPOSE_OPTIONS.map(({ value, icon, label }) => ( + } + > + {label} + + ))} + + )} + /> +
+ + {/* 생성 방식 */} +
+ + ( + + {REFERENCE_OPTIONS.map( + ({ value, icon, label, description }) => ( + } + > + {label} + + {description} + + + ) + )} + + )} + /> + {reference === REFERENCE_TYPE.IMAGE && ( + ( + + )} + /> + )} +
+ + {/* 조건부 렌더링 섹션들 */} + {reference === REFERENCE_TYPE.NEWS && ( +
+ + ( + + {['투자', '패션', '피트니스', '헬스케어']} + + )} + /> +
+ )} + + {/* 본문 길이 */} +
+ + ( + + {LENGTH_OPTIONS.map( + ({ value, label, description, badge }) => ( + + {badge} + {label} + + {description} + + + ) + )} + + )} + /> +
+ + {/* 핵심 내용 */} +
+ + 핵심 내용 + ( + + )} + /> + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.css.ts b/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.css.ts new file mode 100644 index 00000000..f3dfe8cd --- /dev/null +++ b/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.css.ts @@ -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, +}); diff --git a/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.tsx b/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.tsx new file mode 100644 index 00000000..e850e95e --- /dev/null +++ b/apps/web/src/app/create/_components/AnimatedContainer/AnimatedContainer.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.css.ts b/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.css.ts new file mode 100644 index 00000000..52db0369 --- /dev/null +++ b/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.css.ts @@ -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', +}); diff --git a/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.tsx b/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.tsx new file mode 100644 index 00000000..1444e3ab --- /dev/null +++ b/apps/web/src/app/create/_components/GradientAnimatedTitle/GradientAnimatedTitle.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/src/app/create/_components/KeywordChip/KeywordChip.css.ts b/apps/web/src/app/create/_components/KeywordChip/KeywordChip.css.ts new file mode 100644 index 00000000..37cd7bc0 --- /dev/null +++ b/apps/web/src/app/create/_components/KeywordChip/KeywordChip.css.ts @@ -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], +}); diff --git a/apps/web/src/app/create/_components/KeywordChip/KeywordChip.tsx b/apps/web/src/app/create/_components/KeywordChip/KeywordChip.tsx new file mode 100644 index 00000000..0b685669 --- /dev/null +++ b/apps/web/src/app/create/_components/KeywordChip/KeywordChip.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { + ComponentPropsWithoutRef, + forwardRef, + useCallback, + useEffect, + useRef, +} from 'react'; +import { KeywordChipRecipe } from './KeywordChip.css'; +import { Text } from '@repo/ui'; +import { useKeywordChip } from './context'; +import { mergeRefs } from '@repo/ui/utils'; + +export type KeywordChipProps = ComponentPropsWithoutRef<'button'> & { + value: string; + disabled?: boolean; +}; + +export const KeywordChip = forwardRef( + ({ className = '', children, value, disabled, ...rest }, ref) => { + const { + onChange, + disabled: groupDisabled, + isSelected, + itemsRef, + onKeyDown, + } = useKeywordChip(); + const isDisabled = disabled || groupDisabled; + const selected = isSelected?.(value) ?? false; + const itemRef = useRef(null); + + useEffect(() => { + const currentRef = itemRef.current; + if (currentRef) { + itemsRef.push(currentRef); + return () => { + const index = itemsRef.indexOf(currentRef); + if (index !== -1) itemsRef.splice(index, 1); + }; + } + }, [itemsRef]); + + const handleClick = useCallback(() => { + if (!isDisabled) { + onChange?.(value); + } + }, [isDisabled, onChange, value]); + + return ( + + ); + } +); + +KeywordChip.displayName = 'KeywordChip'; diff --git a/apps/web/src/app/create/_components/KeywordChip/KeywordChipGroup.tsx b/apps/web/src/app/create/_components/KeywordChip/KeywordChipGroup.tsx new file mode 100644 index 00000000..458ef547 --- /dev/null +++ b/apps/web/src/app/create/_components/KeywordChip/KeywordChipGroup.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { KeyboardEvent, ReactNode, useCallback, useState } from 'react'; +import { KeywordChip } from './KeywordChip'; +import { keywordChipGroupWrapper } from './KeywordChip.css'; +import { KeywordChipProvider } from './context'; + +const KEYBOARD_KEY = { + ARROW_RIGHT: 'ArrowRight', + ARROW_LEFT: 'ArrowLeft', +} as const; + +type KeywordChipGroupProps = { + children: ReactNode[]; + onChange?: (value: string) => void; + defaultValue?: string; + disabled?: boolean; + name?: string; +}; + +export const KeywordChipGroup = ({ + children, + onChange, + defaultValue = '', + disabled = false, + name = 'keyword-group', +}: KeywordChipGroupProps) => { + const [value, setValue] = useState(defaultValue); + const [itemsRef] = useState([]); + + const handleChange = useCallback( + (newValue: string) => { + setValue(newValue); + onChange?.(newValue); + }, + [onChange] + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent, currentIndex: number) => { + const items = itemsRef; + let nextIndex: number; + + switch (event.key) { + case KEYBOARD_KEY.ARROW_RIGHT: + event.preventDefault(); + nextIndex = (currentIndex + 1) % items.length; + break; + case KEYBOARD_KEY.ARROW_LEFT: + event.preventDefault(); + nextIndex = (currentIndex - 1 + items.length) % items.length; + break; + default: + return; + } + + items[nextIndex]?.focus(); + }, + [itemsRef] + ); + + const isSelected = useCallback( + (itemValue: string) => itemValue === value, + [value] + ); + + return ( + +
+ {children.map((child) => ( + + {child} + + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/create/_components/KeywordChip/context.tsx b/apps/web/src/app/create/_components/KeywordChip/context.tsx new file mode 100644 index 00000000..82366d35 --- /dev/null +++ b/apps/web/src/app/create/_components/KeywordChip/context.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, KeyboardEvent } from 'react'; + +type KeywordChipContextValue = { + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + isSelected?: (value: string) => boolean; + itemValue?: string; + itemsRef: HTMLButtonElement[]; + onKeyDown?: ( + event: KeyboardEvent, + currentIndex: number + ) => void; +}; + +const KeywordChipContext = createContext( + undefined +); + +export const useKeywordChip = () => { + const context = useContext(KeywordChipContext); + if (!context) { + throw new Error( + 'KeywordChip은 KeywordChipProvider 내부에서만 사용할 수 있습니다.' + ); + } + return context; +}; + +export const KeywordChipProvider = KeywordChipContext.Provider; diff --git a/apps/web/src/app/create/constants.ts b/apps/web/src/app/create/constants.ts new file mode 100644 index 00000000..ead87027 --- /dev/null +++ b/apps/web/src/app/create/constants.ts @@ -0,0 +1,83 @@ +export const PURPOSE_TYPE = { + INFORMATION: 'INFORMATION', + OPINION: 'OPINION', + HUMOR: 'HUMOR', + MARKETING: 'MARKETING', +} as const; + +export const REFERENCE_TYPE = { + NONE: 'NONE', + NEWS: 'NEWS', + IMAGE: 'IMAGE', +} as const; + +export const LENGTH_TYPE = { + SHORT: 'SHORT', + MEDIUM: 'MEDIUM', + LONG: 'LONG', +} as const; + +export const PURPOSE_OPTIONS = [ + { + value: PURPOSE_TYPE.INFORMATION, + icon: 'document', + label: '정보 제공', + }, + { + value: PURPOSE_TYPE.OPINION, + icon: 'chat', + label: '의견 표출', + }, + { + value: PURPOSE_TYPE.HUMOR, + icon: 'smile', + label: '공감/유머', + }, + { + value: PURPOSE_TYPE.MARKETING, + icon: 'shopping', + label: '홍보/마케팅', + }, +] as const; + +export const REFERENCE_OPTIONS = [ + { + value: REFERENCE_TYPE.NONE, + icon: 'pencil', + label: '입력된 주제로만 생성', + description: '주제에 맞는 글을 간단히 생성', + }, + { + value: REFERENCE_TYPE.NEWS, + icon: 'stack', + label: '최근 뉴스로 글 생성', + description: '최근 소식/뉴스 기반', + }, + { + value: REFERENCE_TYPE.IMAGE, + icon: 'picture', + label: '이미지를 참고해 글 생성', + description: '첨부한 이미지 기반', + }, +] as const; + +export const LENGTH_OPTIONS = [ + { + value: LENGTH_TYPE.SHORT, + label: '짧은 게시물', + description: '약 1~2문장, 최대 140자', + badge: '누구나 이용 가능', + }, + { + value: LENGTH_TYPE.MEDIUM, + label: '보통 게시물', + description: '약 3~4문장, 최대 300자', + badge: 'X 유료 구독 전용', + }, + { + value: LENGTH_TYPE.LONG, + label: '긴 게시물', + description: '약 7~8문장, 최대 1000자', + badge: 'X 유료 구독 전용', + }, +] as const; diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/page.tsx new file mode 100644 index 00000000..2e46186e --- /dev/null +++ b/apps/web/src/app/create/page.tsx @@ -0,0 +1,5 @@ +import Create from './Create'; + +export default function CreatePage() { + return ; +} diff --git a/apps/web/src/app/create/pageStyle.css.ts b/apps/web/src/app/create/pageStyle.css.ts new file mode 100644 index 00000000..559d197b --- /dev/null +++ b/apps/web/src/app/create/pageStyle.css.ts @@ -0,0 +1,39 @@ +import { style } from '@vanilla-extract/css'; +import { vars } from '@repo/theme'; + +export const mainStyle = style({ + maxWidth: '100%', + height: '100vh', + margin: '0 auto', + background: 'radial-gradient(circle at 50% 0%, #D7DAFF 0%, #FFFFFF 76%)', + overflow: 'auto', +}); + +export const headerStyle = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + position: 'fixed', + top: 0, + left: 0, + right: 0, + padding: `${vars.space[12]} ${vars.space[24]}`, + zIndex: 1000, +}); + +export const contentStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: '4.8rem', +}); + +export const sectionStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.space[16], +}); + +export const labelDiscriptionWrapperStyle = style({ + display: 'flex', + flexDirection: 'column', +}); diff --git a/apps/web/src/app/create/types.ts b/apps/web/src/app/create/types.ts new file mode 100644 index 00000000..c36b7af8 --- /dev/null +++ b/apps/web/src/app/create/types.ts @@ -0,0 +1,18 @@ +import { PURPOSE_TYPE, REFERENCE_TYPE, LENGTH_TYPE } from './constants'; + +export type PurposeType = (typeof PURPOSE_TYPE)[keyof typeof PURPOSE_TYPE]; + +export type ReferenceType = + (typeof REFERENCE_TYPE)[keyof typeof REFERENCE_TYPE]; + +export type LengthType = (typeof LENGTH_TYPE)[keyof typeof LENGTH_TYPE]; + +export interface CreateFormValues { + topic: string; + purpose: PurposeType; + reference: ReferenceType; + newsCategory?: string; + imageUrls?: File[]; + length: LengthType; + content: string; +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 13e76acd..f41fa856 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -18,7 +18,7 @@ import Link from 'next/link'; import { overlay } from 'overlay-kit'; import { useModal } from '@repo/ui/hooks'; import { useToast } from '@repo/ui/hooks'; -import { ImageManager } from '../components/common/ImageManager'; +import { ImageManager } from '@web/components/common'; type FormValues = { topic: string; diff --git a/apps/web/src/components/common/ImageManager/TypeA/TypeA.css.ts b/apps/web/src/components/common/ImageManager/TypeA/TypeA.css.ts index da6a1737..a7b50a74 100644 --- a/apps/web/src/components/common/ImageManager/TypeA/TypeA.css.ts +++ b/apps/web/src/components/common/ImageManager/TypeA/TypeA.css.ts @@ -10,11 +10,12 @@ export const textContent = recipe({ justifyContent: 'center', gap: vars.space[8], color: vars.colors.grey600, + width: '100%', }, variants: { isCenter: { - true: { alignItems: 'center' }, - false: { alignItems: 'flex-start' }, + true: { justifyContent: 'center' }, + false: { justifyContent: 'flex-start' }, }, }, }); diff --git a/apps/web/src/components/common/ImageManager/TypeA/TypeA.tsx b/apps/web/src/components/common/ImageManager/TypeA/TypeA.tsx index 2160dd49..15fb4db9 100644 --- a/apps/web/src/components/common/ImageManager/TypeA/TypeA.tsx +++ b/apps/web/src/components/common/ImageManager/TypeA/TypeA.tsx @@ -20,21 +20,31 @@ export type ImageManagerTypeAProps = { * @default 5 */ maxFiles?: number; + onChange?: (files: File[]) => void; + value?: File[]; }; export const TypeA = ({ maxFileSize = 10, maxFiles = 5, + onChange, + value = [], }: ImageManagerTypeAProps) => { if (maxFileSize <= 0) throw new Error('maxFileSize는 0보다 커야합니다.'); if (maxFiles <= 0) throw new Error('maxFiles는 0보다 커야합니다.'); - const [images, setImages] = useState([]); - const isImageUploaded = images.length > 0; + const [images, setImages] = useState( + value.map((file) => ({ + id: crypto.randomUUID(), + file, + preview: URL.createObjectURL(file), + })) + ); + const toast = useToast(); + const handleUpload = useCallback( (files: FileList) => { - // 파일 크기 체크 const oversizedFiles = Array.from(files).filter( (file) => file.size > maxFileSize * 1024 * 1024 ); @@ -44,7 +54,6 @@ export const TypeA = ({ return; } - // 최대 파일 개수 체크 if (images.length + files.length > maxFiles) { toast.error( `이미지는 최대 ${maxFiles}장까지 업로드할 수 있어요.`, @@ -53,7 +62,6 @@ export const TypeA = ({ return; } - // 이미지 파일 타입 체크 const invalidFiles = Array.from(files).filter( (file) => !file.type.startsWith('image/') ); @@ -69,30 +77,28 @@ export const TypeA = ({ preview: URL.createObjectURL(file), })); - setImages((prev) => { - prev.forEach((image) => URL.revokeObjectURL(image.preview)); - return [...prev, ...newFiles]; - }); + const updatedImages = [...images, ...newFiles]; + + setImages(updatedImages); + onChange?.(updatedImages.map((img) => img.file)); // 🔹 변경: File 객체를 넘김 }, - [images.length, maxFiles, maxFileSize, toast] + [images, maxFiles, maxFileSize, toast, onChange] ); - const handleRemove = useCallback((id: string) => { - setImages((prevImages) => { - const targetImage = prevImages.find((image) => image.id === id); - if (targetImage) { - URL.revokeObjectURL(targetImage.preview); - } - return prevImages.filter((image) => image.id !== id); - }); - }, []); + const handleRemove = useCallback( + (id: string) => { + setImages((prevImages) => { + const updatedImages = prevImages.filter((image) => image.id !== id); + onChange?.(updatedImages.map((img) => img.file)); // 🔹 삭제 후 File 배열 업데이트 + return updatedImages; + }); + }, + [onChange] + ); - // 컴포넌트 언마운트 시 메모리 정리 useEffect(() => { return () => { - images.forEach((image) => { - URL.revokeObjectURL(image.preview); - }); + images.forEach((image) => URL.revokeObjectURL(image.preview)); }; }, [images]); @@ -101,22 +107,18 @@ export const TypeA = ({ value={{ images, onUpload: handleUpload, onRemove: handleRemove }} > -
+
이곳에 이미지를 드래그하거나 클릭하여 업로드 - {!isImageUploaded && ( + {images.length === 0 && ( - 최대 {maxFiles}장, 각 {maxFileSize}MB이하 + 최대 {maxFiles}장, 각 {maxFileSize}MB 이하 )}
- {isImageUploaded && ( + {images.length > 0 && (
diff --git a/apps/web/src/components/common/ImageManager/TypeA/UploadedImages.css.ts b/apps/web/src/components/common/ImageManager/TypeA/UploadedImages.css.ts index 067c1b1c..a9abeca7 100644 --- a/apps/web/src/components/common/ImageManager/TypeA/UploadedImages.css.ts +++ b/apps/web/src/components/common/ImageManager/TypeA/UploadedImages.css.ts @@ -31,8 +31,8 @@ export const removeButton = style({ border: 'transparent', cursor: 'pointer', ':focus-visible': { - outline: `2px solid ${vars.colors.primary500}`, - outlineOffset: '2px', + outline: `0.2rem solid ${vars.colors.primary500}`, + outlineOffset: '0.2rem', }, ':hover': { opacity: 0.8, diff --git a/apps/web/src/components/common/ImageManager/index.ts b/apps/web/src/components/common/ImageManager/index.ts deleted file mode 100644 index 814673ba..00000000 --- a/apps/web/src/components/common/ImageManager/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ImageManager } from './ImageManager'; -export type { ImageManagerTypeAProps } from './ImageManager'; diff --git a/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.css.ts b/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.css.ts new file mode 100644 index 00000000..a34a946e --- /dev/null +++ b/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.css.ts @@ -0,0 +1,28 @@ +import { vars } from '@repo/theme'; +import { style } from '@vanilla-extract/css'; + +export const headerStyle = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + position: 'fixed', + top: 0, + left: 0, + right: 0, + padding: `${vars.space[12]} ${vars.space[24]}`, + zIndex: 1000, +}); + +export const insteadTextWrapperStyle = style({ + display: 'flex', + alignItems: 'center', + gap: vars.space[8], +}); + +export const insteadTextStyle = style({ + color: vars.colors.grey900, + fontSize: vars.typography.fontSize[24], + fontStyle: 'normal', + fontWeight: vars.typography.fontWeight.bold, + lineHeight: 'normal', +}); diff --git a/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.tsx b/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.tsx new file mode 100644 index 00000000..c6380954 --- /dev/null +++ b/apps/web/src/components/common/MainBreadcrumbItem/MainBreadcrumbItem.tsx @@ -0,0 +1,18 @@ +import { Icon } from '@repo/ui'; +import * as styles from './MainBreadcrumbItem.css'; +import Link from 'next/link'; + +type MainBreadcrumbItemProps = { + href?: string; +}; + +export function MainBreadcrumbItem({ + href = '/create', +}: MainBreadcrumbItemProps) { + return ( + + + Instead + + ); +} diff --git a/apps/web/src/components/common/index.ts b/apps/web/src/components/common/index.ts new file mode 100644 index 00000000..0d8917a0 --- /dev/null +++ b/apps/web/src/components/common/index.ts @@ -0,0 +1,4 @@ +export { ImageManager } from './ImageManager/ImageManager'; +export type { ImageManagerTypeAProps } from './ImageManager/ImageManager'; + +export { MainBreadcrumbItem } from './MainBreadcrumbItem/MainBreadcrumbItem'; diff --git a/apps/web/src/utils/index.ts b/apps/web/src/utils/index.ts index 014ae385..71c2faf2 100644 --- a/apps/web/src/utils/index.ts +++ b/apps/web/src/utils/index.ts @@ -1 +1,2 @@ export { validateFiles } from './validateFiles'; +export { isEmptyStringOrNil } from './isEmptyStringOrNil'; diff --git a/apps/web/src/utils/isEmptyStringOrNil.ts b/apps/web/src/utils/isEmptyStringOrNil.ts new file mode 100644 index 00000000..9050c01c --- /dev/null +++ b/apps/web/src/utils/isEmptyStringOrNil.ts @@ -0,0 +1,7 @@ +import { isNil } from '@repo/ui/utils'; + +type NullableString = string | null | undefined; + +export function isEmptyStringOrNil(value: NullableString): boolean { + return isNil(value) || value.trim() === ''; +} diff --git a/packages/theme/src/themes/contract.ts b/packages/theme/src/themes/contract.ts index 881e2a4d..ff06cc3f 100644 --- a/packages/theme/src/themes/contract.ts +++ b/packages/theme/src/themes/contract.ts @@ -32,6 +32,7 @@ export type ThemeContract = { grey950toPrimary: string; grey1000to1000: string; + primary200: string; primary500: string; primary600: string; primary700: string; diff --git a/packages/theme/src/themes/dark.ts b/packages/theme/src/themes/dark.ts index 4ae34faa..27405c88 100644 --- a/packages/theme/src/themes/dark.ts +++ b/packages/theme/src/themes/dark.ts @@ -36,6 +36,7 @@ export const darkTheme: ThemeContract = { grey950toPrimary: tokens.colors.green200, grey1000to1000: tokens.colors.grey1000, + primary200: tokens.colors.primary200, primary500: tokens.colors.primary500, primary600: tokens.colors.primary600, primary700: tokens.colors.primary700, diff --git a/packages/theme/src/themes/light.ts b/packages/theme/src/themes/light.ts index d5d35a04..d2c33ec7 100644 --- a/packages/theme/src/themes/light.ts +++ b/packages/theme/src/themes/light.ts @@ -36,6 +36,7 @@ export const lightTheme: ThemeContract = { grey950toPrimary: tokens.colors.grey950, grey1000to1000: tokens.colors.grey1000, + primary200: tokens.colors.primary200, primary500: tokens.colors.primary500, primary600: tokens.colors.primary600, primary700: tokens.colors.primary700, diff --git a/packages/theme/src/tokens/colors.ts b/packages/theme/src/tokens/colors.ts index ae2ca1c4..c095d131 100644 --- a/packages/theme/src/tokens/colors.ts +++ b/packages/theme/src/tokens/colors.ts @@ -1,8 +1,9 @@ export const colors = { + primary200: '#D8DDFF', primary500: '#7081F4', primary600: '#5266EC', primary700: '#3348D6', - primary800: '#2035BC', // TODO 삭제 예정 + primary800: '#2739B2', warning300: '#FF724E', warning500: '#FF3300', diff --git a/packages/theme/src/tokens/spacing.ts b/packages/theme/src/tokens/spacing.ts index 44450332..5394b507 100644 --- a/packages/theme/src/tokens/spacing.ts +++ b/packages/theme/src/tokens/spacing.ts @@ -9,6 +9,7 @@ export const spacing = { 32: '3.2rem', 40: '4.0rem', 64: '6.4rem', + 80: '8.0rem', 128: '12.8rem', } as const; diff --git a/packages/ui/package.json b/packages/ui/package.json index 6b007dbb..d182c43a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,9 +18,7 @@ "types": "./dist/hooks/index.d.ts", "import": "./dist/hooks/index.js" }, - "./styles": { - "import": "./dist/index.css" - }, + "./styles": "./dist/index.css", "./IconButton": { "types": "./dist/components/IconButton/index.d.ts", "import": "./dist/components/IconButton/index.js" diff --git a/packages/ui/src/components/TextField/TextFieldInput.tsx b/packages/ui/src/components/TextField/TextFieldInput.tsx index 82c0a839..74fc856c 100644 --- a/packages/ui/src/components/TextField/TextFieldInput.tsx +++ b/packages/ui/src/components/TextField/TextFieldInput.tsx @@ -29,7 +29,7 @@ export const TextFieldInput = forwardRef< ( { maxLength = 500, - showCounter = true, + showCounter = false, value: controlledValue, defaultValue, className = '', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 888b8899..0a5ee69a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@vanilla-extract/css': specifier: ^1.17.0 version: 1.17.0 + motion: + specifier: ^11.17.0 + version: 11.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: 14.2.21 version: 14.2.21(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)