diff --git a/.gitignore b/.gitignore
index 362bb45b..6b86470f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@ __pycache__/
# ===== Backend =====
backend/src/main/java/org/sejongisc/backend/stock/TestController.java
+
+# ===== windows =====
+.desktop.ini
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f08df15c..17da1d44 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -4,6 +4,7 @@ import Layout from './components/Layout';
import Home from './pages/Home';
import Attendance from './pages/Attendance';
import Board from './pages/Board';
+import PostDetail from './pages/PostDetail';
import StockGame from './pages/StockGame';
import BackTest from './pages/BackTest';
import Mypage from './pages/Mypage';
@@ -23,6 +24,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/assets/boardBookMark.fill.svg b/frontend/src/assets/boardBookMark.fill.svg
new file mode 100644
index 00000000..a6fe9c82
--- /dev/null
+++ b/frontend/src/assets/boardBookMark.fill.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardBookMark.svg b/frontend/src/assets/boardBookMark.svg
new file mode 100644
index 00000000..9d7b7233
--- /dev/null
+++ b/frontend/src/assets/boardBookMark.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardCloseIcon.svg b/frontend/src/assets/boardCloseIcon.svg
new file mode 100644
index 00000000..29bfc4a8
--- /dev/null
+++ b/frontend/src/assets/boardCloseIcon.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardFolder.svg b/frontend/src/assets/boardFolder.svg
new file mode 100644
index 00000000..2984e0b5
--- /dev/null
+++ b/frontend/src/assets/boardFolder.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardHeart.fill.svg b/frontend/src/assets/boardHeart.fill.svg
new file mode 100644
index 00000000..898fa8c8
--- /dev/null
+++ b/frontend/src/assets/boardHeart.fill.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardHeart.svg b/frontend/src/assets/boardHeart.svg
new file mode 100644
index 00000000..def4c24d
--- /dev/null
+++ b/frontend/src/assets/boardHeart.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardPencil.svg b/frontend/src/assets/boardPencil.svg
new file mode 100644
index 00000000..f09450e6
--- /dev/null
+++ b/frontend/src/assets/boardPencil.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardSearchArrow.svg b/frontend/src/assets/boardSearchArrow.svg
new file mode 100644
index 00000000..379b70ef
--- /dev/null
+++ b/frontend/src/assets/boardSearchArrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardSelectArrow.svg b/frontend/src/assets/boardSelectArrow.svg
new file mode 100644
index 00000000..c5100271
--- /dev/null
+++ b/frontend/src/assets/boardSelectArrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/boardSortArrow.svg b/frontend/src/assets/boardSortArrow.svg
new file mode 100644
index 00000000..6300239c
--- /dev/null
+++ b/frontend/src/assets/boardSortArrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/board_plus.svg b/frontend/src/assets/board_plus.svg
new file mode 100644
index 00000000..e4c2fb8f
--- /dev/null
+++ b/frontend/src/assets/board_plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/board_profile.svg b/frontend/src/assets/board_profile.svg
new file mode 100644
index 00000000..556e1244
--- /dev/null
+++ b/frontend/src/assets/board_profile.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/src/components/Board/BoardActions.jsx b/frontend/src/components/Board/BoardActions.jsx
new file mode 100644
index 00000000..69d94437
--- /dev/null
+++ b/frontend/src/components/Board/BoardActions.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import styles from './BoardActions.module.css';
+import PlusIcon from '../../assets/board_plus.svg';
+import DropdownArrowIcon from '../../assets/boardSelectArrow.svg';
+
+const BoardActions = ({ sortOption, onSortChange, onWrite }) => {
+ return (
+
+
+
+

+
+
+
+
+ );
+};
+
+export default BoardActions;
diff --git a/frontend/src/components/Board/BoardActions.module.css b/frontend/src/components/Board/BoardActions.module.css
new file mode 100644
index 00000000..8540f0d1
--- /dev/null
+++ b/frontend/src/components/Board/BoardActions.module.css
@@ -0,0 +1,118 @@
+:root {
+ --color-border: 0.6px solid rgba(0, 0, 0, 1);
+ --color-text: rgba(0, 0, 0, 1);
+ --color-bg-light: #fafafa;
+ --color-primary: #1d80f4;
+ --color-button-gradient: linear-gradient(
+ 92.89deg,
+ #d8e8ff 6.95%,
+ #d1d8ff 74.81%,
+ #dddeff 107.39%
+ );
+ --font-family:
+ 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-regular: 400;
+ --font-size: 16px;
+ --line-height: 100%;
+ --letter-spacing: 0%;
+ --gap: 12px;
+ --radius: 4px;
+ --transition: all 0.2s;
+}
+
+.boardActions {
+ width: 100%;
+ 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-bottom: 32px;
+}
+
+.selectWrapper {
+ position: relative;
+ flex-shrink: 0;
+}
+
+.sortSelect {
+ width: 90px;
+ height: 35px;
+ padding: 8px;
+ padding-right: 20px;
+ border: var(--color-border);
+ border-radius: var(--radius);
+ background: #fff;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ transition: var(--transition);
+ box-sizing: border-box;
+ font-family: var(--font-family);
+ font-weight: var(--font-regular);
+ font-size: var(--font-size);
+ line-height: var(--line-height);
+ letter-spacing: var(--letter-spacing);
+ color: var(--color-text);
+}
+
+.sortSelect:hover {
+ background: var(--color-bg-light);
+}
+
+.sortSelect:focus {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 2px rgba(29, 128, 244, 0.1);
+}
+
+.selectArrow {
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ width: 14px;
+ height: 14px;
+}
+
+.writeButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--gap);
+ width: 122px;
+ height: 35px;
+ padding: 8px 12px;
+ background: var(--color-button-gradient);
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: var(--transition);
+ white-space: nowrap;
+ font-family: var(--font-family);
+ font-weight: var(--font-regular);
+ font-size: var(--font-size);
+ line-height: var(--line-height);
+ letter-spacing: var(--letter-spacing);
+ color: var(--color-text);
+ box-sizing: border-box;
+ flex-shrink: 0;
+}
+
+.writeButton:hover {
+ opacity: 0.9;
+ box-shadow: 0 2px 8px rgba(29, 128, 244, 0.15);
+}
+
+.writeButton:active {
+ opacity: 0.8;
+}
+
+.writeButton img {
+ width: 9.67px;
+ height: 9.67px;
+}
diff --git a/frontend/src/components/Board/Modal.jsx b/frontend/src/components/Board/Modal.jsx
new file mode 100644
index 00000000..5c01ff1c
--- /dev/null
+++ b/frontend/src/components/Board/Modal.jsx
@@ -0,0 +1,76 @@
+import styles from './Modal.module.css';
+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 }) => {
+ return (
+
+
e.stopPropagation()}>
+
+
게시글 작성
+
+
+
+
+
+
+ setTitle(e.target.value)}
+ />
+
+
+
+
+
+
document.getElementById('fileUpload').click()}
+ >
+

