diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b7a809..0416864 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import PageLayout from './layouts/pageLayout' import MyPage from './pages/MyPage' import PostPage from './pages/PostPage' import MainPage from './pages/MainPage' +import SearchPage from './pages/SearchPage' + function App() { return ( @@ -32,6 +34,10 @@ function App() { element={} /> } + /> + } /> diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 363eb7a..9c0b6f2 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -47,7 +47,12 @@ const NavigationBar: React.FC = () => { // 내 정보 페이지 이동 const handleProfile = () => { - navigate('/profile') // 프로필 페이지로 이동 + navigate('/profile') + } + + // 게시물 작성 네비게이션 + const handleWriting = () => { + navigate('/write') } // 카테고리 배열 @@ -97,7 +102,9 @@ const NavigationBar: React.FC = () => { onClick={() => navigate('/search')} /> - {loggedIn ? ( + {' '} + {/*loggedIn ! 추후에 수정 */} + {!loggedIn ? ( { 내 정보 + + 게시물 작성 + + 로그아웃 diff --git a/frontend/src/components/PostCard.tsx b/frontend/src/components/PostCard.tsx index 97d7d51..027bce2 100644 --- a/frontend/src/components/PostCard.tsx +++ b/frontend/src/components/PostCard.tsx @@ -1,68 +1,72 @@ -import styled from 'styled-components'; +import styled from 'styled-components' interface CardProps { - category: string; - title: string; - author: string; - date: string; - text: string; - image_url: string; + category: string + title: string + author: string + date: string + text: string + imgUrl: string + } const Card = styled.div` display: flex; padding: 16px; - margin: 0 50px; + margin: 0 50px; margin-bottom: 16px; background-color: #fff; -`; +` const PostImage = styled.img` - width: 200px; + width: 150px; + height: 150px; height: auto; margin-right: 20px; -`; +` const PostContent = styled.div` display: flex; flex-direction: column; -`; +` const PostCategory = styled.p` font-size: 20px; color: #7d7d7d; margin: 0; font-weight: bold; -`; +` const PostTitle = styled.h2` font-size: 33px; margin: 8px 0; -`; +` const PostAuthor = styled.p` font-size: 16px; color: #7d7d7d; margin: 0; -`; +` const PostDate = styled.p` font-size: 16px; color: #7d7d7d; margin: 4px 0; -`; +` const PostText = styled.p` font-size: 1rem; - color: #1C1C1C; + color: #1c1c1c; margin-top: 8px; -`; - +` function PostCard({ title, author, date, text, category, image_url }: CardProps) { return ( - + {category} {title} @@ -71,7 +75,7 @@ function PostCard({ title, author, date, text, category, image_url }: CardProps) {text} - ); + ) } -export default PostCard; +export default PostCard diff --git a/frontend/src/components/SearchPageInput/index.tsx b/frontend/src/components/SearchPageInput/index.tsx new file mode 100644 index 0000000..a7c2178 --- /dev/null +++ b/frontend/src/components/SearchPageInput/index.tsx @@ -0,0 +1,22 @@ +import { SearchOutlined } from '@ant-design/icons' +import { SearchPageInputContainer } from './style' + +const SearchPageInput = () => { + return ( + + + + + ) +} + +export default SearchPageInput diff --git a/frontend/src/components/SearchPageInput/style.tsx b/frontend/src/components/SearchPageInput/style.tsx new file mode 100644 index 0000000..ec4dfc8 --- /dev/null +++ b/frontend/src/components/SearchPageInput/style.tsx @@ -0,0 +1,36 @@ +import styled from 'styled-components' + +export const SearchPageInputContainer = styled.div` + display: flex; + align-items: center; + width: 100%; + border-bottom: 1.5px solid rgb(176, 184, 193); + + & button { + border: none; + background-color: transparent; + } + + & input { + font-family: 'Noto Sans KR', sans-serif !important; + width: 100%; + height: 4rem; + border: none; + background-color: transparent; + padding-left: 1rem; + font-size: 1.3rem; + font-weight: 500; + } + + & input::placeholder { + color: #7d7d7d; + } + + & input:focus { + outline: none; + } + + &:focus-within { + border-bottom: 1.5px solid #1c1c1c; + } +` diff --git a/frontend/src/components/SearchPageNav/index.tsx b/frontend/src/components/SearchPageNav/index.tsx new file mode 100644 index 0000000..ebdf2b2 --- /dev/null +++ b/frontend/src/components/SearchPageNav/index.tsx @@ -0,0 +1,29 @@ +import { SearchPageNavContainer, SearchPageNavItem } from './style' + +const SearchPageNav = () => { + const categories = [ + '전체', + '한식', + '중식', + '일식', + '양식', + '동남아 요리', + '남미 요리', + '중동 요리', + '퓨전 요리', + '채식 요리', + '해산물 요리', + '바베큐 요리', + '디저트 요리', + ] + + return ( + + {categories.map((category) => ( + {category} + ))} + + ) +} + +export default SearchPageNav diff --git a/frontend/src/components/SearchPageNav/style.ts b/frontend/src/components/SearchPageNav/style.ts new file mode 100644 index 0000000..303d099 --- /dev/null +++ b/frontend/src/components/SearchPageNav/style.ts @@ -0,0 +1,28 @@ +import styled from 'styled-components' + +export const SearchPageNavContainer = styled.ul` + font-family: 'Noto Sans KR', sans-serif; +` +export const SearchPageNavItem = styled.a` + display: block; + width: 100%; + margin-bottom: 1rem; + list-style: none; + padding: 1rem 4rem 1rem 1rem; + font-size: 1.1rem; + font-weight: 600; + border-radius: 0.7rem; + color: #7d7d7d; + letter-spacing: -0.05rem; + cursor: pointer; + + &:hover { + background-color: #f0f0f0; + color: #1c1c1c; + } + + &:active { + background-color: #e0e0e0; + color: #1c1c1c; + } +` diff --git a/frontend/src/components/DraftEditor.tsx b/frontend/src/components/WritingPageComponents/DraftEditor.tsx similarity index 58% rename from frontend/src/components/DraftEditor.tsx rename to frontend/src/components/WritingPageComponents/DraftEditor.tsx index 9b30399..a4ec7ee 100644 --- a/frontend/src/components/DraftEditor.tsx +++ b/frontend/src/components/WritingPageComponents/DraftEditor.tsx @@ -13,22 +13,6 @@ const DraftEditor: React.FC = ({ editorState, setEditorState, }) => { - // 이미지 업로드 콜백 - const uploadImageCallBack = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => { - resolve({ - data: { link: reader.result as string }, - }) - } - reader.onerror = (error) => { - reject(error) - } - reader.readAsDataURL(file) // 파일을 Base64로 변환 - }) - } - return ( @@ -44,18 +28,7 @@ const DraftEditor: React.FC = ({ 'textAlign', 'link', 'history', - 'image', - ], - image: { - uploadCallback: uploadImageCallBack, - previewImage: true, - alt: { present: true, mandatory: false }, - inputAccept: 'image/gif,image/jpeg,image/jpg,image/png,image/svg', - defaultSize: { - height: 'auto', - width: 'auto', - }, - }, + ], // 이미지 옵션 제거 }} wrapperClassName='wrapper-class' editorClassName='editor-class' @@ -70,13 +43,12 @@ const DraftEditor: React.FC = ({ const EditorContainer = styled.div` display: flex; flex-direction: column; - align-items: center; - width: 80%; + /* align-items: flex-end; */ margin: 0 auto; ` const EditorWrapper = styled.div` - width: 100%; + width: auto; min-height: 300px; margin-bottom: 20px; border: 1px solid #ddd; diff --git a/frontend/src/components/WritingPageComponents/ImageUploader.tsx b/frontend/src/components/WritingPageComponents/ImageUploader.tsx new file mode 100644 index 0000000..ab5cb5b --- /dev/null +++ b/frontend/src/components/WritingPageComponents/ImageUploader.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react' +import { Upload, Button, message } from 'antd' +import { UploadOutlined } from '@ant-design/icons' +import styled from 'styled-components' +import axios from 'axios' + +interface ImageUploaderProps { + onUploadSuccess: (url: string) => void +} + +const ImageUploader: React.FC = ({ onUploadSuccess }) => { + const [loading, setLoading] = useState(false) + + // 이미지 확장자를 체크하는 함수 + const beforeUpload = (file: File) => { + const isImage = + file.type === 'image/jpeg' || + file.type === 'image/png' || + file.type === 'image/gif' + if (!isImage) { + message.error('JPG/PNG/GIF 파일만 업로드할 수 있습니다.') + } + return isImage + } + + // customRequest 함수 구현 + const handleCustomRequest = async (options: any) => { + const { file, onSuccess, onError } = options + setLoading(true) + const formData = new FormData() + formData.append('file', file) + formData.append('upload_preset', 'ml_default') // Cloudinary 업로드 프리셋 + + try { + const response = await axios.post( + 'https://api.cloudinary.com/v1_1/dee7rlglp/image/upload', + formData, + ) + const imageUrl = response.data.secure_url + onUploadSuccess(imageUrl) + onSuccess('ok') // 성공 처리 + } catch (error) { + console.error('Upload error:', error) + onError({ error }) // 실패 처리 + } finally { + setLoading(false) + } + } + + return ( + + + } + loading={loading} + > + 파일 선택 + + + + ) +} + +// Styled Components +const UploadContainer = styled.div` + display: flex; + justify-content: flex-end; /* 오른쪽 정렬 */ + margin-top: 20px; +` + +const StyledButton = styled(Button)` + border: 1px solid black; + color: #000000; + &:hover { + background-color: #40a9ff; + } +` + +export default ImageUploader diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e62c9c9..edfa1d4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,9 +6,9 @@ import { queryClient } from './apis/api.ts' import './index.css' createRoot(document.getElementById('root')!).render( - - - - - , + // + + + , + // , ) diff --git a/frontend/src/pages/LogInPage.tsx b/frontend/src/pages/LogInPage.tsx index 3fe02b1..e5f0c20 100644 --- a/frontend/src/pages/LogInPage.tsx +++ b/frontend/src/pages/LogInPage.tsx @@ -1,29 +1,114 @@ -import React from 'react' +import { useState, useEffect } from 'react' import styled from 'styled-components' -import { Input, Button } from 'antd' +import { Input, Button, message } from 'antd' import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' +import axios from 'axios' const LoginPage = () => { + const [id, setId] = useState('') + const [password, setPassword] = useState('') + const [errors, setErrors] = useState({ + id: '', + password: '', + }) + const [isButtonDisabled, setIsButtonDisabled] = useState(true) + const [isSubmitted, setIsSubmitted] = useState(false) // 버튼 클릭 여부 상태 추가 + const navigate = useNavigate() + + // 유효성 검사 함수 + const validateForm = () => { + const newErrors = { id: '', password: '' } + const idRegex = /^\S+$/ // 공백이 없는 문자열 + + // 아이디 유효성 검사 + if (!id || !idRegex.test(id)) { + newErrors.id = '유효한 아이디를 입력해주세요.' + } + + // 비밀번호 유효성 검사 + const passwordRegex = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,16}$/ + if (!password || !passwordRegex.test(password)) { + newErrors.password = + '비밀번호는 영문, 숫자, 특수문자를 포함하여 8~16자여야 합니다.' + } + + setErrors(newErrors) + + // 모든 폼이 유효한지 체크 + return !Object.values(newErrors).some((error) => error !== '') + } + + // 버튼 활성화 상태 관리 + useEffect(() => { + const isFormFilled = id !== '' && password !== '' + setIsButtonDisabled(!isFormFilled) + }, [id, password]) + + // 버튼 클릭 이벤트 + const handleLogIn = async () => { + setIsSubmitted(true) // 버튼이 클릭되었음을 저장 + if (validateForm()) { + try { + // 로그인 POST 요청 보내기 + const response = await axios.post('https://your-server.com/login', { + id, + password, + }) + + const { user, token } = response.data + + // JWT 토큰을 로컬 스토리지에 저장 + localStorage.setItem('token', token) + + // 로그인 성공 메시지 + message.success('로그인 성공!') + + // 메인 페이지로 네비게이션 + navigate('/') + } catch (error) { + message.error('로그인 중 문제가 발생했습니다.') + } + } else { + message.error('입력한 정보를 확인해주세요.') + } + } + return ( 로그인 + {/* ID 폼 */} } + onChange={(e) => setId(e.target.value)} /> + {isSubmitted && errors.id && {errors.id}} + + {/* PW 폼 */} } + onChange={(e) => setPassword(e.target.value)} /> - 회원가입하기 + {isSubmitted && errors.password && ( + {errors.password} + )} + + navigate('/')}>회원가입하기 + + {/* 버튼 */} - Login + 로그인 ) @@ -69,4 +154,10 @@ const StyledButton = styled(Button)` border-radius: 10px; ` +const ErrorText = styled.p` + color: red; + font-size: 12px; + margin-bottom: 20px; +` + export default LoginPage diff --git a/frontend/src/pages/SearchPage/index.tsx b/frontend/src/pages/SearchPage/index.tsx new file mode 100644 index 0000000..ffbbf42 --- /dev/null +++ b/frontend/src/pages/SearchPage/index.tsx @@ -0,0 +1,35 @@ +import SearchPageInput from '@/components/SearchPageInput' +import { + SearchPageContainer, + SearchPageResult, + SearchPageResultContainer, +} from './style' +import SearchPageNav from '@/components/SearchPageNav' +import { mockPosts } from '@/utils/mockPosts' +import PostCard from '@/components/PostCard' + +const SearchPage = () => { + return ( + + + + + + {mockPosts.map(({ author, category, date, imgUrl, text, title }) => ( + + ))} + + + + ) +} + +export default SearchPage diff --git a/frontend/src/pages/SearchPage/style.ts b/frontend/src/pages/SearchPage/style.ts new file mode 100644 index 0000000..5cf1474 --- /dev/null +++ b/frontend/src/pages/SearchPage/style.ts @@ -0,0 +1,22 @@ +import styled from 'styled-components' + +export const SearchPageContainer = styled.div` + font-family: 'Noto Sans KR', sans-serif; + display: flex; + padding-block: 2rem; + flex-direction: column; + align-items: center; + justify-content: center; + width: 60%; + gap: 2rem; +` + +export const SearchPageResultContainer = styled.div` + display: flex; + justify-content: space-between; + width: 100%; +` + +export const SearchPageResult = styled.div` + width: 80%; +` diff --git a/frontend/src/pages/SignInPage.tsx b/frontend/src/pages/SignInPage.tsx index f2bf7ee..d2adce2 100644 --- a/frontend/src/pages/SignInPage.tsx +++ b/frontend/src/pages/SignInPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { Input, Button, message } from 'antd' import { UserOutlined, LockOutlined } from '@ant-design/icons' import styled from 'styled-components' @@ -10,12 +10,13 @@ const SignUpPage = () => { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [errors, setErrors] = useState({ - id: '', // email을 id로 변경 + id: '', password: '', confirmPassword: '', }) const [isButtonDisabled, setIsButtonDisabled] = useState(true) - // const navigate = useNavigate() + const [isSubmitted, setIsSubmitted] = useState(false) // 버튼 클릭 여부 상태 추가 + const navigate = useNavigate() // 유효성 검사 함수 const validateForm = () => { @@ -46,48 +47,41 @@ const SignUpPage = () => { return !Object.values(newErrors).some((error) => error !== '') } - // 입력값이 변경될 때마다 유효성 검사 실행 + // 버튼 활성화 상태 관리 useEffect(() => { - const isValid = validateForm() - setIsButtonDisabled(!isValid) + const isFormFilled = id !== '' && password !== '' && confirmPassword !== '' + setIsButtonDisabled(!isFormFilled) }, [id, password, confirmPassword]) - // 회원가입 버튼 클릭 이벤트 + // 버튼 클릭 이벤트 const handleSignUp = async () => { + setIsSubmitted(true) // 버튼이 클릭되었음을 저장 if (validateForm()) { try { - const response = await axios.get('./userMockUp.json') - const users = response.data.users - - // 동일한 id가 있는지 확인 - const existingUser = users.find((user) => user.id === id) - if (existingUser) { - message.error('이미 존재하는 사용자입니다.') - } else { - // 새로운 사용자 추가 - const newUser = { - id, - password, - token: 'newToken123', // 실제로는 서버에서 생성된 토큰이 필요 - } - - // 로그인된 상태로 JWT 토큰을 로컬 스토리지에 저장 - localStorage.setItem('token', newUser.token) - - // 회원가입 성공 메시지 - message.success('회원가입 성공!') - - // 메인 페이지로 네비게이션 - // navigate('/') - } + // 회원가입 POST 요청 보내기 + const response = await axios.post('https://your-server.com/signup', { + id, + password, + }) + + const { user, token } = response.data + + // 로그인된 상태로 JWT 토큰을 로컬 스토리지에 저장 + localStorage.setItem('token', token) + + // 회원가입 성공 메시지 + message.success('회원가입 성공!') + + // 메인 페이지로 네비게이션 + navigate('/') } catch (error) { - console.error('회원가입 중 오류 발생:', error) message.error('회원가입 중 문제가 발생했습니다.') } } else { message.error('입력한 정보를 확인해주세요.') } } + return ( 회원가입 @@ -99,7 +93,8 @@ const SignUpPage = () => { onChange={(e) => setId(e.target.value)} prefix={} /> - {errors.id && {errors.id}} + {isSubmitted && errors.id && {errors.id}} + {/* 비밀번호 폼 */} { onChange={(e) => setPassword(e.target.value)} prefix={} /> - {errors.password && {errors.password}} + {isSubmitted && errors.password && ( + {errors.password} + )} + {/* 비밀번호 확인 폼 */} { onChange={(e) => setConfirmPassword(e.target.value)} prefix={} /> - {errors.confirmPassword && ( + {isSubmitted && errors.confirmPassword && ( {errors.confirmPassword} )} + + {/* 버튼 */} { const navigate = useNavigate() - const [category, setCategory] = useState('') const [title, setTitle] = useState('') + const [category, setCategory] = useState('') const [editorState, setEditorState] = useState(EditorState.createEmpty()) + const [imageUrl, setImageUrl] = useState(null) // 카테고리 토글 const handleCategoryChange = (value: unknown) => { setCategory(value as string) // value를 string으로 변환 } - // 게시글 등록 버튼 클릭 시 유효성 검사 - const handleSubmit = () => { - const content = editorState.getCurrentContent() - const plainText = content.getPlainText().trim() // 공백 제거한 텍스트 + // 버튼 클릭 -> 유효성 검사 및 POST 요청 + const handleSubmit = async () => { + const contentState = editorState.getCurrentContent() // ContentState 객체 가져오기 + const rawContentState = convertToRaw(contentState) // ContentState를 RawDraftContentState로 변환 + const htmlContent = draftToHtml(rawContentState) // 변환된 RawDraftContentState를 HTML로 변환 - if (!category || !title || !plainText) { + // 필드 검증 + if (!category || !title || !htmlContent.trim()) { message.error('모든 필드를 입력해주세요.') } else { - message.success('게시글이 성공적으로 등록되었습니다.') + try { + const response = await axios.post('/api/post', { + title: title, + content: htmlContent, + category: category, + image_url: imageUrl || 'http://example.com/image.jpg', + }) + + if (response.status === 201) { + message.success('게시글이 성공적으로 등록되었습니다.') + navigate('/') // 요청 성공 후 홈으로 이동 + } + } catch (error) { + message.error('게시글 등록에 실패했습니다.') + } } } @@ -49,6 +69,11 @@ const WritingPage: React.FC = () => { '디저트 요리', ] + // 이미지 업로드 성공 핸들러 + const handleImageUploadSuccess = (url: string) => { + setImageUrl(url) + } + return ( { onChange={(e) => setTitle(e.target.value)} /> - {/* 에디터 컴포넌트 */} - - + {/* 이미지 미리보기 */} + {imageUrl && ( + + )} + + {/* 이미지 업로더 컴포넌트, 에디터 컴포넌트 컨테이너 */} + + + + + + + + + + + {/* 버튼 컨테이너 */} path.replace(/^\/api/, ''), // 경로 재작성 (필요에 따라 수정) + target: 'http://localhost:3000', + changeOrigin: true, // CORS 문제를 우회 + rewrite: (path) => path.replace(/^\/api/, ''), // 경로 재작성 }, }, },