diff --git a/next.config.ts b/next.config.ts index e9ffa30..7a9c8cf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "placehold.co", + port: "", + pathname: "/**", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index d1ced0e..1b30574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "classic-squirrels", "version": "0.1.0", "dependencies": { + "jwt-decode": "^4.0.0", "next": "15.5.0", "postcss": "^8.5.6", "react": "19.1.0", @@ -18,6 +19,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4.1.12", + "@types/jwt-decode": "^2.2.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1297,6 +1299,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jwt-decode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz", + "integrity": "sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", @@ -4173,6 +4182,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index a6e5b0e..9c9d05a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "jwt-decode": "^4.0.0", "next": "15.5.0", "postcss": "^8.5.6", "react": "19.1.0", @@ -19,6 +20,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4.1.12", + "@types/jwt-decode": "^2.2.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/icons/bookmark-off.svg b/public/icons/bookmark-off.svg new file mode 100644 index 0000000..1b76f59 --- /dev/null +++ b/public/icons/bookmark-off.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/icons/bookmark-on.svg b/public/icons/bookmark-on.svg new file mode 100644 index 0000000..ac7f326 --- /dev/null +++ b/public/icons/bookmark-on.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/icons/check.svg b/public/icons/check.svg new file mode 100644 index 0000000..f4c8588 --- /dev/null +++ b/public/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/curation-logo.svg b/public/icons/curation-logo.svg new file mode 100644 index 0000000..480e64e --- /dev/null +++ b/public/icons/curation-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/icons/freetalk-logo.svg b/public/icons/freetalk-logo.svg new file mode 100644 index 0000000..60fdcb9 --- /dev/null +++ b/public/icons/freetalk-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/rules_icon.svg b/public/icons/rules_icon.svg new file mode 100644 index 0000000..7131a24 --- /dev/null +++ b/public/icons/rules_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/share.svg b/public/icons/share.svg new file mode 100644 index 0000000..133aad2 --- /dev/null +++ b/public/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/api/free-talk/[postId]/comments/route.ts b/src/app/api/free-talk/[postId]/comments/route.ts new file mode 100644 index 0000000..df55f87 --- /dev/null +++ b/src/app/api/free-talk/[postId]/comments/route.ts @@ -0,0 +1,48 @@ +'use server'; + +import { NextResponse } from 'next/server'; + +// 간단한 인메모리 저장 (개발/Mock 용). 서버 재시작 시 초기화됨. +// 실제 서비스에서는 DB (Prisma / Drizzle 등) 로 대체. +const commentStore: Record = {}; + +// NOTE (Next.js 15): context.params is now async in route handlers for dynamic segments. +// You must await it before accessing properties, otherwise you'll see the +// warning: "`params` should be awaited before using its properties." +export async function POST( + req: Request, + context: { params: Promise<{ postId: string }> } +) { + const { postId } = await context.params; + try { + const body = await req.json(); + const { content, parentId, author = '익명다람쥐' } = body || {}; + if (!content || typeof content !== 'string') { + return NextResponse.json({ error: '내용이 비어있습니다.' }, { status: 400 }); + } + const list = commentStore[postId] || []; + const newComment = { + id: list.length ? list[list.length - 1].id + 1 : 1, + author, + timestamp: new Date().toISOString(), + content, + isHeartSelected: false, + isReply: !!parentId, + parentId: parentId ?? null + }; + commentStore[postId] = [...list, newComment]; + return NextResponse.json({ ok: true, comment: newComment }, { status: 201 }); + } catch (e) { + console.error('Comment POST error', e); + return NextResponse.json({ error: '서버 오류' }, { status: 500 }); + } +} + +export async function GET( + _req: Request, + context: { params: Promise<{ postId: string }> } +) { + const { postId } = await context.params; + const list = commentStore[postId] || []; + return NextResponse.json({ comments: list }, { status: 200 }); +} diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts deleted file mode 100644 index 4fc2b53..0000000 --- a/src/app/api/report/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { postId, composerId, reason, comment } = body; - - // 필수 필드 검증 - if (!postId || !composerId || !reason) { - return NextResponse.json( - { error: '필수 필드가 누락되었습니다.' }, - { status: 400 } - ); - } - - // TODO: 데이터베이스에 신고 내용 저장 - const reportData = { - postId, - composerId, - reason, - comment: comment || '', - reportedAt: new Date().toISOString(), - status: 'pending', // pending, reviewed, resolved - }; - - // 임시로 콘솔에 로그 출력 (실제로는 DB에 저장) - console.log('신고 접수:', reportData); - - // 실제 구현 시: - // const report = await db.reports.create({ data: reportData }); - - return NextResponse.json( - { - message: '신고가 접수되었습니다.', - reportId: `temp-${Date.now()}` // 임시 ID - }, - { status: 200 } - ); - - } catch (error) { - console.error('신고 처리 오류:', error); - return NextResponse.json( - { error: '신고 처리 중 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/composer-talk-room/[id]/[postId]/comment-item.tsx b/src/app/composer-talk-room/[id]/[postId]/comment-item.tsx index bd25701..3cf3ada 100644 --- a/src/app/composer-talk-room/[id]/[postId]/comment-item.tsx +++ b/src/app/composer-talk-room/[id]/[postId]/comment-item.tsx @@ -1,8 +1,10 @@ 'use client'; + import { useState } from 'react'; import Image from 'next/image'; import { ReportModal } from './report-modal'; +import LikeButton from '@/components/LikeButton'; export interface CommentItemProps { comment: { @@ -10,7 +12,7 @@ export interface CommentItemProps { author: string; timestamp: string; content: string; - isHeartSelected?: boolean; + isHeartSelected?: boolean; // API에서 받아온 디폴트 값 }; isReply?: boolean; composerId?: string; @@ -19,9 +21,9 @@ export interface CommentItemProps { 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) { @@ -29,14 +31,8 @@ const CommentItem = ({ comment, isReply = false, composerId, onReply, onReportOp } }; - const handleHeartClick = () => { - setIsHeartSelected(!isHeartSelected); - // TODO: 백엔드 API 호출로 하트 상태 업데이트 - }; - const handleReportClick = () => { setIsReportModalOpen(true); - // 신고 모달 열렸을 때 댓글 입력창 숨기기 if (onReportOpen) { onReportOpen(); } @@ -44,7 +40,6 @@ const CommentItem = ({ comment, isReply = false, composerId, onReply, onReportOp const handleReportModalClose = () => { setIsReportModalOpen(false); - // 신고 모달 닫혔을 때 댓글 입력창 다시 보이기 if (onReportClose) { onReportClose(); } @@ -70,12 +65,7 @@ const CommentItem = ({ comment, isReply = false, composerId, onReply, onReportOp

{comment.timestamp}

- +
+ {/* Post Image (conditionally rendered) */} + {post.imageUrl && ( +
+ Post image +
+ )} + + + ); } - // --- 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 */} +
+
+ profile +
+
+ + {/* Input Container */} +
+ {!isExpanded ? ( + /* Collapsed State - Placeholder */ + <> + +
+ + + +
+ + ) : ( + /* Expanded State - Textarea */ +
+