diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/next.config.js b/next.config.js index 95dff287..f06257b6 100644 --- a/next.config.js +++ b/next.config.js @@ -10,6 +10,14 @@ const nextConfig = { }); return config; }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '*', + }, + ], + }, }; module.exports = withVanillaExtract(nextConfig); diff --git a/package.json b/package.json index 33569613..b90a5765 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "axios": "^1.6.5", "next": "14.0.4", "react": "^18", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18", - "react-hook-form": "^7.49.3", + "react-hook-form": "^7.50.0", "react-scripts": "^5.0.1", "zustand": "^4.4.7" }, @@ -40,6 +41,7 @@ "@svgr/webpack": "^8.1.0", "@commitlint/cli": "^18.6.0", "@commitlint/config-conventional": "^18.6.0", + "@svgr/webpack": "^8.1.0", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/react-hooks": "^8.0.1", @@ -48,6 +50,7 @@ "@types/jest": "^29.5.11", "@types/node": "^20", "@types/react": "^18", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.0.4", diff --git a/public/icons/add.svg b/public/icons/add.svg new file mode 100644 index 00000000..35b07a43 --- /dev/null +++ b/public/icons/add.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/attach_image.svg b/public/icons/attach_image.svg new file mode 100644 index 00000000..38cdc43e --- /dev/null +++ b/public/icons/attach_image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/back.svg b/public/icons/back.svg new file mode 100644 index 00000000..8d74e91b --- /dev/null +++ b/public/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/clear_x_black.svg b/public/icons/clear_x_black.svg new file mode 100644 index 00000000..ae632feb --- /dev/null +++ b/public/icons/clear_x_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/clear_x_gray.svg b/public/icons/clear_x_gray.svg new file mode 100644 index 00000000..1a896c83 --- /dev/null +++ b/public/icons/clear_x_gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/dnd.svg b/public/icons/dnd.svg new file mode 100644 index 00000000..6f03a70f --- /dev/null +++ b/public/icons/dnd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/link.svg b/public/icons/link.svg new file mode 100644 index 00000000..a10401e5 --- /dev/null +++ b/public/icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/create/_components/AddItemButton.css.ts b/src/app/create/_components/AddItemButton.css.ts new file mode 100644 index 00000000..e28b7a6f --- /dev/null +++ b/src/app/create/_components/AddItemButton.css.ts @@ -0,0 +1,23 @@ +import { style } from '@vanilla-extract/css'; + +export const addButton = style({ + width: '100%', + height: '60px', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '12px', + + //body1 + fontSize: '1.6rem', + fontWeight: '400', + lineHeight: '1.6rem', + letterSpacing: '-0.48px', + color: '#61646B', + + backgroundColor: '#FFF', + + border: 'solid 1px #AFB1B6 ', + borderRadius: '15px', +}); diff --git a/src/app/create/_components/AddItemButton.tsx b/src/app/create/_components/AddItemButton.tsx new file mode 100644 index 00000000..1f48d3f2 --- /dev/null +++ b/src/app/create/_components/AddItemButton.tsx @@ -0,0 +1,14 @@ +import AddIcon from '/public/icons/add.svg'; +import * as styles from './AddItemButton.css'; + +interface AddItemButton { + handleAddButtonClick: () => void; +} + +export default function AddItemButton({ handleAddButtonClick }: AddItemButton) { + return ( + + ); +} diff --git a/src/app/create/_components/CreateItem.css.ts b/src/app/create/_components/CreateItem.css.ts new file mode 100644 index 00000000..0ce41170 --- /dev/null +++ b/src/app/create/_components/CreateItem.css.ts @@ -0,0 +1,71 @@ +import { style } from '@vanilla-extract/css'; + +export const header = style({ + width: '100%', + height: '90px', + paddingLeft: '20px', + paddingRight: '20px', + + position: 'sticky', + top: '0', + left: '0', + zIndex: '10', + + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + + backgroundColor: '#fff', + + borderBottom: '1px solid rgba(0, 0, 0, 0.10)', +}); + +export const headerTitle = style({ + fontSize: '2rem', +}); + +export const headerNextButton = style({ + fontSize: '1.6rem', + backgroundColor: 'transparent', +}); + +export const headerNextButtonDisabled = style([ + headerNextButton, + { + color: '#AFB1B6', //활성화 검정, 아닐때는 회색 + }, +]); + +export const article = style({ + padding: '16px 20px 30px', +}); + +//body1 +export const label = style({ + marginBottom: '1.6rem', + + fontSize: '1.6rem', + fontWeight: '600', + letterSpacing: '-0.048rem', +}); + +export const required = style({ + marginLeft: '6px', + + fontSize: '1.6rem', + fontWeight: '500', + letterSpacing: '-0.048rem', + color: '#FF5454', +}); + +//body3 +export const description = style({ + marginBottom: '1.6rem', + + fontSize: '1.4rem', + color: '#8A8A8E', + fontWeight: '400', + lineHeight: '2.5rem', + letterSpacing: '-0.042rem', +}); diff --git a/src/app/create/_components/CreateItem.tsx b/src/app/create/_components/CreateItem.tsx new file mode 100644 index 00000000..7cd378e6 --- /dev/null +++ b/src/app/create/_components/CreateItem.tsx @@ -0,0 +1,46 @@ +import { useFormContext } from 'react-hook-form'; + +import BackIcon from '/public/icons/back.svg'; +import Items from './Items'; +import * as styles from './CreateItem.css'; + +interface CreateItemProps { + onBackClick: () => void; +} + +export default function CreateItem({ onBackClick }: CreateItemProps) { + const { + formState: { isValid }, + } = useFormContext(); + + return ( +
+
+ +

