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={
+
+ }
+ commentLength={
+
+ ({watchItems[index]?.comment?.length ?? 0}/100)
+
+ }
+ linkModal={
+ {
+ handleLinkModalCancel(index);
+ }}
+ onTriggerButtonClick={() => {
+ handleLinkModalOpen(index);
+ }}
+ onConfirmButtonClick={() => {
+ handleLinkModalConfirm(index);
+ }}
+ isLinkValid={!linkError && watchItems[index]?.link?.length !== 0}
+ >
+
+
+ {watchItems[index]?.link?.length !== 0 && linkError && (
+
{linkError}
+ )}
+
+
+ }
+ linkPreview={
+ watchItems[index]?.link && (
+ {
+ setValue(`items.${index}.link`, '');
+ }}
+ />
+ )
+ }
+ />
+
+ )}
+
+ );
+ })}
+ {provided.placeholder}
+ {watchItems.length < 10 && (
+
+ append({
+ rank: 0,
+ title: '',
+ comment: '',
+ link: '',
+ })
+ }
+ />
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/create/_components/LinkModal.tsx b/src/app/create/_components/LinkModal.tsx
new file mode 100644
index 00000000..1b544aa2
--- /dev/null
+++ b/src/app/create/_components/LinkModal.tsx
@@ -0,0 +1,54 @@
+import { ReactNode } from 'react';
+import LinkIcon from '/public/icons/link.svg';
+import Modal from '@/components/Modal/Modal';
+import useBooleanOutput from '@/hooks/useBooleanOutput';
+
+interface LinkModalProps {
+ children: ReactNode;
+ isLinkValid: boolean;
+ onTriggerButtonClick: () => void;
+ onCancelButtonClick: () => void;
+ onConfirmButtonClick: () => void;
+}
+
+export default function LinkModal({
+ children,
+ isLinkValid,
+ onTriggerButtonClick,
+ onCancelButtonClick,
+ onConfirmButtonClick,
+}: LinkModalProps) {
+ const { isOn, handleSetOff, handleSetOn } = useBooleanOutput();
+
+ const handleOpenClick = () => {
+ onTriggerButtonClick();
+ handleSetOn();
+ };
+
+ const handleCancelButtonClick = () => {
+ onCancelButtonClick();
+ handleSetOff();
+ };
+
+ const handleConfirmButtonClick = () => {
+ onConfirmButtonClick();
+ handleSetOff();
+ };
+
+ return (
+ <>
+
+ {isOn && (
+
+ 링크 추가
+ {children}
+
+ 확인
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/create/_components/LinkPreview.css.ts b/src/app/create/_components/LinkPreview.css.ts
new file mode 100644
index 00000000..a7c14b02
--- /dev/null
+++ b/src/app/create/_components/LinkPreview.css.ts
@@ -0,0 +1,26 @@
+import { style } from '@vanilla-extract/css';
+
+export const previewBox = style({
+ width: '90px',
+ height: '90px',
+
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+
+ position: 'relative',
+
+ backgroundColor: '#EBF4FF',
+
+ borderRadius: '10px',
+ whiteSpace: 'pre-wrap',
+ overflow: 'hidden',
+ cursor: 'pointer',
+});
+
+export const clearButton = style({
+ position: 'absolute',
+ top: '5px',
+ right: '5px',
+});
diff --git a/src/app/create/_components/LinkPreview.tsx b/src/app/create/_components/LinkPreview.tsx
new file mode 100644
index 00000000..85f6c161
--- /dev/null
+++ b/src/app/create/_components/LinkPreview.tsx
@@ -0,0 +1,39 @@
+import Image from 'next/image';
+
+import ClearBlackIcon from '/public/icons/clear_x_black.svg';
+import LinkIcon from '/public/icons/link.svg';
+import * as styles from './LinkPreview.css';
+
+interface LinkPreviewProps {
+ type: 'link' | 'image';
+ url?: string;
+ domain?: string;
+ imageUrl?: string;
+ handleClearButtonClick: () => void;
+}
+
+export default function PreviewBox({ type, url, domain, imageUrl, handleClearButtonClick }: LinkPreviewProps) {
+ const handlePreviewClick = () => {
+ window.open(url);
+ };
+
+ const handleClearClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ handleClearButtonClick();
+ };
+
+ return (
+
+ {type === 'link' && (
+ <>
+
+
{domain}
+ >
+ )}
+ {type === 'image' &&
}
+
+
+ );
+}
diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx
index f6b5b8fa..a7271021 100644
--- a/src/app/create/page.tsx
+++ b/src/app/create/page.tsx
@@ -1,9 +1,9 @@
'use client';
-//page.tsx
import { FieldErrors, FormProvider, useForm } from 'react-hook-form';
-// import CreateItem from '@/app/create/_components/CreateItem';
+import CreateItem from '@/app/create/_components/CreateItem';
import CreateList from '@/app/create/_components/CreateList';
+import { useState } from 'react';
interface Item {
rank: number;
@@ -27,6 +27,12 @@ interface FormValues {
export type FormErrors = FieldErrors;
export default function CreatePage() {
+ const [step, setStep] = useState<'list' | 'item'>('list');
+
+ const handleStepChange = (step: 'list' | 'item') => {
+ setStep(step);
+ };
+
const methods = useForm({
mode: 'onChange',
defaultValues: {
@@ -71,8 +77,19 @@ export default function CreatePage() {
- {/* */}
-
+ {step === 'list' ? (
+ {
+ handleStepChange('item');
+ }}
+ />
+ ) : (
+ {
+ handleStepChange('list');
+ }}
+ />
+ )}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 19153c10..fa45bfa6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
'use client';
import { ReactNode } from 'react';
+import '@/styles/globalStyles.css';
export default function TempLayout({ children }: { children: ReactNode }) {
return (
@@ -8,6 +9,7 @@ export default function TempLayout({ children }: { children: ReactNode }) {
ListyWave
+
{children}