- {/* Post Content */}
-
-
-
- {post.category}
-
-
-
{post.title}
-
{post.content}
-
-
-
- {post.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
-
-
- {post.likes}
-
-
-
- {post.comments}
-
-
-
{post.timestamp}
-
-
{post.author}
-
-
-
-
-
- {/* Post Image (conditionally rendered) */}
- {post.imageUrl && (
-
-
-
- )}
+ return (
+
+
+ {/* Post Content */}
+
+
+
+ {post.category}
+
+
+
{post.title}
+
{post.content}
+
+
+
+ {post.tags.map((tag) => (
+
+ {tag}
+
+ ))}
-
- );
+
+
+
+ {post.likes}
+
+
+
+ {post.comments}
+
+
+
{post.timestamp}
+
+
{post.author}
+
+
+
+
+ {/* Post Image (conditionally rendered) */}
+ {post.imageUrl && (
+
+
+
+ )}
+
+
+ );
}
-
// --- Main Page Component ---
-export default async function ComposerTalkPage({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- return (
-
- {/* Composer Profile도 id에 맞는 정보로 바뀌어야함 */}
-
+export default function ComposerTalkPage({ params }: { params: { id: string } }) {
+ const { id } = params;
+ const [posts, setPosts] = useState
([]);
+ const [loading, setLoading] = useState(true);
- {/* Post List */}
-
- {mockPosts.map((post) => (
-
-
-
- ))}
-
+ // API 연결 전에는 mockPosts를 사용하고, API 연결 시 아래 fetch 부분을 주석 해제하세요.
+ /*
+ useEffect(() => {
+ // 실제 API 엔드포인트로 변경 필요
+ fetch(`/api/composer-talk-room/${id}/posts`)
+ .then((res) => res.json())
+ .then((data) => {
+ setPosts(data.posts); // API 응답 구조에 따라 수정
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+ }, [id]);
+ */
-
-
- );
+ // mock 데이터로 초기화 (API 연결 전용)
+ useEffect(() => {
+ setPosts(mockPosts);
+ setLoading(false);
+ }, [id]);
+
+ return (
+
+
+
+ {loading ? (
+ 불러오는 중...
+ ) : posts.length === 0 ? (
+ 게시글이 없습니다.
+ ) : (
+ posts.map((post) => (
+
+
+
+ ))
+ )}
+
+
+
+ );
}
\ No newline at end of file
diff --git a/src/app/curation/[postId]/comment-input.tsx b/src/app/curation/[postId]/comment-input.tsx
new file mode 100644
index 0000000..190474e
--- /dev/null
+++ b/src/app/curation/[postId]/comment-input.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import React, { useState } from 'react';
+import Image from 'next/image';
+
+interface CommentInputProps {
+ onSubmitComment: (content: string, isReply?: boolean, replyToId?: number) => void;
+ replyMode?: {
+ isReply: boolean;
+ replyToId: number;
+ replyToAuthor: string;
+ };
+ onCancelReply?: () => void;
+}
+
+export default function CommentInput({
+ onSubmitComment,
+ replyMode,
+ onCancelReply
+}: CommentInputProps) {
+ const [content, setContent] = useState('');
+ const [isExpanded, setIsExpanded] = useState(false);
+ const MAX_CHARS = 28;
+
+ const handleSubmit = () => {
+ if (!content.trim()) return;
+
+ onSubmitComment(
+ content.trim(),
+ replyMode?.isReply || false,
+ replyMode?.replyToId
+ );
+
+ setContent('');
+ setIsExpanded(false);
+ if (onCancelReply) {
+ onCancelReply();
+ }
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (content.trim() && content.length <= MAX_CHARS) {
+ handleSubmit();
+ }
+ }
+ };
+
+ const handleInputClick = () => {
+ setIsExpanded(true);
+ };
+
+ const handleInputBlur = () => {
+ if (!content.trim()) {
+ setIsExpanded(false);
+ }
+ };
+
+ const isOverLimit = content.length > MAX_CHARS;
+ const canSubmit = content.trim() && !isOverLimit;
+
+ return (
+
+ {/* Reply Mode Header */}
+ {replyMode?.isReply && (
+
+
+
+ @{replyMode.replyToAuthor}님에게 답글
+
+
+ ✕
+
+
+
+ )}
+
+ {/* Main Comment Input Area */}
+
+
+ {/* Profile Image */}
+
+
+ {/* Input Container */}
+
+ {!isExpanded ? (
+ /* Collapsed State - Placeholder */
+ <>
+
+ 댓글을 남겨주세요
+
+
+ >
+ ) : (
+ /* Expanded State - Textarea */
+
+
+ )}
+
+
+
+ {/* Expanded Controls */}
+ {isExpanded && (
+
+
+ {content.length}/{MAX_CHARS}
+
+
+
+ 등록
+
+
+ {
+ setContent('');
+ setIsExpanded(false);
+ if (onCancelReply) {
+ onCancelReply();
+ }
+ }}
+ className="px-3 py-2 text-sm text-[#A6A6A6] hover:text-[#4C4C4C] transition-colors"
+ >
+ 취소
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/curation/[postId]/comment-item.tsx b/src/app/curation/[postId]/comment-item.tsx
new file mode 100644
index 0000000..adf42b3
--- /dev/null
+++ b/src/app/curation/[postId]/comment-item.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useState } from 'react';
+import Image from 'next/image';
+import { ReportModal } from './report-modal';
+
+export interface CommentItemProps {
+ comment: {
+ id: number;
+ author: string;
+ timestamp: string;
+ content: string;
+ isHeartSelected?: boolean;
+ };
+ isReply?: boolean;
+ composerId?: string;
+ onReply?: (commentId: number, author: string) => void;
+ onReportOpen?: () => void;
+ onReportClose?: () => void;
+}
+
+const CommentItem = ({ comment, isReply = false, composerId, onReply, onReportOpen, onReportClose }: CommentItemProps) => {
+ const [isReportModalOpen, setIsReportModalOpen] = useState(false);
+ const [isHeartSelected, setIsHeartSelected] = useState(comment.isHeartSelected || false);
+
+ const handleReplyClick = () => {
+ if (onReply) {
+ onReply(comment.id, comment.author);
+ }
+ };
+
+ const handleHeartClick = () => {
+ setIsHeartSelected(!isHeartSelected);
+ // TODO: 백엔드 API 호출로 하트 상태 업데이트
+ };
+
+ const handleReportClick = () => {
+ setIsReportModalOpen(true);
+ // 신고 모달 열렸을 때 댓글 입력창 숨기기
+ if (onReportOpen) {
+ onReportOpen();
+ }
+ };
+
+ const handleReportModalClose = () => {
+ setIsReportModalOpen(false);
+ // 신고 모달 닫혔을 때 댓글 입력창 다시 보이기
+ if (onReportClose) {
+ onReportClose();
+ }
+ };
+
+ return (
+ <>
+ {isReportModalOpen && (
+
+ )}
+
+
+
+
+
+
+
{comment.author}
+
{comment.timestamp}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CommentItem;
\ No newline at end of file
diff --git a/src/app/curation/[postId]/comment-list.tsx b/src/app/curation/[postId]/comment-list.tsx
new file mode 100644
index 0000000..fb1a2a7
--- /dev/null
+++ b/src/app/curation/[postId]/comment-list.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import CommentItem from './comment-item';
+
+// Comment type definition
+type Comment = {
+ id: number;
+ author: string;
+ timestamp: string;
+ content: string;
+ isHeartSelected: boolean;
+ isReply: boolean;
+};
+
+const COMMENTS_PER_PAGE = 5;
+
+interface CommentListProps {
+ composerId?: string;
+ initialComments: Comment[];
+ onAddComment?: (content: string, isReply?: boolean, replyToId?: number) => void;
+ onReply?: (commentId: number, author: string) => void;
+ onReportOpen?: () => void;
+ onReportClose?: () => void;
+}
+
+export default function CommentList({ composerId, initialComments, onAddComment, onReply, onReportOpen, onReportClose }: CommentListProps) {
+ const [comments, setComments] = useState
(initialComments.slice(0, COMMENTS_PER_PAGE));
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(initialComments.length > COMMENTS_PER_PAGE);
+ const loader = useRef(null);
+
+ // initialComments가 변경될 때마다 comments 업데이트
+ useEffect(() => {
+ const currentPageComments = initialComments.slice(0, page * COMMENTS_PER_PAGE);
+ setComments(currentPageComments);
+ setHasMore(currentPageComments.length < initialComments.length);
+ }, [initialComments, page]);
+
+ const loadMoreComments = useCallback(() => {
+ if (!hasMore) return;
+
+ const nextPage = page + 1;
+ const newComments = initialComments.slice(0, nextPage * COMMENTS_PER_PAGE);
+
+ setComments(newComments);
+ setPage(nextPage);
+ if (newComments.length >= initialComments.length) {
+ setHasMore(false);
+ }
+ }, [hasMore, page, initialComments]);
+
+ useEffect(() => {//무한스크롤의 영역
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0].isIntersecting && hasMore) {
+ // Simulate network latency
+ setTimeout(() => {
+ loadMoreComments();
+ }, 500);
+ }
+ },
+ { threshold: 1.0 }
+ );
+
+ const currentLoader = loader.current;
+ if (currentLoader) {
+ observer.observe(currentLoader);
+ }
+
+ return () => {
+ if (currentLoader) {
+ observer.unobserve(currentLoader);
+ }
+ };
+ }, [hasMore, page, loadMoreComments]); // Dependency array updated for correctness
+
+ return (
+ <>
+ {comments.map((comment) => (
+
+ ))}
+ {hasMore && (
+
+ 댓글을 불러오는 중...
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/app/curation/[postId]/page.tsx b/src/app/curation/[postId]/page.tsx
new file mode 100644
index 0000000..0642212
--- /dev/null
+++ b/src/app/curation/[postId]/page.tsx
@@ -0,0 +1,243 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import CommentList from './comment-list';
+import CommentInput from './comment-input';
+import { ReportButton } from './report-button';
+import { useState, useEffect } from 'react';
+
+// Mock Data - postId에 따라 다른 데이터를 반환
+const getMockPostData = (postId: string) => {
+ const posts = {
+ '1': {
+ userId: '123',
+ composer: '큐레이션',
+ author: 'Username',
+ timestamp: '25/08/28 14:26',
+ category: '큐레이션글',
+ postType: '큐레이션',
+ title: '제목이 들어갈 자리입니다',
+ content: '글 내용이 들어갈 자리입니다. 글자수는 아 이게몇개냐\n가나다라마바사아자차카타파하가나다라마바사아자차카타파하\n28자입니다 최대',
+ tags: ['#태그01', '#태그02', '#태그03'],
+ images: ['/images/placeholder-1.png', '/images/placeholder-2.png'],
+ likes: 12,
+ scraps: 3,
+ },
+ '2': {
+ userId: '456',
+ composer: '큐레이션',
+ author: '음악애호가',
+ timestamp: '25/08/29 10:15',
+ category: '큐레이션글',
+ postType: '큐레이션',
+ title: '라흐마니노프의 피아노 협주곡 2번',
+ content: '오늘은 제가 정말 사랑하는 클래식 음악에 대해 이야기해보려고 합니다.\n라흐마니노프의 피아노 협주곡 2번은 정말 감동적인 곡입니다.',
+ tags: ['#라흐마니노프', '#피아노협주곡', '#클래식'],
+ images: ['/images/placeholder-3.png'],
+ likes: 25,
+ scraps: 8,
+ },
+ '3': {
+ userId: '789',
+ composer: '큐레이션',
+ author: '클래식러버',
+ timestamp: '25/08/30 16:45',
+ category: '큐레이션글',
+ postType: '큐레이션',
+ title: '베토벤 교향곡 9번 합창 추천',
+ content: '베토벤의 교향곡 9번 합창은 클래식 음악의 최고 걸작 중 하나입니다.\n특히 4악장의 환희의 송가는 정말 압도적입니다.',
+ tags: ['#베토벤', '#교향곡', '#합창'],
+ images: [],
+ likes: 18,
+ scraps: 5,
+ }
+ };
+
+ return posts[postId as keyof typeof posts] || posts['1'];
+};
+
+const mockComments = [
+ { id: 1, author: 'Username', timestamp: '25/08/28 14:26', content: '댓글내용을 입력하는 곳입니다. 최대는 마찬가지로 28자 근데 띄어쓰기랑 문장부호는 어떻게 세나요?', isHeartSelected: false, isReply: false },
+ { id: 2, author: 'Username', timestamp: '25/08/28 14:26', content: '댓글내용을 입력하는 곳입니다. 최대는 마찬가지로 28자 근데 띄어쓰기랑 문장부호는 어떻게 세나요?', isHeartSelected: true, isReply: true },
+ { id: 3, author: 'Username', timestamp: '25/08/28 14:26', content: '댓글내용을 입력하는 곳입니다. 최대는 마찬가지로 28자 근데 띄어쓰기랑 문장부호는 어떻게 세나요?', isHeartSelected: true, isReply: false },
+ { id: 4, author: 'Username', timestamp: '25/08/28 14:26', content: '댓글내용을 입력하는 곳입니다. 최대는 마찬가지로 28자 근데 띄어쓰기랑 문장부호는 어떻게 세나요?', isHeartSelected: false, isReply: false },
+ { id: 5, author: 'Username', timestamp: '25/08/28 14:26', content: '추가 댓글입니다. 스크롤을 내려 확인해보세요.', isHeartSelected: false, isReply: false },
+ { id: 6, author: 'Username', timestamp: '25/08/28 14:27', content: '새로운 댓글이 로드되었습니다.', isHeartSelected: false, isReply: false },
+ { id: 7, author: 'Username', timestamp: '25/08/28 14:28', content: '무한 스크롤 테스트 중입니다.', isHeartSelected: false, isReply: true },
+ { id: 8, author: 'Username', timestamp: '25/08/28 14:29', content: '여기까지 보인다면 성공입니다.', isHeartSelected: false, isReply: false },
+ { id: 9, author: 'Username', timestamp: '25/08/28 14:30', content: '마지막 댓글입니다.', isHeartSelected: false, isReply: false },
+];
+
+type Comment = (typeof mockComments)[0];
+
+type PostDetailPageProps = {
+ params: Promise<{
+ postId: string;
+ }>;
+};
+
+export default function CurationPostDetail({ params }: PostDetailPageProps) {
+ const [currentComments, setCurrentComments] = useState(mockComments);
+ const [postId, setPostId] = useState('');
+ const [mockPostData, setMockPostData] = useState(getMockPostData('1'));
+ const [replyMode, setReplyMode] = useState<{
+ isReply: boolean;
+ replyToId: number;
+ replyToAuthor: string;
+ } | undefined>(undefined);
+ const [showCommentInput, setShowCommentInput] = useState(true);
+ const [isReportModalOpen, setIsReportModalOpen] = useState(false);
+
+ // params 처리
+ useEffect(() => {
+ params.then(({ postId: pId }) => {
+ setPostId(pId);
+ setMockPostData(getMockPostData(pId));
+ });
+ }, [params]);
+
+ const handleAddComment = (content: string, isReply: boolean = false, replyToId?: number) => {
+ const newComment: Comment = {
+ id: Math.max(...currentComments.map(c => c.id), 0) + 1,
+ author: 'Username',
+ timestamp: new Date().toLocaleDateString('ko-KR', {
+ year: '2-digit',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ }).replace(/\. /g, '/').replace('.', ''),
+ content,
+ isHeartSelected: false,
+ isReply
+ };
+
+ if (isReply && replyToId) {
+ // 답글인 경우, 원댓글 바로 다음에 삽입
+ const replyToIndex = currentComments.findIndex(c => c.id === replyToId);
+ if (replyToIndex !== -1) {
+ const newComments = [...currentComments];
+ newComments.splice(replyToIndex + 1, 0, newComment);
+ setCurrentComments(newComments);
+ } else {
+ // 원댓글을 찾지 못한 경우 맨 위에 추가
+ setCurrentComments([newComment, ...currentComments]);
+ }
+ } else {
+ // 일반 댓글인 경우 맨 위에 추가
+ setCurrentComments([newComment, ...currentComments]);
+ }
+
+ // 답글 모드 해제
+ if (replyMode) {
+ setReplyMode(undefined);
+ }
+ };
+
+ const handleReply = (commentId: number, author: string) => {
+ setReplyMode({
+ isReply: true,
+ replyToId: commentId,
+ replyToAuthor: author
+ });
+ };
+
+ const handleCancelReply = () => {
+ setReplyMode(undefined);
+ };
+
+ const handleReportOpen = () => {
+ // 신고 모달 열렸을 때 댓글 입력창 숨기기
+ setIsReportModalOpen(true);
+ setShowCommentInput(false);
+ };
+
+ const handleReportClose = () => {
+ // 신고 모달 닫혔을 때 댓글 입력창 다시 보이기
+ setIsReportModalOpen(false);
+ setShowCommentInput(true);
+ };
+
+ return (
+
+
+ {/* Post Content */}
+
+
+
+
+
+
+
{mockPostData.author}
+
{mockPostData.timestamp} · {mockPostData.category}
+
+
+
+
+ {mockPostData.postType}
+
+
+
+
+
+
+
{mockPostData.title}
+
+
+
+ {mockPostData.content}
+
+
+
+ {mockPostData.tags.map(tag => {tag} )}
+
+
+ {mockPostData.images.length > 0 && (
+
+ {mockPostData.images.map((src, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ 좋아요
+
+
+
+ 스크랩
+
+
+
+
+
+
+
+
+
+ {/* Comments Section */}
+
+
+
+
+ {/* Comment Input - Fixed at bottom */}
+ {showCommentInput && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/curation/[postId]/report-button.tsx b/src/app/curation/[postId]/report-button.tsx
new file mode 100644
index 0000000..051b9a1
--- /dev/null
+++ b/src/app/curation/[postId]/report-button.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useState } from 'react';
+import { ReportModal } from './report-modal';
+
+interface ReportButtonProps {
+ postId: string;
+ composerId: string;
+}
+
+export function ReportButton({ postId, composerId }: ReportButtonProps) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ return (
+ <>
+ setIsModalOpen(true)}
+ className="p-1 hover:bg-gray-100 rounded-full transition-colors"
+ aria-label="신고하기"
+ >
+
+
+
+
+
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ postId={postId}
+ composerId={composerId}
+ />
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/app/curation/[postId]/report-modal.tsx b/src/app/curation/[postId]/report-modal.tsx
new file mode 100644
index 0000000..d2796ed
--- /dev/null
+++ b/src/app/curation/[postId]/report-modal.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useState } from 'react';
+
+interface ReportModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit?: () => void;
+ postId: string;
+ composerId: string;
+}
+
+const REPORT_REASONS = [
+ {
+ id: 1,
+ title: '1. 스팸/광고',
+ description: '무관한 상업 광고, 도배성 홍보, 외부 사이트 유도'
+ },
+ {
+ id: 2,
+ title: '2.부적절한 언어/욕설',
+ description: '비속어, 혐오 발언, 음란 표현 등'
+ },
+ {
+ id: 3,
+ title: '3.명예훼손/비방',
+ description: '특정 개인/단체에 대한 악의적 허위사실 유포'
+ },
+ {
+ id: 4,
+ title: '4.저작권 침해',
+ description: '불법 복제 음원/영상/악보/촬영, 무단 이미지 사용'
+ },
+ {
+ id: 5,
+ title: '5.주제와 무관한 내용',
+ description: '카테고리/토크룸 주제와 전혀 상관없는 내용'
+ },
+ {
+ id: 6,
+ title: '6.개인정보 유출',
+ description: '전화번호, 주소, 계좌번호 등 개인정보 노출'
+ },
+ {
+ id: 7,
+ title: '7.기타 (내용 입력 필수)',
+ description: '위 항목에 해당하지 않는 부적절한 콘텐츠'
+ }
+];
+
+export function ReportModal({ isOpen, onClose, onSubmit, postId, composerId }: ReportModalProps) {
+ const [selectedReason, setSelectedReason] = useState(null);
+ const [additionalComment, setAdditionalComment] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!selectedReason) return;
+
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch('/api/report', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ postId,
+ composerId,
+ reason: selectedReason,
+ comment: additionalComment,
+ }),
+ });
+
+ if (response.ok) {
+ alert('신고가 접수되었습니다.');
+ if (onSubmit) {
+ onSubmit();
+ } else {
+ onClose();
+ }
+ } else {
+ alert('신고 접수 중 오류가 발생했습니다.');
+ }
+ } catch (error) {
+ console.error('Report submission error:', error);
+ alert('신고 접수 중 오류가 발생했습니다.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleReasonSelect = (reasonId: number) => {
+ setSelectedReason(reasonId);
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Main Container - 375px width matching Figma */}
+
+ {/* Header */}
+
+
+
+
+
+ 등록
+
+
+
+
+
+ 등록
+
+
+
+
+
+
+
+ {/* Post Type Section */}
+
+ 게시글 유형
+
+
+ {/* Selected Post Type */}
+
+
+ {/* Title Section */}
+
+ 게시글 제목
+
+
+ {/* Report Reasons */}
+
+
신고 사유 선택
+
+
+ {REPORT_REASONS.map((reason) => (
+
+
handleReasonSelect(reason.id)}
+ className={`w-3 h-3 rounded-full border-[1.5px] flex-shrink-0 ${
+ selectedReason === reason.id
+ ? 'bg-[#293A92] border-[#293A92]'
+ : 'border-[#D9D9D9] bg-white'
+ }`}
+ />
+
+
+ {reason.title}
+
+
+ {reason.description}
+
+
+
+ ))}
+
+
+ {/* Additional Comment for "기타" option */}
+ {selectedReason === 7 && (
+
+ )}
+
+
+ {/* Post Title Input */}
+
+
+
+
+ {/* Content Section Header */}
+
+ 게시글 내용
+
+
+ {/* Content Input */}
+
+
+
+
+ {/* Hashtag Section */}
+
+ 해시태그 등록
+
+
+
+
+
+
+ {/* Content Attachment Section */}
+
+ 콘텐츠 첨부
+
+
+
+
+ 영상 링크 붙여넣기
+
+
+ 이미지 업로드
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/curation/components/FloatingButtons.tsx b/src/app/curation/components/FloatingButtons.tsx
new file mode 100644
index 0000000..ae4fb72
--- /dev/null
+++ b/src/app/curation/components/FloatingButtons.tsx
@@ -0,0 +1,36 @@
+'use client';
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+
+export default function FloatingButtons() {
+ const router = useRouter();
+
+ const scrollToTop = () => {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ };
+
+ return (
+ <>
+ {/* 글쓰기 버튼 - 중앙 하단 */}
+
+ router.push('/write')}
+ className="px-6 py-3 bg-blue-900 rounded-full shadow-lg flex items-center gap-1.5"
+ >
+
+ 글쓰기
+
+
+
+ {/* 맨 위로 버튼 - 오른쪽 하단 */}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/curation/components/Header.tsx b/src/app/curation/components/Header.tsx
new file mode 100644
index 0000000..7858e56
--- /dev/null
+++ b/src/app/curation/components/Header.tsx
@@ -0,0 +1,20 @@
+'use client';
+import Image from 'next/image';
+import { useRouter } from 'next/navigation';
+
+export default function Header() {
+ const router = useRouter();
+
+ return (
+
+ );
+}
diff --git a/src/app/curation/components/InfoBanner.tsx b/src/app/curation/components/InfoBanner.tsx
new file mode 100644
index 0000000..62d09d2
--- /dev/null
+++ b/src/app/curation/components/InfoBanner.tsx
@@ -0,0 +1,18 @@
+import Image from 'next/image';
+
+export default function InfoBanner() {
+ return (
+
+ {/* 작곡가 토크룸 소개 */}
+
+
+
+
+
+
나만의 이야기와 취향을 담아 클래식을 추천하는 공간
+
다람쥐 여러분, 누구나 이곳에서 큐레이터가 될 수 있습니다. 자신의 이야기를 담아 곡과 음반, 영상을 추천해보세요.
+
+
+
+ );
+}
diff --git a/src/app/curation/components/PostItem.tsx b/src/app/curation/components/PostItem.tsx
new file mode 100644
index 0000000..efeab03
--- /dev/null
+++ b/src/app/curation/components/PostItem.tsx
@@ -0,0 +1,84 @@
+import Image from 'next/image';
+import Link from 'next/link';
+
+interface Post {
+ id: number;
+ title: string;
+ content: string;
+ tags: string[];
+ likes: number;
+ comments: number;
+ author: string;
+ createdAt: string;
+ imageUrl?: string;
+}
+
+interface PostItemProps {
+ post: Post;
+ isFirst?: boolean;
+}
+
+export default function PostItem({ post, isFirst = false }: PostItemProps) {
+ const containerClasses = `flex justify-center items-center px-3 py-4 bg-white ${
+ !isFirst ? 'border-t border-zinc-200' : ''
+ }`;
+
+ return (
+
+
+
+
+ 큐레이션글
+
+
+
+ {post.title}
+
+
+ {post.content}
+
+
+
+
+ {post.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+
+
+ {post.likes}
+
+
+
+ {post.comments}
+
+
+ {post.createdAt}
+
+ {post.author}
+
+
+
+
+ {post.imageUrl ? (
+
+ {/* 외부 이미지를 주석 처리합니다.
+
+ */}
+ 이미지
+
+ ) : (
+
// 이미지가 없을 때 공간을 차지하도록 빈 div 추가
+ )}
+
+ );
+}
diff --git a/src/app/curation/components/PostList.tsx b/src/app/curation/components/PostList.tsx
new file mode 100644
index 0000000..3b7c198
--- /dev/null
+++ b/src/app/curation/components/PostList.tsx
@@ -0,0 +1,46 @@
+import PostItem from './PostItem';
+
+const mockPosts = [
+ {
+ id: 1,
+ title: '제목이 들어갈 자리입니다',
+ content: '글 내용이 들어갈 자리입니다. 글 내용은 한줄 제한을 할까말까 두줄도 괜찮은 것 같고',
+ tags: ['태그01', '태그02', '태그03'],
+ likes: 3,
+ comments: 5,
+ author: '글쓴이',
+ createdAt: '3초전',
+ imageUrl: 'https://via.placeholder.com/96',
+ },
+ {
+ id: 2,
+ title: '두 번째 큐레이션 글 제목',
+ content: '글 미리보기 내용이 들어갈 자리입니다.',
+ tags: ['클래식', '피아노', '쇼팽'],
+ likes: 12,
+ comments: 8,
+ author: '다람쥐',
+ createdAt: '1분전',
+ imageUrl: 'https://via.placeholder.com/96',
+ },
+ {
+ id: 3,
+ title: '세 번째 글입니다',
+ content: '이 글은 이미지가 없습니다.',
+ tags: ['바이올린', '협주곡'],
+ likes: 7,
+ comments: 2,
+ author: '큐레이터',
+ createdAt: '5분전',
+ },
+];
+
+export default function PostList() {
+ return (
+
+ {mockPosts.map((post, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/curation/components/SearchFilterBar.tsx b/src/app/curation/components/SearchFilterBar.tsx
new file mode 100644
index 0000000..0aba18d
--- /dev/null
+++ b/src/app/curation/components/SearchFilterBar.tsx
@@ -0,0 +1,23 @@
+import Image from 'next/image';
+
+export default function SearchFilterBar() {
+ return (
+
+ );
+}
diff --git a/src/app/curation/layout.tsx b/src/app/curation/layout.tsx
new file mode 100644
index 0000000..1580a69
--- /dev/null
+++ b/src/app/curation/layout.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+export default function CurationLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children}
;
+}
diff --git a/src/app/curation/page.tsx b/src/app/curation/page.tsx
new file mode 100644
index 0000000..a538be3
--- /dev/null
+++ b/src/app/curation/page.tsx
@@ -0,0 +1,19 @@
+import Header from './components/Header';
+import InfoBanner from './components/InfoBanner';
+import SearchFilterBar from './components/SearchFilterBar';
+import PostList from './components/PostList';
+import FloatingButtons from './components/FloatingButtons';
+
+export default function CurationPage() {
+ return (
+
+ );
+}
diff --git a/src/app/free-talk/[postId]/comment-input.tsx b/src/app/free-talk/[postId]/comment-input.tsx
new file mode 100644
index 0000000..d59da25
--- /dev/null
+++ b/src/app/free-talk/[postId]/comment-input.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+// 자유 토크룸 댓글 입력 (큐레이션 버전 재사용)
+import React, { useState } from 'react';
+import Image from 'next/image';
+
+interface CommentInputProps {
+ onSubmitComment: (content: string, isReply?: boolean, replyToId?: number) => void;
+ replyMode?: { isReply: boolean; replyToId: number; replyToAuthor: string } | null;
+ onCancelReply?: () => void;
+ hidden?: boolean;
+}
+
+export default function CommentInput({ onSubmitComment, replyMode, onCancelReply, hidden }: CommentInputProps) {
+ const [content, setContent] = useState('');
+ const [isExpanded, setIsExpanded] = useState(false);
+ const MAX_CHARS = 28;
+
+ const handleSubmit = () => {
+ if (!content.trim()) return;
+ onSubmitComment(content.trim(), replyMode?.isReply || false, replyMode?.replyToId);
+ setContent('');
+ setIsExpanded(false);
+ onCancelReply?.();
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (content.trim() && content.length <= MAX_CHARS) handleSubmit();
+ }
+ };
+
+ const isOverLimit = content.length > MAX_CHARS;
+ const canSubmit = content.trim() && !isOverLimit;
+
+ return (
+
+ {replyMode?.isReply && (
+
+
+ @{replyMode.replyToAuthor}님에게 답글
+ ✕
+
+
+ )}
+
+
+
+
+
+
+ {!isExpanded ? (
+ <>
+
setIsExpanded(true)} className="flex items-center justify-center text-[14px] font-medium leading-[17px] text-[#A6A6A6] flex-1">댓글을 남겨주세요
+
+ >
+ ) : (
+
+
+ )}
+
+
+ {isExpanded && (
+
+ {content.length}/{MAX_CHARS}
+ 등록
+ { setContent(''); setIsExpanded(false); onCancelReply?.(); }} className="px-3 py-2 text-sm text-[#A6A6A6] hover:text-[#4C4C4C]">취소
+
+ )}
+
+
+ );
+}
diff --git a/src/app/free-talk/[postId]/comment-item.tsx b/src/app/free-talk/[postId]/comment-item.tsx
new file mode 100644
index 0000000..1bf3588
--- /dev/null
+++ b/src/app/free-talk/[postId]/comment-item.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { useState } from 'react';
+import Image from 'next/image';
+import { ReportModal } from './report-modal';
+
+export interface CommentData {
+ id: number;
+ author: string;
+ timestamp: string;
+ content: string;
+ isHeartSelected?: boolean;
+ isReply?: boolean;
+}
+
+interface CommentItemProps {
+ comment: CommentData;
+ composerId?: string;
+ onReply?: (commentId: number, author: string) => void;
+ onReportOpen?: () => void;
+ onReportClose?: () => void;
+}
+
+export default function CommentItem({ comment, composerId, onReply, onReportOpen, onReportClose }: CommentItemProps) {
+ const [isReportModalOpen, setIsReportModalOpen] = useState(false);
+ const [isHeartSelected, setIsHeartSelected] = useState(comment.isHeartSelected || false);
+
+ const handleReplyClick = () => onReply?.(comment.id, comment.author);
+ const handleHeartClick = () => setIsHeartSelected(v => !v); // TODO: backend sync
+ const handleReportClick = () => { setIsReportModalOpen(true); onReportOpen?.(); };
+ const handleReportModalClose = () => { setIsReportModalOpen(false); onReportClose?.(); };
+
+ return (
+ <>
+ {isReportModalOpen && (
+
+ )}
+
+
+
+
+
+
+
{comment.author}
+
{comment.timestamp}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/free-talk/[postId]/comment-list.tsx b/src/app/free-talk/[postId]/comment-list.tsx
new file mode 100644
index 0000000..b0e10db
--- /dev/null
+++ b/src/app/free-talk/[postId]/comment-list.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import CommentItem, { CommentData } from './comment-item';
+
+const COMMENTS_PER_PAGE = 5;
+
+interface CommentListProps {
+ composerId?: string;
+ initialComments: CommentData[];
+ onReply?: (commentId: number, author: string) => void;
+ onReportOpen?: () => void;
+ onReportClose?: () => void;
+}
+
+export default function CommentList({ composerId, initialComments, onReply, onReportOpen, onReportClose }: CommentListProps) {
+ const [comments, setComments] = useState(initialComments.slice(0, COMMENTS_PER_PAGE));
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(initialComments.length > COMMENTS_PER_PAGE);
+ const loader = useRef(null);
+
+ useEffect(() => {
+ const currentPageComments = initialComments.slice(0, page * COMMENTS_PER_PAGE);
+ setComments(currentPageComments);
+ setHasMore(currentPageComments.length < initialComments.length);
+ }, [initialComments, page]);
+
+ const loadMoreComments = useCallback(() => {
+ if (!hasMore) return;
+ const nextPage = page + 1;
+ const newComments = initialComments.slice(0, nextPage * COMMENTS_PER_PAGE);
+ setComments(newComments);
+ setPage(nextPage);
+ if (newComments.length >= initialComments.length) setHasMore(false);
+ }, [hasMore, page, initialComments]);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(entries => {
+ if (entries[0].isIntersecting && hasMore) {
+ setTimeout(() => loadMoreComments(), 400);
+ }
+ }, { threshold: 1.0 });
+ const current = loader.current;
+ if (current) observer.observe(current);
+ return () => { if (current) observer.unobserve(current); };
+ }, [hasMore, loadMoreComments]);
+
+ return (
+ <>
+ {comments.map(c => (
+
+ ))}
+ {hasMore && 댓글을 불러오는 중...
}
+ >
+ );
+}
diff --git a/src/app/free-talk/[postId]/page.tsx b/src/app/free-talk/[postId]/page.tsx
new file mode 100644
index 0000000..8c3ee48
--- /dev/null
+++ b/src/app/free-talk/[postId]/page.tsx
@@ -0,0 +1,331 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import CommentInput from './comment-input';
+import CommentList from './comment-list';
+import { ReportButton } from './report-button';
+
+// 추후 실제 API 연동을 위한 타입
+interface FreeTalkPost {
+ id: string;
+ author: string;
+ userId: string;
+ title: string;
+ content: string;
+ tags: string[];
+ createdAt: string; // ISO string
+ likes: number;
+ comments: number;
+ hasImage: boolean;
+ images?: string[];
+}
+
+interface CommentData {
+ id: number;
+ author: string;
+ timestamp: string;
+ content: string;
+ isHeartSelected?: boolean;
+ isReply?: boolean;
+ parentId?: number;
+}
+
+/**
+ * ===============================
+ * DATA FETCHING LAYER (SWAPPABLE)
+ * ===============================
+ * 1) 현재는 MOCK 데이터를 사용합니다.
+ * 2) 나중에 실제 백엔드 URL을 전달받으면 REAL fetch 부분의 주석을 해제하고
+ * USE_MOCK = false 로 바꾸면 그대로 동작하도록 설계했습니다.
+ * 3) 에러/로딩/변환(어댑터) 한 곳에서 처리 → 컴포넌트 깔끔 유지.
+ */
+
+// ----- 환경 스위치 -----
+const USE_MOCK = true; // 실제 연동 시 false 로 변경
+const USE_COMMENT_API = true; // 댓글 POST/GET 사용 여부 (USE_MOCK=false 와 함께 실사용 권장)
+
+// ----- 실제 API 엔드포인트 (나중에 URL 주입) -----
+// const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://api.example.com';
+// const ENDPOINT = (postId: string) => `${API_BASE}/free-talk/${postId}`;
+
+// ----- 공용 어댑터 (백엔드 응답 -> FreeTalkPost) -----
+// interface FreeTalkPostApiResponse { ...백엔드 스키마... }
+// function adaptFreeTalkPost(res: FreeTalkPostApiResponse): FreeTalkPost { return { ... } }
+
+// ----- MOCK 구현 -----
+async function fetchMockPost(postId: string): Promise {
+ await new Promise(r => setTimeout(r, 200));
+ return {
+ id: postId,
+ author: '익명다람쥐',
+ userId: 'user123',
+ title: `샘플 자유글 제목 ${postId}`,
+ content: '이곳은 자유 토크룸 글 내용입니다. 백엔드 연동 후 실제 데이터가 표기됩니다.\n여러 줄도 정상적으로 표현됩니다.',
+ tags: ['#잡담', '#클래식', '#일상'],
+ createdAt: new Date().toISOString(),
+ likes: 5,
+ comments: 2,
+ hasImage: true,
+ images: ['/icons/img.svg']
+ };
+}
+
+// ----- REAL FETCH (나중에 사용) -----
+// async function fetchRealPost(postId: string): Promise {
+// const res = await fetch(ENDPOINT(postId), {
+// method: 'GET',
+// headers: {
+// 'Content-Type': 'application/json',
+// // 필요 시 인증 토큰 추가: Authorization: `Bearer ${token}`
+// },
+// // 캐시 전략 (SSR/ISR 고려 시 조정):
+// // next: { revalidate: 60 }
+// });
+// if (!res.ok) throw new Error('게시글 요청 실패');
+// const json = await res.json();
+// // return adaptFreeTalkPost(json);
+// // 임시: 백엔드 스키마 확정 전 직렬화 예시
+// return json as FreeTalkPost; // (스키마 확정 후 어댑터 적용 권장)
+// }
+
+// ----- Unified Fetch Wrapper -----
+async function fetchPostDetail(postId: string): Promise {
+ if (USE_MOCK) return fetchMockPost(postId);
+ // return fetchRealPost(postId);
+ return fetchMockPost(postId); // 안전장치 (실제 전환 시 위 라인 주석 해제)
+}
+
+interface PageProps {
+ params: Promise<{ postId: string }>; // Next 15 app router async params
+}
+
+export default function FreeTalkPostDetail({ params }: PageProps) {
+ const [post, setPost] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [postId, setPostId] = useState('');
+ const [liked, setLiked] = useState(false);
+ const [likesCount, setLikesCount] = useState(0);
+ const [bookmarked, setBookmarked] = useState(false);
+
+ // 댓글 상태
+ const [comments, setComments] = useState([]);
+ const [replyMode, setReplyMode] = useState<{ isReply: boolean; replyToId: number; replyToAuthor: string } | null>(null);
+ const [hideInput, setHideInput] = useState(false); // 신고 모달 열릴 때 입력 숨김
+
+ useEffect(() => {
+ let active = true;
+ params.then(async ({ postId }) => {
+ setPostId(postId);
+ try {
+ const data = await fetchPostDetail(postId);
+ if (active) {
+ setPost(data);
+ }
+ } catch (e: any) {
+ if (active) setError(e.message || '게시글을 불러오지 못했습니다.');
+ } finally {
+ if (active) setLoading(false);
+ }
+ });
+ return () => { active = false; };
+ }, [params]);
+
+ // 초기 Mock 댓글 세팅 (포스트 로드 후 1회)
+ useEffect(() => {
+ if (post) {
+ setLikesCount(post.likes);
+ // 샘플 댓글 8개 (답글 포함)
+ const mock: CommentData[] = [
+ { id: 1, author: '익명1', timestamp: '1분 전', content: '재밌는 글이네요!', isHeartSelected: false },
+ { id: 2, author: '익명2', timestamp: '3분 전', content: '동의합니다 ㅎㅎ', isHeartSelected: true },
+ { id: 3, author: '익명3', timestamp: '5분 전', content: '클래식은 언제나 옳죠', isHeartSelected: false },
+ { id: 4, author: '익명4', timestamp: '10분 전', content: '좋은 하루 되세요', isHeartSelected: false },
+ { id: 5, author: '익명5', timestamp: '15분 전', content: '저도 비슷한 경험!', isHeartSelected: false },
+ { id: 6, author: '익명6', timestamp: '20분 전', content: '답글 예시', isHeartSelected: false, isReply: true, parentId: 2 },
+ { id: 7, author: '익명7', timestamp: '25분 전', content: '또 다른 댓글', isHeartSelected: false },
+ { id: 8, author: '익명8', timestamp: '30분 전', content: '또 다른 답글', isHeartSelected: false, isReply: true, parentId: 7 }
+ ];
+ setComments(mock);
+ }
+ }, [post]);
+
+ const handleToggleLike = () => {
+ setLiked(prev => !prev);
+ setLikesCount(c => (liked ? c - 1 : c + 1));
+ // TODO: backend sync
+ };
+
+ const handleToggleBookmark = () => {
+ setBookmarked(prev => !prev);
+ // TODO: backend sync
+ };
+
+ const handleAddComment = async (content: string, isReply?: boolean, replyToId?: number) => {
+ // 낙관적 UI 반영
+ const optimisticId = Date.now();
+ const optimistic: CommentData = {
+ id: optimisticId,
+ author: '익명다람쥐',
+ timestamp: '방금',
+ content,
+ isReply: !!isReply,
+ parentId: replyToId
+ };
+ setComments(prev => [...prev, optimistic]);
+ if (!isReply) setPost(p => p ? { ...p, comments: p.comments + 1 } : p);
+ setReplyMode(null);
+
+ if (USE_COMMENT_API) {
+ try {
+ const res = await fetch(`/api/free-talk/${postId}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content, parentId: replyToId })
+ });
+ if (res.ok) {
+ const json = await res.json();
+ if (json?.comment) {
+ // 낙관적 항목 교체
+ setComments(prev => prev.map(c => c.id === optimisticId ? { ...json.comment, timestamp: '방금' } : c));
+ }
+ } else {
+ throw new Error('댓글 전송 실패');
+ }
+ } catch (e) {
+ console.error(e);
+ // 롤백
+ setComments(prev => prev.filter(c => c.id !== optimisticId));
+ if (!isReply) setPost(p => p ? { ...p, comments: Math.max(0, p.comments - 1) } : p);
+ alert('댓글 전송에 실패했습니다. 다시 시도해주세요.');
+ }
+ }
+ };
+
+ const handleReply = (commentId: number, author: string) => {
+ setReplyMode({ isReply: true, replyToId: commentId, replyToAuthor: author });
+ };
+
+ const handleReportOpen = () => setHideInput(true);
+ const handleReportClose = () => setHideInput(false);
+
+ if (loading) {
+ return (
+
+ 로딩 중...
+
+ );
+ }
+
+ if (error || !post) {
+ return (
+
+ {error || '게시글을 찾을 수 없습니다.'}
+
+ );
+ }
+
+ const formattedDate = new Date(post.createdAt).toLocaleString('ko-KR', { hour12: false });
+
+ return (
+ {/* bottom padding for fixed input */}
+
+ {/* Header */}
+
+
+
+
+
+
+
자유 토크룸
+
+ setHideInput(o)} />
+
+
+
+ {/* Writer Info */}
+
+
+
+
+
{post.author}
+
{formattedDate}
+
+
+
+
+ {/* Title */}
+
+
{post.title}
+ {/* Content */}
+
{post.content}
+
+
+ {/* Tags */}
+
+ {post.tags.map(tag => {tag} )}
+
+
+ {/* Images */}
+ {post.images && post.images.length > 0 && (
+
+ {post.images.slice(0,3).map((src, idx) => (
+
+
+
+ ))}
+
+ )}
+
+ {/* Reactions */}
+
+
+
+
+
+
+ 좋아요{likesCount > 0 ? ` ${likesCount}` : ''}
+
+
+
+
+
+ 스크랩
+
+
+
+
+
+
+
+
+ {/* 댓글 영역 */}
+
+
+
+
댓글
+ 총 {post.comments}개
+
+
a.id - b.id)}
+ onReply={handleReply}
+ onReportOpen={handleReportOpen}
+ onReportClose={handleReportClose}
+ />
+
+
+
setReplyMode(null)}
+ hidden={hideInput}
+ />
+
+ );
+}
diff --git a/src/app/free-talk/[postId]/report-button.tsx b/src/app/free-talk/[postId]/report-button.tsx
new file mode 100644
index 0000000..6afed58
--- /dev/null
+++ b/src/app/free-talk/[postId]/report-button.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useState } from 'react';
+import { ReportModal } from './report-modal';
+
+interface ReportButtonProps {
+ postId: string;
+ composerId: string;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function ReportButton({ postId, composerId, onOpenChange }: ReportButtonProps) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ return (
+ <>
+ { setIsModalOpen(true); onOpenChange?.(true); }}
+ className="p-1 hover:bg-gray-100 rounded-full transition-colors"
+ aria-label="신고하기"
+ >
+
+
+
+
+ {isModalOpen && (
+ { setIsModalOpen(false); onOpenChange?.(false); }}
+ postId={postId}
+ composerId={composerId}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/app/free-talk/[postId]/report-modal.tsx b/src/app/free-talk/[postId]/report-modal.tsx
new file mode 100644
index 0000000..6d6f097
--- /dev/null
+++ b/src/app/free-talk/[postId]/report-modal.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import { useState } from 'react';
+
+interface ReportModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit?: () => void;
+ postId: string;
+ composerId: string;
+}
+
+// 자유 토크룸 전용 신고 사유 목록
+const REPORT_REASONS = [
+ { id: 1, title: '1. 스팸/광고', description: '무관한 상업 광고, 도배성 홍보, 외부 사이트 유도' },
+ { id: 2, title: '2. 부적절한 언어/욕설', description: '비속어, 혐오 발언, 음란 표현 등' },
+ { id: 3, title: '3. 명예훼손/비방', description: '특정 개인/단체에 대한 악의적 허위사실 유포' },
+ { id: 4, title: '4. 저작권 침해', description: '불법 복제 음원/영상/악보/촬영, 무단 이미지 사용' },
+ { id: 5, title: '5. 주제와 무관한 내용', description: '카테고리/토크룸 주제와 전혀 상관없는 내용' },
+ { id: 6, title: '6. 개인정보 유출', description: '전화번호, 주소, 계좌번호 등 개인정보 노출' },
+ { id: 7, title: '7. 기타 (내용 입력 필수)', description: '위 항목에 해당하지 않는 부적절한 콘텐츠' }
+];
+
+export function ReportModal({ isOpen, onClose, onSubmit, postId, composerId }: ReportModalProps) {
+ const [selectedReason, setSelectedReason] = useState(null);
+ const [additionalComment, setAdditionalComment] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!selectedReason) return;
+ setIsSubmitting(true);
+ try {
+ const response = await fetch('/api/report', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ postId, composerId, reason: selectedReason, comment: additionalComment })
+ });
+ if (response.ok) {
+ alert('신고가 접수되었습니다.');
+ onSubmit ? onSubmit() : onClose();
+ } else {
+ alert('신고 접수 중 오류가 발생했습니다.');
+ }
+ } catch (e) {
+ console.error(e);
+ alert('신고 접수 중 오류가 발생했습니다.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+ {/* Post Type Section */}
+
+ 게시글 유형
+
+
+
+ {/* Title Section */}
+
+ 게시글 제목
+
+
+
+
+
+ {/* Report Reasons */}
+
+
신고 사유 선택
+
+ {REPORT_REASONS.map(r => (
+
+
setSelectedReason(r.id)} className={`w-3 h-3 rounded-full border-[1.5px] ${selectedReason === r.id ? 'bg-[#293A92] border-[#293A92]' : 'border-[#D9D9D9] bg-white'}`} />
+
+
{r.title}
+
{r.description}
+
+
+ ))}
+
+ {selectedReason === 7 && (
+
+ )}
+
+
+ {/* Content Section */}
+
+ 게시글 내용
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/free-talk/components/Header.tsx b/src/app/free-talk/components/Header.tsx
new file mode 100644
index 0000000..85ae624
--- /dev/null
+++ b/src/app/free-talk/components/Header.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useCallback } from 'react';
+
+interface HeaderProps {
+ title?: string;
+ /** 기본: history back */
+ onBack?: () => void;
+ /** 신고 아이콘 표시 여부 */
+ showReport?: boolean;
+ /** 신고 클릭 핸들러 (모달 열기 등) */
+ onReportClick?: () => void;
+ /** 추가 클래스 */
+ className?: string;
+ /** 상단 고정 여부 */
+ sticky?: boolean;
+}
+
+/**
+ * 자유 토크룸 헤더 (디자인 시안 반영)
+ * - 폭 고정 384px (w-96)
+ * - 좌: 뒤로가기 / 중앙: 타이틀 / 우: 신고 버튼 (옵션)
+ * - 넓은 터치 영역 (38~44px) 확보
+ */
+export default function Header({
+ title = '자유 토크룸',
+ onBack,
+ showReport = false,
+ onReportClick,
+ className,
+ sticky = false
+}: HeaderProps) {
+ const router = useRouter();
+
+ const handleBack = useCallback(() => {
+ if (onBack) return onBack();
+ // history stack이 없을 수도 있으니 fallback 홈
+ if (typeof window !== 'undefined' && window.history.length > 1) {
+ router.back();
+ } else {
+ router.push('/free-talk');
+ }
+ }, [onBack, router]);
+
+ const handleReport = useCallback(() => {
+ onReportClick?.();
+ }, [onReportClick]);
+
+ const headerClass = [
+ 'w-full flex justify-center bg-white',
+ sticky ? 'sticky top-0 z-40 shadow-[0_1px_0_0_rgba(0,0,0,0.04)]' : '',
+ className || ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ );
+}
diff --git a/src/app/free-talk/components/InfoBanner.tsx b/src/app/free-talk/components/InfoBanner.tsx
new file mode 100644
index 0000000..3b2ef0e
--- /dev/null
+++ b/src/app/free-talk/components/InfoBanner.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import Image from 'next/image';
+
+export default function InfoBanner() {
+ return (
+
+
+
+
+
+
+ 클래식부터 일상까지 자유롭게 이야기를 주고받는 공간
+
+
+ 이용수칙을 준수하여 자유롭게 다람쥐들과 대화를 나눠보세요.
+
+
+
+ );
+}
diff --git a/src/app/free-talk/components/PostItem.tsx b/src/app/free-talk/components/PostItem.tsx
new file mode 100644
index 0000000..e0249fa
--- /dev/null
+++ b/src/app/free-talk/components/PostItem.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+
+interface PostItemProps {
+ id: number;
+ title: string;
+ content: string;
+ tags: string[];
+ likes: number;
+ comments: number;
+ timeAgo: string;
+ author: string;
+ hasImage?: boolean;
+}
+
+export default function PostItem({
+ id,
+ title,
+ content,
+ tags,
+ likes,
+ comments,
+ timeAgo,
+ author,
+ hasImage = true
+}: PostItemProps) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {content}
+
+
+
+
+
+ {tags.slice(0, 3).map((tag, index) => (
+
+ #{tag}
+
+ ))}
+
+
+
+
+
+ {likes}
+
+
+
+ {comments}
+
+
{timeAgo}
+
+
{author}
+
+
+
+
+ {hasImage && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/free-talk/components/PostList.tsx b/src/app/free-talk/components/PostList.tsx
new file mode 100644
index 0000000..3d92b73
--- /dev/null
+++ b/src/app/free-talk/components/PostList.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import PostItem from './PostItem';
+
+const samplePosts = [
+ {
+ id: 1,
+ title: "베토벤 교향곡 9번 '합창' 후기",
+ content: "오늘 처음으로 베토벤 9번 교향곡을 끝까지 들어봤는데, 마지막 합창 부분에서 소름이 돋았어요. 클래식 초보인데도 이런 감동을 느낄 수 있다니...",
+ tags: ["베토벤", "교향곡9번", "클래식입문"],
+ likes: 15,
+ comments: 4,
+ timeAgo: "10분 전",
+ author: "클래식새내기",
+ hasImage: true
+ },
+ {
+ id: 2,
+ title: "요즘 자주 듣는 쇼팽 녹턴 추천합니다.",
+ content: "밤에 일기 쓰거나 책 읽을 때 쇼팽 녹턴을 자주 들어요. 특히 2번이랑 20번은 마음이 편안해지는 느낌이라 좋아요. 다른 분들은 어떤 곡 좋아하세요?",
+ tags: ["쇼팽", "녹턴", "피아노"],
+ likes: 22,
+ comments: 8,
+ timeAgo: "1시간 전",
+ author: "감성다람쥐",
+ hasImage: true
+ },
+ {
+ id: 3,
+ title: "클래식 공연 같이 갈 친구 구해요!",
+ content: "이번 주말에 예술의전당에서 하는 오케스트라 공연 예매했는데 같이 갈 친구가 갑자기 약속을 취소했네요. 클래식 좋아하는 분 중에 같이 가실 분 있을까요?",
+ tags: ["공연", "예술의전당", "친구구함"],
+ likes: 5,
+ comments: 12,
+ timeAgo: "3시간 전",
+ author: "공연메이트",
+ hasImage: false
+ },
+ {
+ id: 4,
+ title: "다들 클래식 입문 어떤 곡으로 하셨나요?",
+ content: "저는 비발디의 '사계'로 클래식에 처음 입문했어요. 각 계절을 음악으로 표현한 게 너무 신기하고 재미있더라고요. 다른 분들의 입문곡도 궁금해요!",
+ tags: ["입문곡", "비발디", "사계"],
+ likes: 31,
+ comments: 15,
+ timeAgo: "5시간 전",
+ author: "궁금한도토리",
+ hasImage: true
+ }
+];
+
+export default function PostList({ searchTerm }: { searchTerm?: string }) {
+ const filteredPosts = samplePosts.filter(post =>
+ post.title.toLowerCase().includes(searchTerm?.toLowerCase() || '') ||
+ post.content.toLowerCase().includes(searchTerm?.toLowerCase() || '')
+ );
+
+ return (
+
+ {filteredPosts.map((post) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/free-talk/components/SearchFilterBar.tsx b/src/app/free-talk/components/SearchFilterBar.tsx
new file mode 100644
index 0000000..0fe7d83
--- /dev/null
+++ b/src/app/free-talk/components/SearchFilterBar.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Image from 'next/image';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+export default function SearchFilterBar() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [searchTerm, setSearchTerm] = useState('');
+
+ useEffect(() => {
+ setSearchTerm(searchParams.get('search') || '');
+ }, [searchParams]);
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ router.push(`/free-talk?search=${searchTerm}`);
+ };
+
+ const handleRulesClick = () => {
+ // TODO: 규칙 모달 열기 / 페이지 이동 등 원하는 동작 연결
+ console.log('이용수칙 버튼 클릭');
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/free-talk/page.tsx b/src/app/free-talk/page.tsx
new file mode 100644
index 0000000..c339b20
--- /dev/null
+++ b/src/app/free-talk/page.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import Header from './components/Header';
+import InfoBanner from './components/InfoBanner';
+import SearchFilterBar from './components/SearchFilterBar';
+import PostList from './components/PostList';
+import WriteButton from '@/components/WriteButton';
+
+export default function FreeTalkPage({
+ searchParams,
+}: {
+ searchParams?: {
+ search?: string;
+ };
+}) {
+ const searchTerm = searchParams?.search || '';
+
+ return (
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 9098302..d7075b3 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -11,7 +11,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* 헤더 */}
-
{/* 페이지 콘텐츠 (children) */}
{children}
diff --git a/src/app/loginpage/page.tsx b/src/app/loginpage/page.tsx
index 22bbb3a..df8b9e5 100644
--- a/src/app/loginpage/page.tsx
+++ b/src/app/loginpage/page.tsx
@@ -1,9 +1,11 @@
'use client';
import React, { useState } from 'react';
+import { useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import ToastNotification from '../../components/ToastNotification';
+import { useUserProfileStore } from '../../store/userProfileStore';
const loginpage = () => {
const [email, setEmail] = useState('');
@@ -11,6 +13,8 @@ const loginpage = () => {
const [showPassword, setShowPassword] = useState(false);
const [toast, setToast] = useState({ show: false, message: '' });
const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+ const setProfile = useUserProfileStore((state) => state.setProfile);
const handleLogin = async () => {
// 입력 검증
@@ -24,6 +28,51 @@ const loginpage = () => {
return;
}
+ // 관리자 임시 로그인 우회
+ //보안위험**************
+ if (email.trim() === 'admin@gmail.com' && password === 'admin1234!') {
+ setToast({ show: true, message: '관리자 계정으로 로그인되었습니다.' });
+ // 임시 JWT (exp: 24시간 후)
+ const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
+ // 유니코드 안전 base64 인코딩 함수
+ function base64EncodeUnicode(str: string) {
+ return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
+ String.fromCharCode(parseInt(p1, 16))
+ ));
+ }
+
+ const adminJwt = [
+ base64EncodeUnicode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })),
+ base64EncodeUnicode(JSON.stringify({
+ email: 'admin@gmail.com',
+ name: '관리자',
+ nickname: 'admin',
+ })),
+ 'signature'
+ ].join('.')
+ localStorage.setItem('authToken', adminJwt);
+ setProfile({
+ name: '관리자',
+ nickname: 'admin',
+ email: 'admin@gmail.com',
+ bio: '관리자 계정',
+ profileImage: '/icons/profile.svg',
+ birthDate: '',
+ });
+ // 상태 확인용 콘솔 출력
+ // setTimeout(() => {
+ // const profileState = useUserProfileStore.getState().profile;
+ // console.log('localStorage user-profile-storage:', localStorage.getItem('user-profile-storage'));
+ // console.log('profile 상태:', profileState);
+ // console.log('isLoggedIn (profile !== null):', profileState !== null);
+ // }, 100);
+ setIsLoading(false);
+ setTimeout(() => {
+ router.push('/');
+ }, 500);
+ return;
+ }
+ //*********보안 위험*********//
setIsLoading(true);
try {
@@ -50,9 +99,13 @@ const loginpage = () => {
localStorage.setItem('authToken', data.token);
}
- // 메인 페이지로 이동 또는 리다이렉트
- // router.push('/') 등으로 페이지 이동
-
+ setTimeout(() => {
+ const profileState = useUserProfileStore.getState().profile;
+ console.log('localStorage user-profile-storage:', localStorage.getItem('user-profile-storage'));
+ console.log('profile 상태:', profileState);
+ console.log('isLoggedIn (profile !== null):', profileState !== null);
+ router.push('/');
+ }, 500);
} else {
// 로그인 실패
switch (response.status) {
diff --git a/src/app/loginpage/register/page.tsx b/src/app/loginpage/register/page.tsx
index 4921ddc..16a6c5e 100644
--- a/src/app/loginpage/register/page.tsx
+++ b/src/app/loginpage/register/page.tsx
@@ -24,19 +24,24 @@ const SignupPage = () => {
const [isCodeSent, setIsCodeSent] = useState(false);
const [tempSelectedDate, setTempSelectedDate] = useState(new Date(2002, 3, 18));
const [passwordError, setPasswordError] = useState('');
+ const [passwordCheckResult, setPasswordCheckResult] = useState<'valid' | 'invalid' | ''>('');
+ const [confirmPasswordChecked, setConfirmPasswordChecked] = useState<'valid' | 'invalid' | ''>('');
const [ageError, setAgeError] = useState('');
- // 모든 필드가 유효한지 검증
+ // 모든 필드가 유효한지 검증 (비밀번호 규약, 일치, 인증 포함)
const isFormValid = () => {
+ const passwordRuleValid = validatePasswordRule(formData.password);
+ const passwordMatch = formData.password === formData.confirmPassword && formData.password.trim() !== '';
return (
formData.name.trim() !== '' &&
formData.birthDate !== '' &&
ageError === '' &&
formData.email.trim() !== '' &&
isEmailVerified &&
- formData.password.trim() !== '' &&
- formData.confirmPassword.trim() !== '' &&
- passwordError === ''
+ passwordRuleValid &&
+ passwordMatch &&
+ passwordCheckResult === 'valid' &&
+ confirmPasswordChecked === 'valid'
);
};
@@ -53,6 +58,7 @@ const SignupPage = () => {
} else {
setPasswordError('');
}
+ setConfirmPasswordChecked(''); // 입력 시 결과 초기화
}
if (field === 'password') {
@@ -61,15 +67,52 @@ const SignupPage = () => {
} else {
setPasswordError('');
}
+ setPasswordCheckResult(''); // 비밀번호 입력 시 결과 초기화
+ setConfirmPasswordChecked(''); // 입력 시 결과 초기화
+ }
+ };
+ // 비밀번호 규약 체크 함수 (예시: 8~20자, 영문/숫자/특수문자 포함)
+ function validatePasswordRule(password: string): boolean {
+ // 8~20자, 영문/숫자/특수문자 모두 포함
+ const lengthValid = password.length >= 8 && password.length <= 20;
+ const hasLetter = /[a-zA-Z]/.test(password);
+ const hasNumber = /[0-9]/.test(password);
+ const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
+ return lengthValid && hasLetter && hasNumber && hasSpecial;
+ }
+
+ // 비밀번호 확인 버튼 클릭
+ const handlePasswordCheck = () => {
+ if (validatePasswordRule(formData.password)) {
+ setPasswordCheckResult('valid');
+ } else {
+ setPasswordCheckResult('invalid');
+ }
+ };
+
+ // 비밀번호 확인 버튼 (확인 비밀번호)
+ const handleConfirmPasswordCheck = () => {
+ if (
+ formData.password === formData.confirmPassword &&
+ formData.password.trim() !== '' &&
+ validatePasswordRule(formData.password)
+ ) {
+ setConfirmPasswordChecked('valid');
+ } else {
+ setConfirmPasswordChecked('invalid');
}
};
const handleSendVerificationCode = () => {
- setIsCodeSent(true);
+ if (formData.email.trim() !== '') {
+ setIsCodeSent(true);
+ }
};
const handleVerifyCode = () => {
- setIsEmailVerified(true);
+ if (formData.verificationCode.trim() !== '') {
+ setIsEmailVerified(true);
+ }
};
const handleTempDateChange = useCallback((selectedDate: Date) => {
@@ -290,9 +333,14 @@ const SignupPage = () => {
-
+
인증코드 전송
@@ -312,10 +360,14 @@ const SignupPage = () => {