diff --git a/components/Input.tsx b/components/Input.tsx index 6c36ca8..933e858 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -11,6 +11,7 @@ interface InputFieldProps { label?: string; compareValue?: string; layout?: 'vertical' | 'horizontal'; + onValidation?: (isValid: boolean) => void; } function InputField({ @@ -21,6 +22,7 @@ function InputField({ label, compareValue, layout = 'vertical', + onValidation, }: InputFieldProps) { const { errorMessage, validate } = useValidation({ type, @@ -40,17 +42,20 @@ function InputField({ }; const handleBlur = () => { - if (layout === 'vertical') - // 가로모드 에러 확인 비활성화 - validate(value); + if (layout === 'vertical') { + const error = validate(value); + onValidation?.(!error && value.length > 0); + } }; const handleChange = (e: React.ChangeEvent) => { onChange(e); if (layout === 'vertical' && errorMessage) { - validate(e.target.value); + const error = validate(e.target.value); + onValidation?.(!error && e.target.value.length > 0); } }; + const getInputType = () => { if (type === 'name') { return 'text'; @@ -59,10 +64,11 @@ function InputField({ } return type; }; + //스타일에 따른 클래스 const variantClass = { - containerVertical: 'mb-[24px] flex flex-col gap-[10px]', - containerHorizontal: 'mb-[24px] w-[239px] flex items-center gap-[10px]', + containerVertical: 'flex flex-col gap-[10px]', + containerHorizontal: 'w-[239px] flex items-center gap-[10px]', labelVertical: 'text-14 text-gray-500', labelHorizontal: 'text-14 text-gray-400 w-[60px] flex-shrink-0', base: 'px-[20px] py-[10px] h-[45px] w-[400px] rounded-md text-[14px] text-gray-500 placeholder:text-14 focus:outline-none mo:w-[355px]', @@ -71,6 +77,7 @@ function InputField({ 'bg-gray-100 focus:border-green-200 focus:ring-1 focus:ring-green-200', errorText: 'text-12 text-red-100', }; + const labelClass = layout === 'horizontal' ? variantClass.labelHorizontal diff --git a/components/TextEditor.tsx b/components/TextEditor.tsx new file mode 100644 index 0000000..d3e0be7 --- /dev/null +++ b/components/TextEditor.tsx @@ -0,0 +1,49 @@ +import dynamic from 'next/dynamic'; + +import 'react-quill-new/dist/quill.snow.css'; + +// ref: https://www.npmjs.com/package/react-quill-new +const QuillEditor = dynamic(() => import('react-quill-new'), { + ssr: false, + loading: () =>

편집기 불러오는 중...

, +}); + +interface Props { + value?: string; + onChange: (value: string) => void; +} + +/** + * 텍스트 에디터 컴포넌트 + * @param {object} props + * @param {string} props.value - 초기 값 + * @param {function} props.onChange - 값 변경시 콜백 함수 + */ +export default function TextEditor({ value = '', onChange }: Props) { + const modules = { + toolbar: { + container: [ + [{ header: [1, 2, 3, false] }], + ['bold', 'italic', 'underline'], + [{ align: null }, { align: 'center' }, { align: 'right' }], + [{ list: 'ordered' }, { list: 'bullet' }], + ['blockquote', 'link', 'image'], + ], + }, + }; + + const handleChange = (value: string) => { + onChange(value); + }; + + return ( + + ); +} diff --git a/package-lock.json b/package-lock.json index 23e59c5..54e1e40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,13 @@ "@next/eslint-plugin-next": "^15.1.0", "@tanstack/react-query": "^5.62.7", "axios": "^1.7.9", + "date-fns": "^4.1.0", "next": "^15.1.0", "postcss-nesting": "^13.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-day-picker": "^9.4.4", + "react-dom": "^19.0.0", + "react-quill-new": "^3.3.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -55,6 +58,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", @@ -2034,6 +2043,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2905,6 +2924,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2916,7 +2941,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/fast-glob": { @@ -4099,6 +4123,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4551,6 +4599,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5055,6 +5109,35 @@ ], "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -5064,6 +5147,26 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.4.4", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.4.4.tgz", + "integrity": "sha512-1s+jA/bFYtoxhhr8M0kkFHLiMTSII6qU8UfDFprRAUStTVHljLTjg4oarvAngPlQ1cQAC+LUb0k/qMc+jjhmxw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.2.0", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", @@ -5083,6 +5186,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-quill-new": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.3.3.tgz", + "integrity": "sha512-jxbm1QUJlkuGUpc9/GUgGw5USLHdp43H0M7AufqS3V+zRLng9uqLeVBGjXYqEbUKi8QVOM4SClSV3F7kVNj68w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "quill": "~2.0.2" + }, + "peerDependencies": { + "quill-delta": "^5.1.0", + "react": "^16 || ^17 || ^18 || ^19", + "react-dom": "^16 || ^17 || ^18 || ^19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 83b86a9..5c1ccdc 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "@next/eslint-plugin-next": "^15.1.0", "@tanstack/react-query": "^5.62.7", "axios": "^1.7.9", + "date-fns": "^4.1.0", "next": "^15.1.0", "postcss-nesting": "^13.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-day-picker": "^9.4.4", + "react-dom": "^19.0.0", + "react-quill-new": "^3.3.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/pages/signup/index.tsx b/pages/signup/index.tsx index 0e3432d..c3e4dcb 100644 --- a/pages/signup/index.tsx +++ b/pages/signup/index.tsx @@ -1,12 +1,23 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; import { useState } from 'react'; +import Button from '@/components/Button'; import InputField from '@/components/Input'; -const SignUp: React.FC = () => { +function SignUp(): React.ReactElement { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); const [name, setName] = useState(''); + const [validFields, setValidFields] = useState({ + name: false, + email: false, + password: false, + passwordConfirm: false, + }); + const router = useRouter(); const handleEmailChange = (e: React.ChangeEvent) => { setEmail(e.target.value); @@ -26,47 +37,99 @@ const SignUp: React.FC = () => { setName(e.target.value); }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - alert('가입이 완료되었습니다'); + const handleValidation = (field: string, isValid: boolean) => { + setValidFields((prev) => ({ + ...prev, + [field]: isValid, + })); }; - return ( -
- + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting || !isFormValid) return; - + setIsSubmitting(true); + try { + console.log('Form submitted:', { + email, + password, + passwordConfirm, + name, + }); + router.push('/login'); + } catch (error) { + console.error('회원가입 중 오류가 발생했습니다:', error); + } finally { + setIsSubmitting(false); + } + }; - + const isFormValid = Object.values(validFields).every(Boolean); - - + return ( +
+
+
+

+ 회원가입 +

+ handleValidation('name', isValid)} + /> + handleValidation('email', isValid)} + /> + handleValidation('password', isValid)} + /> + + handleValidation('passwordConfirm', isValid) + } + /> + +
+ 이미 회원이신가요?{' '} + + 로그인하기 + +
{' '} +
+
+
); -}; +} export default SignUp; diff --git a/pages/test/editor.tsx b/pages/test/editor.tsx new file mode 100644 index 0000000..63064b8 --- /dev/null +++ b/pages/test/editor.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; + +import TextEditor from '@/components/TextEditor'; + +const cellStyle = 'px-4 py-2'; +const trStyle = 'border-b'; + +export default function Editor() { + const [value, setValue] = useState(''); + + const handleChange = (v: string) => { + // console.log('value', v); + setValue(v); + }; + + return ( +
+ + + + + + + + + + + + + +
+ props + + example +
+
    +
  • value: string
  • +
  • onChange: (value: string) => void
  • +
+
+
+ +
+
+

+ 참고: 에디터의 크기는 에디터 부모의 100%가 적용되니 부모의 + 크기를 정의하시면 됩니다. +

+
+
+ ); +} diff --git a/pages/test/index.tsx b/pages/test/index.tsx index 2360eaf..3d338fc 100644 --- a/pages/test/index.tsx +++ b/pages/test/index.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; import LinkBar from '@/components/LinkBar'; @@ -59,12 +61,6 @@ export default function Test() { - - inputfield - - - - Button @@ -96,11 +92,6 @@ export default function Test() { LinkBar - - inputfield - - - diff --git a/styles/globals.css b/styles/globals.css index 5a6ef0a..647f854 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -51,3 +51,36 @@ body { color: var(--gray-500); background: var(--background); } + +/* quill editor custom style */ +.quill-custom { + @apply grid h-full w-full; + grid-template-rows: 1fr max-content; + + .ql-editor { + @apply p-0; + } + .ql-editor.ql-blank::before { + @apply left-0 not-italic text-gray-400; + } + .ql-container { + @apply overflow-y-auto font-sans text-16; + } + .ql-container.ql-snow { + @apply border-0; + } + .ql-toolbar.ql-snow { + @apply order-last rounded-full border-gray-200 text-gray-400; + } + .ql-snow .ql-stroke { + stroke: var(--gray-400); + } + .ql-snow .ql-fill, + .ql-snow .ql-stroke.ql-fill { + fill: var(--gray-400); + } + .ql-snow .ql-picker.ql-header .ql-picker-label::before, + .ql-snow .ql-picker.ql-header .ql-picker-item::before { + color: var(--gray-400); + } +}