+
파일추가
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ );
+};
+
+export default Modal;
diff --git a/frontend/src/components/Board/Modal.module.css b/frontend/src/components/Board/Modal.module.css
new file mode 100644
index 00000000..8047635a
--- /dev/null
+++ b/frontend/src/components/Board/Modal.module.css
@@ -0,0 +1,254 @@
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal {
+ width: 906px;
+ height: 653px;
+ max-width: 90vw;
+ 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;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.header {
+ width: 100%;
+ height: 35px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ opacity: 1;
+}
+
+.title {
+ font-weight: 700;
+ font-size: 24px;
+ line-height: 146%;
+ letter-spacing: 0%;
+ color: rgba(23, 23, 23, 1);
+ margin: 0;
+}
+
+.closeButton {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 47px;
+ flex: 1;
+}
+
+.titleField {
+ width: 100%;
+ height: 89px;
+}
+
+.label {
+ width: 40px;
+ height: 29px;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 146%;
+ letter-spacing: 0%;
+ color: rgba(23, 23, 23, 1);
+ margin: 0 0 12px 0;
+ display: block;
+ opacity: 1;
+}
+
+.input {
+ width: 100%;
+ height: 48px;
+ border-radius: 8px;
+ border: 0.4px solid rgba(175, 175, 175, 1);
+ background: rgba(255, 255, 255, 1);
+ padding: 16px 24px;
+ box-sizing: border-box;
+ opacity: 1;
+ font-size: 16px;
+ outline: none;
+}
+
+.input:focus {
+ border-color: rgba(130, 130, 130, 1);
+}
+
+.contentField {
+ width: 100%;
+ height: 203px;
+}
+
+.contentContainer {
+ width: 100%;
+ height: 162px;
+ border-radius: 8px;
+ border: 0.4px solid rgba(175, 175, 175, 1);
+ background: rgba(255, 255, 255, 1);
+ padding: 24px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ opacity: 1;
+}
+
+.fileSection {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ flex-shrink: 0;
+ margin-bottom: 12px;
+}
+
+.fileText {
+ width: 52px;
+ height: 14px;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ color: rgba(175, 175, 175, 1);
+ opacity: 1;
+ flex-shrink: 0;
+}
+
+.divider {
+ width: 100%;
+ height: 0px;
+ border: 0.4px solid rgba(175, 175, 175, 1);
+ opacity: 1;
+ margin-bottom: 12px;
+ flex-shrink: 0;
+}
+
+.textarea {
+ border: none;
+ outline: none;
+ resize: none;
+ flex: 1;
+ font-weight: 500;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: -3%;
+ color: rgba(170, 170, 170, 1);
+ background: transparent;
+ min-height: 50px;
+}
+
+.textarea::placeholder {
+ font-weight: 500;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: -3%;
+ color: rgba(170, 170, 170, 1);
+}
+
+.accessField {
+ width: 283px;
+ height: 81px;
+}
+
+.accessLabel {
+ width: 74px;
+ height: 29px;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 146%;
+ letter-spacing: 0%;
+ color: rgba(23, 23, 23, 1);
+ margin: 0 0 12px 0;
+ opacity: 1;
+ display: block;
+}
+
+.selectWrapper {
+ position: relative;
+ width: 283px;
+ display: flex;
+ align-items: center;
+}
+
+.select {
+ width: 100%;
+ height: 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;
+ box-sizing: border-box;
+ opacity: 1;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: -3%;
+ color: rgba(23, 23, 23, 1);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.selectIcon {
+ position: absolute;
+ right: 16px;
+ top: 10px;
+ pointer-events: none;
+ width: 10px;
+ height: 16px;
+}
+
+.select:focus {
+ outline: none;
+ border-color: rgba(130, 130, 130, 1);
+}
+
+.select option {
+ font-weight: 400;
+ font-size: 16px;
+ color: rgba(23, 23, 23, 1);
+ background: rgba(255, 255, 255, 1);
+}
+
+.saveButton {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 16px;
+ cursor: pointer;
+ background: rgba(23, 23, 23, 1);
+ color: rgba(255, 255, 255, 1);
+ margin-left: auto;
+ margin-top: auto;
+}
+
+.saveButton:hover {
+ background: rgba(50, 50, 50, 1);
+ transform: translateY(-1px);
+}
diff --git a/frontend/src/components/Board/PostItem.jsx b/frontend/src/components/Board/PostItem.jsx
new file mode 100644
index 00000000..f609cabd
--- /dev/null
+++ b/frontend/src/components/Board/PostItem.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import styles from './PostItem.module.css';
+import ProfileIcon from '../../assets/board_profile.svg';
+import BookmarkIcon from '../../assets/boardBookMark.svg';
+import BookmarkFilledIcon from '../../assets/boardBookmark.fill.svg';
+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 navigate = useNavigate();
+
+ const handleCardClick = () => {
+ navigate(`/board/${post.id}`, { state: { post } });
+ };
+
+ const handleActionClick = (e) => {
+ e.stopPropagation();
+ };
+
+ return (
+
+
+
+

+
+
+
+
+
+ 운영진
+ {getTimeAgo(post.date)}
+
+
+
{post.title}
+
{post.content}
+
+
+
+
+
+
+
+
+ );
+});
+
+PostItem.displayName = 'PostItem';
+
+export default PostItem;
diff --git a/frontend/src/components/Board/PostItem.module.css b/frontend/src/components/Board/PostItem.module.css
new file mode 100644
index 00000000..9ea3d85e
--- /dev/null
+++ b/frontend/src/components/Board/PostItem.module.css
@@ -0,0 +1,208 @@
+.postItem {
+ width: 100%;
+ background: #f9f9f9;
+ border-radius: 12px;
+ padding: 28px;
+ margin-bottom: 16px;
+ box-sizing: border-box;
+ position: relative;
+}
+
+.mainContent {
+ display: flex;
+ gap: 16px;
+ height: 100%;
+}
+
+.leftSection {
+ flex-shrink: 0;
+}
+
+.authorImage {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.contentSection {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+}
+
+.metaInfo {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.author {
+ font-weight: 500;
+ font-size: 16px;
+ color: #828282;
+ line-height: 1.2;
+}
+
+.time {
+ font-weight: 400;
+ font-size: 12px;
+ color: #aaa;
+ line-height: 1.2;
+}
+
+.title {
+ font-weight: 700;
+ font-size: 20px;
+ color: #616161;
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.content {
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 1.6;
+ color: #5a5a5a;
+ word-break: keep-all;
+ margin-bottom: 32px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.actions {
+ position: absolute;
+ bottom: 28px;
+ right: 28px;
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+.actionButton {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ transition: opacity 0.2s;
+}
+
+.actionButton:hover {
+ opacity: 0.7;
+}
+
+.actionButton img {
+ width: 24px;
+ height: 24px;
+}
+
+.count {
+ font-size: 16px;
+ font-weight: 500;
+ color: #5a5a5a;
+}
+
+/* 반응형 */
+@media (max-width: 768px) {
+ .postItem {
+ padding: 20px 16px;
+ border-radius: 8px;
+ margin-bottom: 12px;
+ }
+
+ .mainContent {
+ gap: 12px;
+ }
+
+ .authorImage {
+ width: 32px;
+ height: 32px;
+ }
+
+ .contentSection {
+ gap: 8px;
+ }
+
+ .author {
+ font-size: 14px;
+ }
+
+ .time {
+ font-size: 11px;
+ }
+
+ .title {
+ font-size: 18px;
+ }
+
+ .content {
+ font-size: 14px;
+ margin-bottom: 28px;
+ -webkit-line-clamp: 3;
+ }
+
+ .actions {
+ bottom: 20px;
+ right: 16px;
+ gap: 12px;
+ }
+
+ .actionButton img {
+ width: 20px;
+ height: 20px;
+ }
+
+ .count {
+ font-size: 14px;
+ }
+}
+
+@media (max-width: 480px) {
+ .postItem {
+ padding: 16px 12px;
+ margin-bottom: 10px;
+ }
+
+ .mainContent {
+ gap: 10px;
+ }
+
+ .title {
+ font-size: 16px;
+ }
+
+ .content {
+ font-size: 13px;
+ margin-bottom: 24px;
+ }
+
+ .actions {
+ bottom: 16px;
+ right: 12px;
+ gap: 10px;
+ }
+
+ .actionButton img {
+ width: 18px;
+ height: 18px;
+ }
+
+ .count {
+ font-size: 13px;
+ }
+}
diff --git a/frontend/src/components/Board/SearchBar.jsx b/frontend/src/components/Board/SearchBar.jsx
new file mode 100644
index 00000000..d178ccc1
--- /dev/null
+++ b/frontend/src/components/Board/SearchBar.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import styles from './SearchBar.module.css';
+import SearchArrowIcon from '../../assets/boardSearchArrow.svg';
+
+const SearchBar = ({ searchTerm, onSearchChange }) => {
+ const handleSearch = () => {
+ if (searchTerm.trim()) {
+ console.log('검색어:', searchTerm);
+ }
+ };
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ return (
+
+
onSearchChange(e.target.value)}
+ onKeyPress={handleKeyPress}
+ className={styles.searchInput}
+ aria-label="검색"
+ />
+
+
+ );
+};
+
+export default SearchBar;
diff --git a/frontend/src/components/Board/SearchBar.module.css b/frontend/src/components/Board/SearchBar.module.css
new file mode 100644
index 00000000..996f8ed1
--- /dev/null
+++ b/frontend/src/components/Board/SearchBar.module.css
@@ -0,0 +1,137 @@
+:root {
+ --search-width: 908px;
+ --search-height: 68px;
+ --search-radius: 50px;
+ --search-padding: 24px 16px 24px 26px;
+ --search-border: 0.8px;
+ --search-gradient: linear-gradient(93.24deg, #7f7f7f 29.23%, #2b518e 91.43%);
+ --button-size: 40px;
+ --button-bg: rgba(0, 0, 0, 1);
+ --button-bg-hover: rgba(50, 50, 50, 1);
+ --font-size-input: 20px;
+ --color-input: rgba(23, 23, 23, 1);
+ --color-placeholder: rgba(170, 170, 170, 1);
+ --transition: all 0.2s ease;
+}
+
+.searchContainer {
+ position: relative;
+ width: var(--search-width);
+ height: var(--search-height);
+ padding: var(--search-padding);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-radius: var(--search-radius);
+ box-sizing: border-box;
+}
+
+.searchContainer::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: var(--search-radius);
+ padding: var(--search-border);
+ background: var(--search-gradient);
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.searchContainer::after {
+ content: '';
+ position: absolute;
+ inset: var(--search-border);
+ border-radius: var(--search-radius);
+ background: var(--search-gradient);
+ pointer-events: none;
+ z-index: -1;
+}
+
+.searchInput {
+ flex: 1;
+ position: relative;
+ z-index: 2;
+ margin: 0;
+ padding: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: var(--font-size-input);
+ color: var(--color-input);
+}
+
+.searchInput::placeholder {
+ font-weight: 400;
+ font-size: var(--font-size-input);
+ color: var(--color-placeholder);
+}
+
+.searchButton {
+ position: relative;
+ z-index: 2;
+ width: var(--button-size);
+ height: var(--button-size);
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--button-bg);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.searchButton:hover {
+ background: var(--button-bg-hover);
+}
+
+.searchButton:active {
+ transform: scale(0.95);
+}
+
+.searchButton img {
+ width: 20px;
+ height: 16px;
+ display: block;
+}
+
+/* 반응형 */
+@media (max-width: 1024px) {
+ .searchContainer {
+ width: 100%;
+ max-width: var(--search-width);
+ }
+}
+
+@media (max-width: 768px) {
+ :root {
+ --search-height: 56px;
+ --search-padding: 16px 12px 16px 20px;
+ --font-size-input: 16px;
+ --button-size: 36px;
+ }
+
+ .searchButton img {
+ width: 18px;
+ height: 14px;
+ }
+}
+
+@media (max-width: 480px) {
+ :root {
+ --search-height: 48px;
+ --search-padding: 12px 8px 12px 16px;
+ --font-size-input: 14px;
+ --button-size: 32px;
+ }
+
+ .searchButton img {
+ width: 16px;
+ height: 12px;
+ }
+}
diff --git a/frontend/src/pages/Board.jsx b/frontend/src/pages/Board.jsx
index 38314788..d3c4e3e6 100644
--- a/frontend/src/pages/Board.jsx
+++ b/frontend/src/pages/Board.jsx
@@ -1,5 +1,145 @@
+import React, { useState, useEffect } from 'react';
+import PostItem from '../components/Board/PostItem';
+import Modal from '../components/Board/Modal';
+import SearchBar from '../components/Board/SearchBar';
+import BoardActions from '../components/Board/BoardActions';
+import styles from './Board.module.css';
+
const Board = () => {
- return Board Page
;
+ const [posts, setPosts] = useState(() => {
+ const saved = localStorage.getItem('boardPosts');
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ return parsed.map((post) => ({
+ ...post,
+ date: new Date(post.date),
+ }));
+ }
+ return [];
+ });
+
+ const [searchTerm, setSearchTerm] = useState('');
+ const [showModal, setShowModal] = useState(false);
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [sortOption, setSortOption] = useState('latest');
+
+ useEffect(() => {
+ localStorage.setItem('boardPosts', JSON.stringify(posts));
+ }, [posts]);
+
+ const handleOpenModal = () => {
+ setShowModal(true);
+ };
+
+ const handleCloseModal = () => {
+ setShowModal(false);
+ setTitle('');
+ setContent('');
+ };
+
+ const handleSave = () => {
+ const newPost = {
+ title,
+ content,
+ date: new Date(),
+ id: Date.now(),
+ likeCount: 0,
+ isLiked: false,
+ isBookmarked: false,
+ };
+ setPosts([newPost, ...posts]);
+ handleCloseModal();
+ };
+
+ const handleLike = (postId) => {
+ setPosts(
+ posts.map((post) =>
+ post.id === postId
+ ? {
+ ...post,
+ isLiked: !post.isLiked,
+ likeCount: post.isLiked ? post.likeCount - 1 : post.likeCount + 1,
+ }
+ : post
+ )
+ );
+ };
+
+ const handleBookmark = (postId) => {
+ setPosts(
+ posts.map((post) =>
+ post.id === postId
+ ? { ...post, isBookmarked: !post.isBookmarked }
+ : post
+ )
+ );
+ };
+
+ const sortedPosts = [...posts].sort((a, b) => {
+ if (sortOption === 'latest') {
+ return new Date(b.date) - new Date(a.date);
+ }
+ if (sortOption === 'oldest') {
+ return new Date(a.date) - new Date(b.date);
+ }
+ if (sortOption === 'popular') {
+ return b.likeCount - a.likeCount;
+ }
+ return 0;
+ });
+
+ const filteredPosts = sortedPosts.filter((post) => {
+ if (!searchTerm) return true;
+ const lowerSearch = searchTerm.toLowerCase();
+ return (
+ post.title.toLowerCase().includes(lowerSearch) ||
+ post.content.toLowerCase().includes(lowerSearch)
+ );
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ {filteredPosts.length > 0 ? (
+ filteredPosts.map((post) => (
+
+ ))
+ ) : (
+
게시글이 없습니다.
+ )}
+
+
+ {showModal && (
+
+ )}
+
+ );
};
export default Board;
diff --git a/frontend/src/pages/Board.module.css b/frontend/src/pages/Board.module.css
new file mode 100644
index 00000000..cc0e15dc
--- /dev/null
+++ b/frontend/src/pages/Board.module.css
@@ -0,0 +1,207 @@
+/* Board.module.css */
+.boardContainer {
+ width: 100%;
+ max-width: 1030px;
+ background: rgba(255, 255, 255, 1);
+ padding: 32px 40px;
+ margin-top: 80px;
+ margin-left: 100px;
+ box-sizing: border-box;
+}
+
+.boardHeader {
+ margin-bottom: 32px;
+}
+
+.boardTitle {
+ width: 94px;
+ height: 36px;
+ font-weight: 600;
+ font-size: 36px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ color: rgba(23, 23, 23, 1);
+ margin: 0;
+ opacity: 1;
+}
+
+.boardControls {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-bottom: 32px;
+ gap: 70px;
+ align-items: flex-start;
+}
+
+.searchContainer {
+ width: 908px;
+ height: 68px;
+ border-radius: 50px;
+ padding: 24px 26px;
+ gap: 10px;
+ opacity: 1;
+ position: relative;
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+}
+
+.searchContainer::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 50px;
+ padding: 0.8px;
+ background: linear-gradient(93.24deg, #7f7f7f 29.23%, #2b518e 91.43%);
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ pointer-events: none;
+}
+
+.searchInput {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: 20px;
+ color: rgba(23, 23, 23, 1);
+ padding: 0;
+ margin: 0;
+}
+
+.searchInput::placeholder {
+ font-weight: 400;
+ font-size: 20px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ color: rgba(170, 170, 170, 1);
+ opacity: 1;
+}
+
+.searchButton {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 1); /* 검정색 원형 배경 유지 */
+ border: none;
+ opacity: 1;
+ cursor: pointer;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.searchButton img {
+ width: 20px;
+ height: 16px;
+ display: block;
+}
+
+.boardActions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 16px;
+ width: 908px;
+}
+
+/* selectWrapper 추가 */
+.selectWrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.sortSelect {
+ width: 90px;
+ height: 35px;
+ border-radius: 4px;
+ padding: 8px 12px;
+ gap: 12px;
+ opacity: 1;
+ border: 0.6px solid rgba(0, 0, 0, 1);
+ background: #ffffff;
+ cursor: pointer;
+ box-sizing: border-box;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ color: rgba(0, 0, 0, 1);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+
+.sortSelect:focus {
+ outline: none;
+ border-color: #2b518e;
+}
+
+/* selectArrow - img 태그용 스타일 */
+.selectArrow {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ width: 12px;
+ height: 8px;
+}
+
+.writeButton {
+ width: 122px;
+ height: 35px;
+ border-radius: 4px;
+ padding: 8px 12px;
+ gap: 12px;
+ opacity: 1;
+ background: linear-gradient(
+ 92.89deg,
+ #d8e8ff 6.95%,
+ #d1d8ff 74.81%,
+ #dddeff 107.39%
+ );
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+}
+
+.writeButton span {
+ width: 74px;
+ height: 19px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ color: rgba(0, 0, 0, 1);
+ opacity: 1;
+ display: flex;
+ align-items: center;
+}
+
+.writeButton img {
+ width: auto;
+ height: auto;
+ flex-shrink: 0;
+ display: block;
+}
+
+.postsContainer {
+ width: 100%;
+ max-width: 908px;
+ margin: 0;
+ height: auto;
+ min-height: auto;
+ box-sizing: border-box;
+}
diff --git a/frontend/src/pages/PostDetail.jsx b/frontend/src/pages/PostDetail.jsx
new file mode 100644
index 00000000..d195a214
--- /dev/null
+++ b/frontend/src/pages/PostDetail.jsx
@@ -0,0 +1,367 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate, useLocation } from 'react-router-dom';
+import styles from './PostDetail.module.css';
+
+import ProfileIcon from '../assets/board_profile.svg';
+import BookmarkIcon from '../assets/boardBookMark.svg';
+import BookmarkFilledIcon from '../assets/boardBookmark.fill.svg';
+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 { getTimeAgo } from '../utils/TimeUtils';
+
+const PostDetail = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [post, setPost] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const [comments, setComments] = useState([]);
+ const [commentText, setCommentText] = useState('');
+ const [showMenu, setShowMenu] = useState(false);
+ const [showCommentMenu, setShowCommentMenu] = useState(null);
+
+ // 게시글 저장소 함수
+ const getPostsFromStorage = () => {
+ try {
+ const saved = localStorage.getItem('boardPosts');
+ if (!saved) return [];
+ return JSON.parse(saved).map((p) => ({ ...p, date: new Date(p.date) }));
+ } catch (error) {
+ console.error('Failed to parse posts from localStorage', error);
+ return [];
+ }
+ };
+
+ const savePostsToStorage = (posts) => {
+ try {
+ localStorage.setItem('boardPosts', JSON.stringify(posts));
+ } catch (error) {
+ if (error.name === 'QuotaExceededError') {
+ console.error('localStorage 용량이 부족합니다.');
+ // 사용자에게 알림 표시 또는 오래된 데이터 정리
+ }
+ throw error;
+ }
+ };
+
+ // 댓글 저장소 함수
+ const getCommentsFromStorage = (postId) => {
+ try {
+ const saved = localStorage.getItem(`comments_${postId}`);
+ if (!saved) return [];
+ return JSON.parse(saved).map((c) => ({ ...c, date: new Date(c.date) }));
+ } catch (error) {
+ console.error('Failed to parse comments from localStorage', error);
+ return [];
+ }
+ };
+
+ const saveCommentsToStorage = (postId, comments) => {
+ localStorage.setItem(`comments_${postId}`, JSON.stringify(comments));
+ };
+
+ // 게시글 로딩 및 댓글 로딩
+ useEffect(() => {
+ setLoading(true);
+ let currentPost = location.state?.post;
+
+ if (!currentPost) {
+ const allPosts = getPostsFromStorage();
+ currentPost = allPosts.find((p) => p.id === parseInt(id, 10));
+ }
+ setPost(currentPost);
+ setLoading(false);
+
+ if (currentPost) {
+ setComments(getCommentsFromStorage(currentPost.id));
+ } else {
+ setComments([]);
+ }
+ }, [id, location.state]);
+
+ // 좋아요 토글
+ const handleLike = () => {
+ const allPosts = getPostsFromStorage();
+ let updatedPost = null;
+ const updatedPosts = allPosts.map((p) => {
+ if (p.id === post.id) {
+ updatedPost = {
+ ...p,
+ isLiked: !p.isLiked,
+ likeCount: p.isLiked ? p.likeCount - 1 : p.likeCount + 1,
+ };
+ return updatedPost;
+ }
+ return p;
+ });
+ savePostsToStorage(updatedPosts);
+ setPost(updatedPost);
+ };
+
+ // 북마크 토글
+ const handleBookmark = () => {
+ const allPosts = getPostsFromStorage();
+ let updatedPost = null;
+ const updatedPosts = allPosts.map((p) => {
+ if (p.id === post.id) {
+ updatedPost = { ...p, isBookmarked: !p.isBookmarked };
+ return updatedPost;
+ }
+ return p;
+ });
+ savePostsToStorage(updatedPosts);
+ setPost(updatedPost);
+ };
+
+ // 게시글 삭제
+ const handleDelete = () => {
+ if (window.confirm('게시글을 정말 삭제하시겠습니까?')) {
+ const allPosts = getPostsFromStorage();
+ const updatedPosts = allPosts.filter((p) => p.id !== post.id);
+ savePostsToStorage(updatedPosts);
+ navigate('/board');
+ }
+ };
+
+ // 게시글 수정
+ 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 allPosts = getPostsFromStorage();
+ let updatedPost = null;
+ const updatedPosts = allPosts.map((p) => {
+ if (p.id === post.id) {
+ updatedPost = { ...p, title: newTitle, content: newContent };
+ return updatedPost;
+ }
+ return p;
+ });
+ savePostsToStorage(updatedPosts);
+ setPost(updatedPost);
+ };
+
+ // 댓글 추가
+ const handleAddComment = () => {
+ if (!commentText.trim()) return;
+ const newComment = {
+ id: Date.now(),
+ author: '사용자',
+ text: commentText,
+ date: new Date(),
+ };
+ const updatedComments = [...comments, newComment];
+ setComments(updatedComments);
+ saveCommentsToStorage(post.id, updatedComments);
+ setCommentText('');
+ };
+
+ // 댓글 수정
+ const handleUpdateComment = (commentId) => {
+ setShowCommentMenu(null);
+ const currentComment = comments.find((c) => c.id === commentId);
+ const newText = prompt('수정할 댓글을 입력하세요:', currentComment.text);
+ if (newText === null || newText.trim() === '') return;
+ const updatedComments = comments.map((c) =>
+ c.id === commentId ? { ...c, text: newText } : c
+ );
+ setComments(updatedComments);
+ saveCommentsToStorage(post.id, updatedComments);
+ };
+
+ // 댓글 삭제
+ const handleDeleteComment = (commentId) => {
+ if (window.confirm('댓글을 정말 삭제하시겠습니까?')) {
+ const updatedComments = comments.filter((c) => c.id !== commentId);
+ setComments(updatedComments);
+ saveCommentsToStorage(post.id, updatedComments);
+ setShowCommentMenu(null);
+ }
+ };
+
+ // 댓글 입력창에서 ctrl+enter로 댓글 추가
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && e.ctrlKey) handleAddComment();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!post) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
{post.title}
+
+
+ {showMenu && (
+
+
+
+
+ )}
+
+
+
+
+
+

+
+
운영진
+
{getTimeAgo(post.date)}
+
+
+ {post.content}
+
+
+
+
+
+
+
+
+
+ 댓글 {comments.length}
+
+
+
+
+
+
+
+
+ {comments.length > 0 ? (
+ comments.map(({ id, author, text, date }) => (
+
+
+
+

+
{author}
+
+
{getTimeAgo(date)}
+
+ {/* 댓글 수정/삭제 메뉴 */}
+
+
+ {showCommentMenu === id && (
+
+
+
+
+ )}
+
+
+
{text}
+
+ ))
+ ) : (
+
댓글이 없습니다.
+ )}
+
+
+
+
+ );
+};
+
+export default PostDetail;
diff --git a/frontend/src/pages/PostDetail.module.css b/frontend/src/pages/PostDetail.module.css
new file mode 100644
index 00000000..d7674a43
--- /dev/null
+++ b/frontend/src/pages/PostDetail.module.css
@@ -0,0 +1,437 @@
+:root {
+ --width-content: 926px;
+ --padding-default: 12px;
+ --gap-default: 12px;
+ --color-bg-light: rgba(244, 244, 244, 1);
+ --color-text-primary: #171717;
+ --color-text-secondary: #828282;
+ --color-text-tertiary: #aaa;
+ --color-text-input: rgba(181, 181, 181, 1);
+ --color-accent: #2b518e;
+ --color-button: rgba(29, 128, 244, 1);
+ --color-bg-card: #f9f9f9;
+ --color-border-card: #eee;
+ --color-divider: rgba(214, 214, 214, 1);
+ --color-text-dark: #333;
+}
+
+.container {
+ width: 100%;
+ min-height: 100vh;
+ background: #fff;
+ display: flex;
+}
+
+.postDetail {
+ flex: 1;
+ padding: 80px 0 40px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.title {
+ width: var(--width-content);
+ margin: 0 0 32px;
+ font-weight: 700;
+ font-size: 32px;
+ line-height: 1.4;
+ color: var(--color-text-primary);
+}
+
+.divider {
+ width: var(--width-content);
+ height: 2px;
+ margin-bottom: 24px;
+ background: var(--color-divider);
+}
+
+.meta,
+.content,
+.commentsSection,
+.commentInput,
+.commentsList {
+ width: var(--width-content);
+}
+
+.meta {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-default);
+ margin-bottom: 32px;
+}
+
+.profileIcon {
+ width: 36px;
+ height: 36px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.metaInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.author {
+ margin: 0;
+ font-weight: 500;
+ font-size: 14px;
+ color: var(--color-text-secondary);
+}
+
+.date {
+ margin: 0;
+ font-weight: 400;
+ font-size: 12px;
+ color: var(--color-text-tertiary);
+}
+
+.content {
+ width: var(--width-content);
+ margin-bottom: 48px;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 160%;
+ letter-spacing: -3%;
+ color: var(--color-text-dark);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ box-sizing: border-box;
+}
+
+.commentsSection {
+ padding-top: 40px;
+}
+
+.commentsHeader {
+ margin-bottom: 24px;
+}
+
+.commentCount {
+ margin: 0;
+ font-weight: 600;
+ font-size: 16px;
+ color: var(--color-text-primary);
+}
+
+.commentCount span {
+ color: var(--color-accent);
+ font-weight: 700;
+}
+
+.commentInput {
+ display: flex;
+ align-items: flex-start;
+ min-height: 71px;
+ margin-bottom: var(--gap-default);
+ padding: var(--padding-default) 16px;
+ background: var(--color-bg-light);
+ border-radius: 12px;
+ box-sizing: border-box;
+}
+
+.textarea {
+ flex: 1;
+ min-height: 47px;
+ max-height: 300px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ font-family: inherit;
+ font-weight: 500;
+ font-size: 16px;
+ line-height: 160%;
+ letter-spacing: 0%;
+ color: var(--color-text-input);
+ outline: none;
+ resize: none;
+ overflow-y: auto;
+}
+
+.textarea::placeholder {
+ font-weight: 500;
+ font-size: 16px;
+ line-height: 160%;
+ letter-spacing: 0%;
+ color: var(--color-text-input);
+}
+
+.textarea:focus {
+ outline: none;
+}
+
+.submitButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 113px;
+ height: 43px;
+ margin-left: calc(var(--width-content) - 113px);
+ margin-bottom: 32px;
+ background: var(--color-button);
+ color: #fff;
+ border: none;
+ border-radius: 10px;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.submitButton:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.submitButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.commentsList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-default);
+}
+
+.commentCard {
+ padding: 20px;
+ border: 1px solid var(--color-border-card);
+ border-radius: 8px;
+ background: var(--color-bg-card);
+ word-break: break-word;
+ overflow-wrap: break-word;
+}
+
+.commentHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: var(--gap-default);
+}
+
+.commentMeta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.commentAuthor {
+ margin: 0;
+ font-weight: 500;
+ font-size: 14px;
+ color: var(--color-text-primary);
+}
+
+.commentDate {
+ margin: 0;
+ font-size: 12px;
+ color: var(--color-text-tertiary);
+}
+
+.commentText {
+ margin: 0;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 160%;
+ letter-spacing: -3%;
+ color: var(--color-text-dark);
+ word-break: break-word;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+.noComments {
+ padding: 40px 0;
+ margin: 0;
+ text-align: center;
+ font-size: 14px;
+ color: var(--color-text-tertiary);
+}
+
+.notFound {
+ padding: 60px 20px;
+ text-align: center;
+ font-size: 18px;
+ color: var(--color-text-tertiary);
+}
+
+/* 반응형 */
+@media (max-width: 768px) {
+ .postDetail {
+ padding: 40px 20px;
+ align-items: stretch;
+ }
+
+ .title,
+ .divider,
+ .meta,
+ .content,
+ .commentsSection,
+ .commentInput,
+ .commentsList {
+ width: 100%;
+ }
+
+ .textarea {
+ min-height: 80px;
+ resize: vertical;
+ }
+
+ .submitButton {
+ width: 100%;
+ margin-left: 0;
+ }
+}
+
+@media (max-width: 400px) {
+ .postDetail {
+ padding: 24px 12px;
+ }
+
+ .title {
+ font-size: 20px;
+ margin-bottom: 20px;
+ }
+
+ .commentInput {
+ padding: 8px;
+ }
+
+ .submitButton {
+ width: 100%;
+ height: 40px;
+ font-size: 14px;
+ margin-bottom: 24px;
+ }
+
+ .commentCard {
+ padding: 12px;
+ }
+
+ .commentText {
+ font-size: 14px;
+ }
+}
+
+.titleWrapper {
+ width: var(--width-content);
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 32px;
+}
+
+.title {
+ width: auto;
+ margin: 0;
+ flex-grow: 1;
+}
+
+.menuContainer {
+ margin-left: auto;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.menuButton {
+ background: transparent;
+ border: none;
+ font-size: 28px;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 0 4px;
+ line-height: 1.2;
+}
+
+.menuDropdown {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ background: white;
+ border: 1px solid var(--color-border-card);
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 10;
+ width: 120px;
+ overflow: hidden;
+}
+
+.menuDropdown button {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 10px 20px;
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ font-size: 14px;
+ white-space: nowrap;
+}
+
+.menuDropdown button img {
+ margin-right: 8px;
+ display: block;
+}
+
+.menuDropdown button:hover {
+ background-color: var(--color-bg-light);
+}
+
+.EditIcon {
+ width: 18px;
+ height: 15px;
+}
+
+.DeleteIcon {
+ width: 11px;
+ height: 11px;
+}
+
+.commentsHeaderWrapper {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 24px;
+ border-bottom: 1px solid var(--color-divider);
+ padding-bottom: 16px;
+}
+
+.commentsHeader {
+ margin: 0;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.actionButton {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+}
+
+.actionButton img {
+ width: 20px;
+ height: 20px;
+}
+
+.actionButton .count {
+ font-size: 14px;
+ color: var(--color-text-secondary);
+ font-weight: 500;
+}
diff --git a/frontend/src/utils/TimeUtils.js b/frontend/src/utils/TimeUtils.js
new file mode 100644
index 00000000..8b77b3d2
--- /dev/null
+++ b/frontend/src/utils/TimeUtils.js
@@ -0,0 +1,26 @@
+export const getTimeAgo = (createdAt) => {
+ const now = new Date();
+ const created = new Date(createdAt);
+ const diffInMs = now - created;
+
+ const seconds = Math.floor(diffInMs / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+ const months = Math.floor(days / 30);
+ const years = Math.floor(months / 12);
+
+ if (seconds < 60) {
+ return '방금전';
+ } else if (minutes < 60) {
+ return `${minutes}분전`;
+ } else if (hours < 24) {
+ return `${hours}시간전`;
+ } else if (days < 30) {
+ return `${days}일전`;
+ } else if (months < 12) {
+ return `${months}달전`;
+ } else {
+ return `${years}년전`;
+ }
+};