리스트 생성

+ +
+
+

+ 아이템 추가 * +

+ +

+ 최소 3개, 최대 10개까지 아이템을 추가할 수 있어요.
+ 아이템의 순서대로 순위가 정해져요. +

+ +
+
+ ); +} diff --git a/src/app/create/_components/CreateList.tsx b/src/app/create/_components/CreateList.tsx index adf67112..8e80d36c 100644 --- a/src/app/create/_components/CreateList.tsx +++ b/src/app/create/_components/CreateList.tsx @@ -21,7 +21,7 @@ interface UserProfileType { nickname: string; } -function CreateList() { +function CreateList({ onNextClick }: { onNextClick: () => void }) { const { register, getValues, setValue, setError, control, formState } = useFormContext(); const { errors, isValid } = formState; @@ -63,9 +63,9 @@ function CreateList() {

리스트 생성

- +
diff --git a/src/app/create/_components/ItemLayout.css.ts b/src/app/create/_components/ItemLayout.css.ts new file mode 100644 index 00000000..09d3172a --- /dev/null +++ b/src/app/create/_components/ItemLayout.css.ts @@ -0,0 +1,76 @@ +import { style } from '@vanilla-extract/css'; + +export const itemHeader = style({ + width: '100%', + + display: 'flex', + alignItems: 'center', + gap: '12px', + + overflow: 'hidden', +}); + +export const rankAndTitle = style({ + width: '100%', + + display: 'flex', + gap: '8px', +}); + +export const line = style({ + width: '100%', + margin: '0px', + + border: 'solid 1px #AFB1B6', +}); + +export const moreInfo = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const toolbar = style({ + width: '100%', + + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const fileButtons = style({ + height: '18px', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '10px', +}); + +export const previewContainer = style({ + display: 'flex', + gap: '10px', +}); + +export const previewBox = style({ + width: '90px', + height: '90px', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + position: 'relative', + + background: '#EBF4FF', + + borderRadius: '10px', + whiteSpace: 'pre-wrap', + overflow: 'hidden', +}); + +export const clearButton = style({ + position: 'absolute', + top: '5px', + right: '5px', +}); diff --git a/src/app/create/_components/ItemLayout.tsx b/src/app/create/_components/ItemLayout.tsx new file mode 100644 index 00000000..17581947 --- /dev/null +++ b/src/app/create/_components/ItemLayout.tsx @@ -0,0 +1,77 @@ +import { ReactNode } from 'react'; + +import DndIcon from '/public/icons/dnd.svg'; +import ClearGrayIcon from '/public/icons/clear_x_gray.svg'; +import ClearBlackIcon from '/public/icons/clear_x_black.svg'; +import ImageIcon from '/public/icons/attach_image.svg'; +import Label from '@/components/Label/Label'; +import * as styles from './ItemLayout.css'; + +interface ItemLayoutProps { + index: number; + handleDeleteItem: () => void; + itemLength: number; + titleInput: ReactNode; + commentTextArea: ReactNode; + commentLength: ReactNode; + linkModal: ReactNode; + linkPreview: ReactNode; +} + +export default function ItemLayout({ + index, + handleDeleteItem, + itemLength, + titleInput, + commentTextArea, + commentLength, + linkModal, + linkPreview, +}: ItemLayoutProps) { + return ( + <> +
+ +
+ + {titleInput} +
+ {itemLength > 3 && ( + + )} +
+ +
+ +
+ {commentTextArea} +
+
+ {linkModal} + +
+ {commentLength} +
+ +
+ {linkPreview} +
+ 사진칸 + +
+
+
+ + ); +} diff --git a/src/app/create/_components/Items.css.ts b/src/app/create/_components/Items.css.ts new file mode 100644 index 00000000..ef5a48a8 --- /dev/null +++ b/src/app/create/_components/Items.css.ts @@ -0,0 +1,102 @@ +import { style } from '@vanilla-extract/css'; + +export const itemsContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '16px', +}); + +export const item = style({ + padding: '12px 18px', + + display: 'flex', + flexDirection: 'column', + gap: '12px', + + backgroundColor: '#fff', + + fontSize: '1.6rem', + border: 'solid 1px #AFB1B6', + borderRadius: '6px', + + transition: 'box-shadow 0.3s ease', + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 2px 2px;', +}); + +export const draggingItem = style([ + item, + { + boxShadow: '0px 20px 50px -5px #AFB1B6', + }, +]); + +export const title = style({ + //body1 + fontSize: '1.6rem', + fontWeight: '400', + lineHeight: '1.6rem', + letterSpacing: '-0.48px', + + '::placeholder': { + color: '#AFB1B6', + }, +}); + +export const errorTitle = style([ + title, + { + '::placeholder': { + color: '#FF5454', + }, + }, +]); + +export const comment = style({ + width: '100%', + resize: 'none', + + flexGrow: '1', + + //body2 + fontSize: '1.5rem', + lineHeight: '2.5rem', + letterSpacing: '-0.45px', + + border: 'none', + outline: 'none', + + '::placeholder': { + color: '#AFB1B6', + }, +}); + +export const linkModalChildren = style({ + width: '100%', +}); + +export const linkInput = style([ + title, + { + width: '100%', + padding: '8px', + + border: 'solid 1px #AFB1B6', + borderRadius: '4px', + }, +]); + +export const countLength = style({ + //body2 + fontSize: '1.5rem', + letterSpacing: '-0.45px', + color: '#61646B', +}); + +export const error = style({ + marginTop: '8px', + marginLeft: '4px', + + flexShrink: '0', + color: '#FF5454', + fontSize: '1.5rem', +}); diff --git a/src/app/create/_components/Items.tsx b/src/app/create/_components/Items.tsx new file mode 100644 index 00000000..4792155a --- /dev/null +++ b/src/app/create/_components/Items.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd'; + +import { itemPlaceholder } from '@/lib/constants/placeholder'; +import { itemTitleRules, itemCommentRules, itemLinkRules } from '@/lib/constants/formInputValidationRules'; +import { StrictModeDroppable } from '@/components/StrictModeDroppable'; +import { FormErrors } from '../page'; +import ItemLayout from './ItemLayout'; +import LinkModal from './LinkModal'; +import LinkPreview from './LinkPreview'; +import * as styles from './Items.css'; +import AddItemButton from './AddItemButton'; + +// http:// 없을경우 추가 +const ensureHttp = (link: string) => { + if (!link.startsWith('http://' || 'https://')) { + return 'http://' + link; + } + return link; +}; + +// 링크 도메인만 추출 (e.g. naver.com) +const urlToDomain = (link: string) => { + const domain = new URL(link).hostname.replace('www.', ''); + return domain; +}; + +export default function Items() { + const { + register, + control, + getValues, + setValue, + formState: { errors }, + } = useFormContext(); + + const { + fields: items, + append, + remove, + } = useFieldArray({ + name: 'items', + control, + rules: { minLength: 3, maxLength: 10 }, + }); + + const [current, setCurrent] = useState(null); + + const watchItems = useWatch({ control, name: 'items' }); + + //--- LinkModal 핸들러 + const handleLinkModalOpen = (index: number) => { + setCurrent(getValues().items[index]?.link); + }; + + const handleLinkModalCancel = (index: number) => { + setValue(`items.${index}.link`, current); + }; + + const handleLinkModalConfirm = (index: number) => { + if (watchItems[index]?.link) { + setValue(`items.${index}.link`, ensureHttp(watchItems[index]?.link)); + } + }; + + //--- 드래그 되었을 때 실행되는 이벤트 + const onDragEnd = ({ source, destination }: DropResult) => { + if (destination && source.index !== destination.index) { + const currentArray = [...getValues().items]; + const sourceItem = currentArray[source.index]; + currentArray.splice(source.index, 1); + currentArray.splice(destination.index, 0, sourceItem); + setValue('items', currentArray); + } + }; + + return ( + + + {(provided) => ( +
+ {items.map((item, index) => { + const errorMessage = (field: 'title' | 'comment' | 'link') => + (errors as FormErrors)?.items?.[index]?.[field]?.message; + const titleError = errorMessage('title'); + const commentError = errorMessage('comment'); + const linkError = errorMessage('link'); + return ( + + {(provided, snapshot) => ( +
+ remove(index)} + itemLength={watchItems.length} + titleInput={ + + } + commentTextArea={ +