diff --git a/frontend/src/components/Board/BoardActions.module.css b/frontend/src/components/Board/BoardActions.module.css index 8540f0d1..5296ab57 100644 --- a/frontend/src/components/Board/BoardActions.module.css +++ b/frontend/src/components/Board/BoardActions.module.css @@ -21,14 +21,15 @@ } .boardActions { - width: 100%; + width: 908px; max-width: calc(100% - 20px); - padding-right: 20px; box-sizing: border-box; display: flex; align-items: center; justify-content: flex-end; gap: var(--gap); + margin-left: auto; + margin-right: auto; margin-bottom: 32px; } diff --git a/frontend/src/components/Board/Modal.jsx b/frontend/src/components/Board/Modal.jsx index 5c01ff1c..e294189f 100644 --- a/frontend/src/components/Board/Modal.jsx +++ b/frontend/src/components/Board/Modal.jsx @@ -3,7 +3,30 @@ import FolderIcon from '../../assets/boardFolder.svg'; import CloseIcon from '../../assets/boardCloseIcon.svg'; import DropdownArrowIcon from '../../assets/boardSelectArrow.svg'; -const Modal = ({ title, setTitle, content, setContent, onSave, onClose }) => { +const Modal = ({ + title, + setTitle, + content, + setContent, + selectedFiles, + onFileChange, + onRemoveFile, + onSave, + onClose, +}) => { + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + + const droppedFiles = Array.from(e.dataTransfer.files); + onFileChange({ target: { files: droppedFiles } }); + }; + return (
e.stopPropagation()}> @@ -32,14 +55,18 @@ const Modal = ({ title, setTitle, content, setContent, onSave, onClose }) => {
document.getElementById('fileUpload').click()} + onDragOver={handleDragOver} + onDrop={handleDrop} > 폴더 - 파일추가 + 파일 추가
@@ -52,6 +79,28 @@ const Modal = ({ title, setTitle, content, setContent, onSave, onClose }) => {
+ {selectedFiles && selectedFiles.length > 0 && ( +
+ + {selectedFiles.map((file, index) => ( +
+ + {file.name} ({(file.size / 1024).toFixed(1)} KB) + + +
+ ))} +
+ )} +
@@ -66,7 +115,7 @@ const Modal = ({ title, setTitle, content, setContent, onSave, onClose }) => {
diff --git a/frontend/src/components/Board/Modal.module.css b/frontend/src/components/Board/Modal.module.css index 8047635a..447bc5c2 100644 --- a/frontend/src/components/Board/Modal.module.css +++ b/frontend/src/components/Board/Modal.module.css @@ -12,35 +12,44 @@ } .modal { - width: 906px; - height: 653px; - max-width: 90vw; + --spacing-xs: clamp(8px, 1vh, 12px); + --spacing-sm: clamp(12px, 2vh, 24px); + --spacing-md: clamp(24px, 4vh, 47px); + --spacing-lg: clamp(24px, 5vw, 52px); + + --font-sm: clamp(12px, 1.5vw, 14px); + --font-base: clamp(14px, 2vw, 16px); + --font-md: clamp(16px, 2.5vw, 20px); + --font-lg: clamp(18px, 3vw, 24px); + + --input-height: clamp(40px, 6vh, 48px); + --button-padding: clamp(10px, 1.5vw, 12px) clamp(18px, 3vw, 24px); + + width: clamp(320px, 90vw, 906px); max-height: 90vh; background: rgba(255, 255, 255, 1); border: 1px solid rgba(128, 128, 128, 1); border-radius: 20px; - padding: 52px; - gap: 47px; - opacity: 1; + padding: var(--spacing-lg); display: flex; flex-direction: column; position: relative; + overflow: hidden; /* ✨ auto → hidden으로 변경 (모서리 유지) */ } .header { width: 100%; - height: 35px; display: flex; justify-content: space-between; align-items: center; - opacity: 1; + margin-bottom: var(--spacing-md); + flex-shrink: 0; /* ✨ 헤더 고정 */ } .title { font-weight: 700; - font-size: 24px; + font-size: var(--font-lg); line-height: 146%; - letter-spacing: 0%; color: rgba(23, 23, 23, 1); margin: 0; } @@ -53,45 +62,70 @@ display: flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: clamp(16px, 2vw, 18px); + height: clamp(16px, 2vw, 18px); } .form { display: flex; flex-direction: column; - gap: 47px; - flex: 1; + gap: var(--spacing-md); + flex: 1; /* ✨ 0 1 auto → 1로 변경 (남은 공간 차지) */ + margin-bottom: var(--spacing-sm); + overflow-y: auto; /* ✨ 스크롤 추가 */ + overflow-x: hidden; + padding-right: 8px; /* ✨ 스크롤바 공간 */ +} + +/* ✨ 스크롤바 스타일 추가 */ +.form::-webkit-scrollbar { + width: 6px; +} + +.form::-webkit-scrollbar-track { + background: transparent; + margin: 4px 0; +} + +.form::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 3px; +} + +.form::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); } .titleField { width: 100%; - height: 89px; + display: flex; + flex-direction: column; } .label { - width: 40px; - height: 29px; font-weight: 700; - font-size: 20px; + font-size: var(--font-md); line-height: 146%; - letter-spacing: 0%; color: rgba(23, 23, 23, 1); - margin: 0 0 12px 0; + margin: 0 0 var(--spacing-xs) 0; display: block; - opacity: 1; } .input { width: 100%; - height: 48px; + height: var(--input-height); border-radius: 8px; border: 0.4px solid rgba(175, 175, 175, 1); background: rgba(255, 255, 255, 1); - padding: 16px 24px; + padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px); box-sizing: border-box; - opacity: 1; - font-size: 16px; + font-family: + 'Pretendard', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: var(--font-base); outline: none; } @@ -101,20 +135,20 @@ .contentField { width: 100%; - height: 203px; + display: flex; + flex-direction: column; } .contentContainer { width: 100%; - height: 162px; + min-height: clamp(120px, 20vh, 162px); border-radius: 8px; border: 0.4px solid rgba(175, 175, 175, 1); background: rgba(255, 255, 255, 1); - padding: 24px; + padding: clamp(16px, 3vh, 24px); box-sizing: border-box; display: flex; flex-direction: column; - opacity: 1; } .fileSection { @@ -123,27 +157,59 @@ gap: 8px; cursor: pointer; flex-shrink: 0; - margin-bottom: 12px; + margin-bottom: var(--spacing-xs); } .fileText { - width: 52px; - height: 14px; font-weight: 500; - font-size: 14px; + font-size: var(--font-sm); line-height: 100%; - letter-spacing: 0%; color: rgba(175, 175, 175, 1); - opacity: 1; flex-shrink: 0; } +/* 파일 목록 */ +.fileList { + margin-top: 12px; +} + +.fileItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: #f5f5f5; + border-radius: 4px; + margin-bottom: 6px; +} + +.fileName { + font-size: 14px; + color: #333; + flex: 1; + word-break: break-all; /* ✨ 긴 파일명 줄바꿈 */ +} + +.removeFileButton { + background: none; + border: none; + color: #999; + font-size: 16px; + cursor: pointer; + padding: 0 8px; + transition: color 0.2s; + flex-shrink: 0; /* ✨ 버튼 크기 고정 */ +} + +.removeFileButton:hover { + color: #ff4444; +} + .divider { width: 100%; height: 0px; border: 0.4px solid rgba(175, 175, 175, 1); - opacity: 1; - margin-bottom: 12px; + margin-bottom: var(--spacing-xs); flex-shrink: 0; } @@ -152,75 +218,83 @@ outline: none; resize: none; flex: 1; + font-family: + 'Pretendard', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; font-weight: 500; - font-size: 16px; - line-height: 100%; - letter-spacing: -3%; - color: rgba(170, 170, 170, 1); + font-size: var(--font-base); + line-height: 140%; + color: rgba(23, 23, 23, 1); background: transparent; - min-height: 50px; + min-height: clamp(40px, 8vh, 50px); } .textarea::placeholder { font-weight: 500; - font-size: 16px; - line-height: 100%; - letter-spacing: -3%; + font-size: var(--font-base); + line-height: 140%; color: rgba(170, 170, 170, 1); } .accessField { - width: 283px; - height: 81px; + width: 100%; + max-width: clamp(200px, 40vw, 283px); + display: flex; + flex-direction: column; } .accessLabel { - width: 74px; - height: 29px; font-weight: 700; - font-size: 20px; + font-size: var(--font-md); line-height: 146%; - letter-spacing: 0%; color: rgba(23, 23, 23, 1); - margin: 0 0 12px 0; - opacity: 1; + margin: 0 0 var(--spacing-xs) 0; display: block; } .selectWrapper { position: relative; - width: 283px; + width: 100%; display: flex; align-items: center; } .select { width: 100%; - height: 40px; + height: clamp(36px, 5vh, 40px); border-radius: 8px; border: 0.8px solid rgba(175, 175, 175, 1); background: rgba(255, 255, 255, 1); - padding: 12px 20px; - padding-right: 40px; + padding: clamp(8px, 1.5vh, 12px) clamp(16px, 2.5vw, 20px); + padding-right: clamp(32px, 5vw, 40px); box-sizing: border-box; - opacity: 1; + font-family: + 'Pretendard', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; font-weight: 400; - font-size: 16px; + font-size: var(--font-base); line-height: 100%; - letter-spacing: -3%; color: rgba(23, 23, 23, 1); -webkit-appearance: none; -moz-appearance: none; appearance: none; + cursor: pointer; } .selectIcon { position: absolute; - right: 16px; - top: 10px; + right: clamp(12px, 2vw, 16px); + top: 50%; + transform: translateY(-50%); pointer-events: none; - width: 10px; - height: 16px; + width: clamp(8px, 1.2vw, 10px); + height: clamp(12px, 2vw, 16px); } .select:focus { @@ -230,25 +304,30 @@ .select option { font-weight: 400; - font-size: 16px; + font-size: var(--font-base); color: rgba(23, 23, 23, 1); background: rgba(255, 255, 255, 1); } .saveButton { - padding: 12px 24px; + padding: var(--button-padding); border: none; border-radius: 8px; font-weight: 500; - font-size: 16px; + font-size: var(--font-base); cursor: pointer; - background: rgba(23, 23, 23, 1); + background: rgba(29, 128, 244, 1); color: rgba(255, 255, 255, 1); - margin-left: auto; - margin-top: auto; + align-self: flex-end; + flex-shrink: 0; /* ✨ 버튼 고정 */ + transition: all 0.2s; } .saveButton:hover { - background: rgba(50, 50, 50, 1); + background: rgba(24, 102, 195, 1); transform: translateY(-1px); } + +.saveButton:active { + transform: translateY(0); +} diff --git a/frontend/src/components/Board/PostItem.jsx b/frontend/src/components/Board/PostItem.jsx index 182f1d3f..d94eaf51 100644 --- a/frontend/src/components/Board/PostItem.jsx +++ b/frontend/src/components/Board/PostItem.jsx @@ -8,11 +8,12 @@ import HeartIcon from '../../assets/boardHeart.svg'; import HeartFilledIcon from '../../assets/boardHeart.fill.svg'; import { getTimeAgo } from '../../utils/TimeUtils'; -const PostItem = React.memo(({ post, onLike, onBookmark }) => { +const PostItem = React.memo(({ post, onLike, onBookmark, currentTeam }) => { const navigate = useNavigate(); const handleCardClick = () => { - navigate(`/board/${post.id}`, { state: { post } }); + const team = currentTeam || 'all'; + navigate(`/board/${team}/${post.id}`, { state: { post } }); }; const handleActionClick = (e) => { @@ -20,11 +21,7 @@ const PostItem = React.memo(({ post, onLike, onBookmark }) => { }; return ( -
+
프로필 @@ -72,6 +69,4 @@ const PostItem = React.memo(({ post, onLike, onBookmark }) => { ); }); -PostItem.displayName = 'PostItem'; - export default PostItem; diff --git a/frontend/src/components/Board/PostItem.module.css b/frontend/src/components/Board/PostItem.module.css index 9ea3d85e..7a40075c 100644 --- a/frontend/src/components/Board/PostItem.module.css +++ b/frontend/src/components/Board/PostItem.module.css @@ -6,6 +6,7 @@ margin-bottom: 16px; box-sizing: border-box; position: relative; + cursor: pointer; } .mainContent { diff --git a/frontend/src/pages/Board.jsx b/frontend/src/pages/Board.jsx index d3c4e3e6..b113c7c6 100644 --- a/frontend/src/pages/Board.jsx +++ b/frontend/src/pages/Board.jsx @@ -4,8 +4,11 @@ import Modal from '../components/Board/Modal'; import SearchBar from '../components/Board/SearchBar'; import BoardActions from '../components/Board/BoardActions'; import styles from './Board.module.css'; +import { useParams } from 'react-router-dom'; const Board = () => { + const { team } = useParams(); + const [posts, setPosts] = useState(() => { const saved = localStorage.getItem('boardPosts'); if (saved) { @@ -23,6 +26,7 @@ const Board = () => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [sortOption, setSortOption] = useState('latest'); + const [selectedFiles, setSelectedFiles] = useState([]); useEffect(() => { localStorage.setItem('boardPosts', JSON.stringify(posts)); @@ -38,6 +42,56 @@ const Board = () => { setContent(''); }; + // 파일 선택 핸들러 + const handleFileChange = (e) => { + const newFiles = Array.from(e.target.files); + + setSelectedFiles((prevFiles) => { + const updatedFiles = [...prevFiles]; + const replacedFileNames = []; + + newFiles.forEach((newFile) => { + const sameNameIndex = updatedFiles.findIndex( + (f) => f.name === newFile.name + ); + + if (sameNameIndex !== -1) { + // 같은 이름 발견 + if (updatedFiles[sameNameIndex].size === newFile.size) { + // 같은 크기 = 완전 중복 (무시) + return; + } else { + // 다른 크기 = 교체 + updatedFiles[sameNameIndex] = newFile; + replacedFileNames.push(newFile.name); + return; + } + } + + // 새 파일 추가 + updatedFiles.push(newFile); + }); + + // 교체된 파일이 있으면 알림 + if (replacedFileNames.length > 0) { + alert(`교체됨: ${replacedFileNames.join(', ')}`); + } + + return updatedFiles; + }); + + if (e.target.value !== undefined) { + e.target.value = ''; + } + }; + + // 파일 삭제 핸들러 + const handleRemoveFile = (indexToRemove) => { + setSelectedFiles((prevFiles) => + prevFiles.filter((_, index) => index !== indexToRemove) + ); + }; + const handleSave = () => { const newPost = { title, @@ -46,7 +100,13 @@ const Board = () => { id: Date.now(), likeCount: 0, isLiked: false, + bookmarkCount: 0, isBookmarked: false, + files: selectedFiles.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + })), }; setPosts([newPost, ...posts]); handleCloseModal(); @@ -70,7 +130,13 @@ const Board = () => { setPosts( posts.map((post) => post.id === postId - ? { ...post, isBookmarked: !post.isBookmarked } + ? { + ...post, + isBookmarked: !post.isBookmarked, + bookmarkCount: post.isBookmarked + ? (post.bookmarkCount || 1) - 1 + : (post.bookmarkCount || 0) + 1, + } : post ) ); @@ -119,6 +185,7 @@ const Board = () => { @@ -134,6 +201,9 @@ const Board = () => { setTitle={setTitle} content={content} setContent={setContent} + selectedFiles={selectedFiles} + onFileChange={handleFileChange} + onRemoveFile={handleRemoveFile} onSave={handleSave} onClose={handleCloseModal} /> diff --git a/frontend/src/pages/PostDetail.jsx b/frontend/src/pages/PostDetail.jsx index 0a82b7b3..80e9d5d6 100644 --- a/frontend/src/pages/PostDetail.jsx +++ b/frontend/src/pages/PostDetail.jsx @@ -9,15 +9,22 @@ import HeartIcon from '../assets/boardHeart.svg'; import HeartFilledIcon from '../assets/boardHeart.fill.svg'; import EditIcon from '../assets/boardPencil.svg'; import DeleteIcon from '../assets/boardCloseIcon.svg'; +import FolderIcon from '../assets/boardFolder.svg'; import { getTimeAgo } from '../utils/TimeUtils'; const PostDetail = () => { - const { id } = useParams(); + const { postId, team } = useParams(); const navigate = useNavigate(); const location = useLocation(); const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); + const [isEdit, setIsEdit] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editContent, setEditContent] = useState(''); + const [editFiles, setEditFiles] = useState([]); + const [newFiles, setNewFiles] = useState([]); + const [comments, setComments] = useState([]); const [commentText, setCommentText] = useState(''); const [showMenu, setShowMenu] = useState(false); @@ -41,7 +48,6 @@ const PostDetail = () => { } catch (error) { if (error.name === 'QuotaExceededError') { console.error('localStorage 용량이 부족합니다.'); - // 사용자에게 알림 표시 또는 오래된 데이터 정리 } throw error; } @@ -70,17 +76,22 @@ const PostDetail = () => { if (!currentPost) { const allPosts = getPostsFromStorage(); - currentPost = allPosts.find((p) => p.id === parseInt(id, 10)); + currentPost = allPosts.find((p) => p.id === parseInt(postId, 10)); } setPost(currentPost); setLoading(false); + if (currentPost) { + setEditTitle(currentPost.title); + setEditContent(currentPost.content); + } + if (currentPost) { setComments(getCommentsFromStorage(currentPost.id)); } else { setComments([]); } - }, [id, location.state]); + }, [postId, location.state]); // 좋아요 토글 const handleLike = () => { @@ -107,7 +118,13 @@ const PostDetail = () => { let updatedPost = null; const updatedPosts = allPosts.map((p) => { if (p.id === post.id) { - updatedPost = { ...p, isBookmarked: !p.isBookmarked }; + updatedPost = { + ...p, + isBookmarked: !p.isBookmarked, + bookmarkCount: p.isBookmarked + ? (p.bookmarkCount || 1) - 1 + : (p.bookmarkCount || 0) + 1, + }; return updatedPost; } return p; @@ -116,35 +133,83 @@ const PostDetail = () => { setPost(updatedPost); }; - // 게시글 삭제 - const handleDelete = () => { - if (window.confirm('게시글을 정말 삭제하시겠습니까?')) { - const allPosts = getPostsFromStorage(); - const updatedPosts = allPosts.filter((p) => p.id !== post.id); - savePostsToStorage(updatedPosts); - navigate('/board'); - } + // 수정 모드 진입 + const handleEdit = () => { + setIsEdit(true); + setShowMenu(false); + setEditFiles(post.files || []); + setNewFiles([]); }; - // 게시글 수정 - const handleUpdate = () => { - setShowMenu(false); - const newTitle = prompt('수정할 제목을 입력하세요:', post.title); - if (newTitle === null || newTitle.trim() === '') return; - const newContent = prompt('수정할 내용을 입력하세요:', post.content); - if (newContent === null) return; + // 수정 취소 + const handleCancelEdit = () => { + setIsEdit(false); + setEditTitle(post.title); + setEditContent(post.content); + }; + + // 기존 파일 삭제 + const handleRemoveExistingFile = (index) => { + setEditFiles(editFiles.filter((_, i) => i !== index)); + }; + + // 새 파일 추가 + const handleAddNewFile = (e) => { + const files = Array.from(e.target.files); + setNewFiles([...newFiles, ...files]); + e.target.value = ''; + }; + + // 새 파일 삭제 + const handleRemoveNewFile = (index) => { + setNewFiles(newFiles.filter((_, i) => i !== index)); + }; + + // 수정 저장 + const handleSaveEdit = () => { + if (!editTitle.trim() || !editContent.trim()) { + alert('제목과 내용을 입력해주세요.'); + return; + } + + // 기존 파일 + 새 파일 정보 합치기 + const allFiles = [ + ...editFiles, + ...newFiles.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + })), + ]; const allPosts = getPostsFromStorage(); let updatedPost = null; const updatedPosts = allPosts.map((p) => { if (p.id === post.id) { - updatedPost = { ...p, title: newTitle, content: newContent }; + updatedPost = { + ...p, + title: editTitle, + content: editContent, + files: allFiles, + }; return updatedPost; } return p; }); savePostsToStorage(updatedPosts); setPost(updatedPost); + setIsEdit(false); + setNewFiles([]); + }; + + // 게시글 삭제 + const handleDelete = () => { + if (window.confirm('게시글을 정말 삭제하시겠습니까?')) { + const allPosts = getPostsFromStorage(); + const updatedPosts = allPosts.filter((p) => p.id !== post.id); + savePostsToStorage(updatedPosts); + navigate(`/board/${team || 'all'}`); + } }; // 댓글 추가 @@ -210,36 +275,49 @@ const PostDetail = () => {
-

{post.title}

-
- - {showMenu && ( -
- - -
- )} -
+ {isEdit ? ( + setEditTitle(e.target.value)} + placeholder="제목을 입력하세요" + /> + ) : ( +

{post.title}

+ )} + + {!isEdit && ( +
+ + {showMenu && ( +
+ + +
+ )} +
+ )}
@@ -250,115 +328,249 @@ const PostDetail = () => {

{getTimeAgo(post.date)}

-
{post.content}
-
-
-
- - +
+ ))} +
+ )} + + {/* 새로 추가된 파일 목록 */} + {isEdit && newFiles.length > 0 && ( +
+ {newFiles.map((file, index) => ( +
+ 파일 + + {file.name}{' '} + 새파일 + + + ({(file.size / 1024).toFixed(1)} KB) + + +
+ ))} +
+ )} + + {/* 파일 추가 버튼 */} + {isEdit && ( +
+ - {post.likeCount > 0 && ( - {post.likeCount} - )} - -
-
-

- 댓글 {comments.length} -

-
+ +
+ )} + + {/* 일반 모드: 파일 목록만 표시 */} + {!isEdit && post.files && post.files.length > 0 && ( +
+ {post.files.map((file, index) => ( +
+ 파일 + {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +
+ ))} +
+ )}
+ )} -
-