BIN
public/uploads/1762694665640-mepawpoqguh.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694722874-13r02smuxh0n.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694815229-575v2kyj72x.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695071878-s0a8nautp7d.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695378037-rbj4gzlxveq.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762696083342-gwebeuwl0q4.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/uploads/1762696149731-1fom3wudm94.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
23
src/app/api/auth/check-name/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { nameSchema } from "@/lib/validation/auth";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const nameRaw = searchParams.get("name") ?? "";
|
||||||
|
const name = nameRaw.trim();
|
||||||
|
|
||||||
|
const parsed = nameSchema.safeParse(name);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { fieldErrors: { name: [firstMsg] } } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.user.findFirst({ where: { name } });
|
||||||
|
return NextResponse.json({ available: !exists });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
23
src/app/api/auth/check-nickname/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { nicknameSchema } from "@/lib/validation/auth";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const nicknameRaw = searchParams.get("nickname") ?? "";
|
||||||
|
const nickname = nicknameRaw.trim();
|
||||||
|
|
||||||
|
const parsed = nicknameSchema.safeParse(nickname);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { fieldErrors: { nickname: [firstMsg] } } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({ where: { nickname } });
|
||||||
|
return NextResponse.json({ available: !exists });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -12,11 +12,18 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const parsed = loginSchema.safeParse(body);
|
const parsed = loginSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json(
|
||||||
const { nickname, password } = parsed.data;
|
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
const { id, password } = parsed.data;
|
||||||
|
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
||||||
|
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
||||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
|
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,40 @@ export async function POST(req: Request) {
|
|||||||
const parsed = registerSchema.safeParse(body);
|
const parsed = registerSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const { nickname, name, password } = parsed.data;
|
const { nickname, name, password, profileImage } = parsed.data as {
|
||||||
const exists = await prisma.user.findFirst({ where: { nickname } });
|
nickname: string;
|
||||||
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
|
name: string;
|
||||||
|
password: string;
|
||||||
|
profileImage?: string;
|
||||||
|
};
|
||||||
|
// 아이디(닉네임 필드와 구분)를 우선 검사
|
||||||
|
const nicknameExists = await prisma.user.findFirst({ where: { nickname } });
|
||||||
|
if (nicknameExists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { fieldErrors: { nickname: ["이미 사용 중인 아이디입니다"] } } },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 표시용 닉네임(name)도 유일해야 함
|
||||||
|
const nameExists = await prisma.user.findFirst({ where: { name } });
|
||||||
|
if (nameExists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { fieldErrors: { name: ["이미 사용 중인 닉네임입니다"] } } },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
nickname,
|
nickname,
|
||||||
name,
|
name,
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
|
// 일부 환경에서 birth 컬럼이 NOT NULL 제약으로 남아있는 경우가 있어 안전 기본값을 기록
|
||||||
|
birth: new Date(0),
|
||||||
|
// 일부 환경에서 phone 컬럼이 NOT NULL+UNIQUE 제약으로 남아있는 경우가 있어
|
||||||
|
// 임시 유니크 플레이스홀더를 기록합니다. (후속 마이그레이션으로 NULL 허용 권장)
|
||||||
|
phone: `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
profileImage: profileImage || null,
|
||||||
},
|
},
|
||||||
select: { userId: true, nickname: true },
|
select: { userId: true, nickname: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,11 +29,18 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const parsed = loginSchema.safeParse(body);
|
const parsed = loginSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json(
|
||||||
const { nickname, password } = parsed.data;
|
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
const { id, password } = parsed.data;
|
||||||
|
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
||||||
|
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
||||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 사용자의 관리자 권한 여부 확인
|
// 사용자의 관리자 권한 여부 확인
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
|||||||
43
src/app/api/me/password/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getUserIdOrAdmin } from "@/lib/auth";
|
||||||
|
import { verifyPassword, hashPassword } from "@/lib/password";
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const userId = await getUserIdOrAdmin(req);
|
||||||
|
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const currentPassword: string | undefined = body?.currentPassword;
|
||||||
|
const newPassword: string | undefined = body?.newPassword;
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8 || newPassword.length > 100) {
|
||||||
|
return NextResponse.json({ error: "password length invalid" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
});
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
return NextResponse.json({ error: "invalid user" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!verifyPassword(currentPassword, user.passwordHash)) {
|
||||||
|
return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (verifyPassword(newPassword, user.passwordHash)) {
|
||||||
|
// 새 비밀번호가 기존과 동일
|
||||||
|
return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { passwordHash: hashPassword(newPassword) },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
77
src/app/api/messages/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
|
||||||
|
const sendSchema = z.object({
|
||||||
|
receiverId: z.string().min(1, "수신자 정보가 없습니다"),
|
||||||
|
body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const senderId = getUserIdFromRequest(req);
|
||||||
|
if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
let data: any = {};
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
data = await req.json().catch(() => ({}));
|
||||||
|
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||||
|
const form = await req.formData();
|
||||||
|
data = Object.fromEntries(form.entries());
|
||||||
|
} else {
|
||||||
|
// 기본적으로 form 으로 간주
|
||||||
|
const form = await req.formData().catch(() => null);
|
||||||
|
if (form) data = Object.fromEntries(form.entries());
|
||||||
|
}
|
||||||
|
const parsed = sendSchema.safeParse(data);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
const { receiverId, body } = parsed.data;
|
||||||
|
if (receiverId === senderId) {
|
||||||
|
return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } });
|
||||||
|
if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 });
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: { senderId, receiverId, body },
|
||||||
|
select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ message }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const listQuery = z.object({
|
||||||
|
box: z.enum(["received", "sent"]).default("received").optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1).optional(),
|
||||||
|
pageSize: z.coerce.number().min(1).max(100).default(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const userId = getUserIdFromRequest(req);
|
||||||
|
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const parsed = listQuery.safeParse(Object.fromEntries(searchParams));
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
const { box = "received", page = 1, pageSize = 20 } = parsed.data;
|
||||||
|
const where =
|
||||||
|
box === "received"
|
||||||
|
? { receiverId: userId }
|
||||||
|
: { senderId: userId };
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
prisma.message.count({ where }),
|
||||||
|
prisma.message.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
body: true,
|
||||||
|
createdAt: true,
|
||||||
|
sender: { select: { userId: true, nickname: true } },
|
||||||
|
receiver: { select: { userId: true, nickname: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ total, page, pageSize, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
|
const requesterId = getUserIdFromRequest(req);
|
||||||
|
const post = await prisma.post.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { authorId: true },
|
||||||
|
});
|
||||||
|
const postAuthorId = post?.authorId ?? null;
|
||||||
|
|
||||||
// 최상위 댓글만 가져오기
|
// 최상위 댓글만 가져오기
|
||||||
const topComments = await prisma.comment.findMany({
|
const topComments = await prisma.comment.findMany({
|
||||||
@@ -29,23 +36,33 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 재귀적으로 댓글 구조 변환
|
// 재귀적으로 댓글 구조 변환
|
||||||
const transformComment = (comment: any) => ({
|
const transformComment = (comment: any): any => {
|
||||||
|
const commentAuthorId: string | null = comment.author?.userId ?? null;
|
||||||
|
const canViewSecret =
|
||||||
|
!comment.isSecret ||
|
||||||
|
(requesterId != null &&
|
||||||
|
(requesterId === commentAuthorId || requesterId === postAuthorId));
|
||||||
|
|
||||||
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
parentId: comment.parentId,
|
parentId: comment.parentId,
|
||||||
depth: comment.depth,
|
depth: comment.depth,
|
||||||
content: comment.isSecret ? "비밀댓글입니다." : comment.content,
|
content: canViewSecret ? comment.content : "비밀댓글입니다.",
|
||||||
isAnonymous: comment.isAnonymous,
|
isAnonymous: comment.isAnonymous,
|
||||||
isSecret: comment.isSecret,
|
isSecret: comment.isSecret,
|
||||||
author: comment.author ? {
|
author: comment.author
|
||||||
|
? {
|
||||||
userId: comment.author.userId,
|
userId: comment.author.userId,
|
||||||
nickname: comment.author.nickname,
|
nickname: comment.author.nickname,
|
||||||
profileImage: comment.author.profileImage,
|
profileImage: comment.author.profileImage,
|
||||||
} : null,
|
}
|
||||||
|
: null,
|
||||||
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
|
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
replies: comment.replies ? comment.replies.map(transformComment) : [],
|
replies: comment.replies ? comment.replies.map(transformComment) : [],
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const presented = topComments.map(transformComment);
|
const presented = topComments.map(transformComment);
|
||||||
return NextResponse.json({ comments: presented });
|
return NextResponse.json({ comments: presented });
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
|||||||
const post = await prisma.post.findUnique({
|
const post = await prisma.post.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
author: { select: { userId: true, nickname: true } },
|
||||||
board: { select: { id: true, name: true, slug: true } },
|
board: { select: { id: true, name: true, slug: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,9 +117,10 @@ export async function GET(req: Request) {
|
|||||||
title: true,
|
title: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
boardId: true,
|
boardId: true,
|
||||||
|
board: { select: { id: true, name: true, slug: true } },
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
status: true,
|
status: true,
|
||||||
author: { select: { nickname: true } },
|
author: { select: { userId: true, nickname: true } },
|
||||||
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
||||||
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SearchBar } from "@/app/components/SearchBar";
|
import { SearchBar } from "./SearchBar";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
import { GradeIcon } from "@/app/components/GradeIcon";
|
import { GradeIcon } from "@/app/components/GradeIcon";
|
||||||
@@ -577,14 +577,24 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar fullWidth />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div key={cat.id}>
|
<div key={cat.id}>
|
||||||
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link key={b.id} href={`/boards/${b.slug}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={`/boards/${b.slug}`}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`rounded px-2 py-1 text-sm transition-colors duration-150 ${
|
||||||
|
activeBoardId === b.slug
|
||||||
|
? "text-[var(--red-50,#F94B37)] font-semibold"
|
||||||
|
: "text-neutral-700 hover:text-[var(--red-50,#F94B37)]"
|
||||||
|
}`}
|
||||||
|
aria-current={activeBoardId === b.slug ? "page" : undefined}
|
||||||
|
>
|
||||||
{b.name}
|
{b.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function AutoLoginAdmin() {
|
|||||||
fetch("/api/auth/session", {
|
fetch("/api/auth/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ nickname: "admin", password: "1234" }),
|
body: JSON.stringify({ id: "admin", password: "1234" }),
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((loginData) => {
|
.then((loginData) => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { UserAvatar } from "./UserAvatar";
|
import { UserAvatar } from "./UserAvatar";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { UserNameMenu } from "./UserNameMenu";
|
||||||
|
|
||||||
type CommentAuthor = {
|
type CommentAuthor = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -27,78 +28,19 @@ type Props = {
|
|||||||
postId: string;
|
postId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CommentSection({ postId }: Props) {
|
type CommentItemProps = {
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
comment: Comment;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
depth?: number;
|
||||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
expandedReplies: Set<string>;
|
||||||
const [newContent, setNewContent] = useState("");
|
toggleReplies: (commentId: string) => void;
|
||||||
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
|
replyingTo: string | null;
|
||||||
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
|
setReplyingTo: (id: string | null) => void;
|
||||||
|
replyContents: Record<string, string>;
|
||||||
useEffect(() => {
|
setReplyContents: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
loadComments();
|
handleSubmitReply: (parentId: string | null) => void;
|
||||||
}, [postId]);
|
replySecretFlags: Record<string, boolean>;
|
||||||
|
setReplySecretFlags: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
async function loadComments() {
|
};
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/posts/${postId}/comments`);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
setComments(data.comments || []);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmitReply(parentId: string | null) {
|
|
||||||
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
|
|
||||||
if (!content.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/comments", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
postId,
|
|
||||||
parentId,
|
|
||||||
content,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json();
|
|
||||||
alert(error.error || "댓글 작성 실패");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
setReplyContents((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[parentId!];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setNewContent("");
|
|
||||||
}
|
|
||||||
setReplyingTo(null);
|
|
||||||
loadComments();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("댓글 작성 실패");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleReplies(commentId: string) {
|
|
||||||
const newExpanded = new Set(expandedReplies);
|
|
||||||
if (newExpanded.has(commentId)) {
|
|
||||||
newExpanded.delete(commentId);
|
|
||||||
} else {
|
|
||||||
newExpanded.add(commentId);
|
|
||||||
}
|
|
||||||
setExpandedReplies(newExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -115,10 +57,32 @@ export function CommentSection({ postId }: Props) {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentItem({ comment, depth = 0 }: { comment: Comment; depth?: number }) {
|
// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 memo 처리
|
||||||
|
const CommentItem = memo(function CommentItem({
|
||||||
|
comment,
|
||||||
|
depth = 0,
|
||||||
|
expandedReplies,
|
||||||
|
toggleReplies,
|
||||||
|
replyingTo,
|
||||||
|
setReplyingTo,
|
||||||
|
replyContents,
|
||||||
|
setReplyContents,
|
||||||
|
handleSubmitReply,
|
||||||
|
replySecretFlags,
|
||||||
|
setReplySecretFlags,
|
||||||
|
}: CommentItemProps) {
|
||||||
const canReply = depth < 2; // 최대 3단계까지만
|
const canReply = depth < 2; // 최대 3단계까지만
|
||||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||||
const isExpanded = expandedReplies.has(comment.id);
|
const isExpanded = expandedReplies.has(comment.id);
|
||||||
|
const isReplyingHere = replyingTo === comment.id;
|
||||||
|
const replyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
// 답글 폼이 열릴 때만 포커스를 보장
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReplyingHere) {
|
||||||
|
replyRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isReplyingHere]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
|
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
|
||||||
@@ -133,7 +97,13 @@ export function CommentSection({ postId }: Props) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-sm font-semibold text-neutral-900">
|
<span className="text-sm font-semibold text-neutral-900">
|
||||||
{comment.isAnonymous ? `익명${comment.anonId}` : comment.author?.nickname || "익명"}
|
{/* 닉네임 드롭다운 */}
|
||||||
|
{/* 익명 댓글이면 드롭다운 없이 표시 */}
|
||||||
|
{comment.isAnonymous ? (
|
||||||
|
`익명${comment.anonId}`
|
||||||
|
) : (
|
||||||
|
<UserNameMenu userId={comment.author?.userId} nickname={comment.author?.nickname} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
|
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
|
||||||
{comment.updatedAt !== comment.createdAt && (
|
{comment.updatedAt !== comment.createdAt && (
|
||||||
@@ -166,16 +136,28 @@ export function CommentSection({ postId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 답글 입력 폼 */}
|
{/* 답글 입력 폼 */}
|
||||||
{replyingTo === comment.id && (
|
{isReplyingHere && (
|
||||||
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={replyRef}
|
||||||
value={replyContents[comment.id] ?? ""}
|
value={replyContents[comment.id] ?? ""}
|
||||||
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
|
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
|
||||||
placeholder="답글을 입력하세요..."
|
placeholder="답글을 입력하세요..."
|
||||||
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 mt-2 justify-end">
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!replySecretFlags[comment.id]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setReplySecretFlags((prev) => ({ ...prev, [comment.id]: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
비밀 댓글
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
@@ -184,6 +166,11 @@ export function CommentSection({ postId }: Props) {
|
|||||||
delete next[comment.id];
|
delete next[comment.id];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setReplySecretFlags((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[comment.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
|
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
@@ -197,6 +184,7 @@ export function CommentSection({ postId }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,12 +193,105 @@ export function CommentSection({ postId }: Props) {
|
|||||||
{hasReplies && isExpanded && (
|
{hasReplies && isExpanded && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{comment.replies.map((reply) => (
|
{comment.replies.map((reply) => (
|
||||||
<CommentItem key={reply.id} comment={reply} depth={depth + 1} />
|
<CommentItem
|
||||||
|
key={reply.id}
|
||||||
|
comment={reply}
|
||||||
|
depth={depth + 1}
|
||||||
|
expandedReplies={expandedReplies}
|
||||||
|
toggleReplies={toggleReplies}
|
||||||
|
replyingTo={replyingTo}
|
||||||
|
setReplyingTo={setReplyingTo}
|
||||||
|
replyContents={replyContents}
|
||||||
|
setReplyContents={setReplyContents}
|
||||||
|
handleSubmitReply={handleSubmitReply}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CommentSection({ postId }: Props) {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
|
const [newContent, setNewContent] = useState("");
|
||||||
|
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
|
||||||
|
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
|
||||||
|
const [newIsSecret, setNewIsSecret] = useState(false);
|
||||||
|
const [replySecretFlags, setReplySecretFlags] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadComments();
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/posts/${postId}/comments`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(data.comments || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitReply(parentId: string | null) {
|
||||||
|
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
|
||||||
|
if (!content.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/comments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
postId,
|
||||||
|
parentId,
|
||||||
|
content,
|
||||||
|
isSecret: parentId ? !!replySecretFlags[parentId] : newIsSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
alert(error.error || "댓글 작성 실패");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
setReplyContents((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[parentId!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setReplySecretFlags((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[parentId!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewContent("");
|
||||||
|
setNewIsSecret(false);
|
||||||
|
}
|
||||||
|
setReplyingTo(null);
|
||||||
|
loadComments();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("댓글 작성 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReplies(commentId: string) {
|
||||||
|
const newExpanded = new Set(expandedReplies);
|
||||||
|
if (newExpanded.has(commentId)) {
|
||||||
|
newExpanded.delete(commentId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(commentId);
|
||||||
|
}
|
||||||
|
setExpandedReplies(newExpanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -237,7 +318,15 @@ export function CommentSection({ postId }: Props) {
|
|||||||
className="w-full p-3 border border-neutral-300 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
className="w-full p-3 border border-neutral-300 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newIsSecret}
|
||||||
|
onChange={(e) => setNewIsSecret(e.target.checked)}
|
||||||
|
/>
|
||||||
|
비밀 댓글
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmitReply(null)}
|
onClick={() => handleSubmitReply(null)}
|
||||||
disabled={!newContent.trim()}
|
disabled={!newContent.trim()}
|
||||||
@@ -254,7 +343,19 @@ export function CommentSection({ postId }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<CommentItem key={comment.id} comment={comment} />
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
expandedReplies={expandedReplies}
|
||||||
|
toggleReplies={toggleReplies}
|
||||||
|
replyingTo={replyingTo}
|
||||||
|
setReplyingTo={setReplyingTo}
|
||||||
|
replyContents={replyContents}
|
||||||
|
setReplyContents={setReplyContents}
|
||||||
|
handleSubmitReply={handleSubmitReply}
|
||||||
|
replySecretFlags={replySecretFlags}
|
||||||
|
setReplySecretFlags={setReplySecretFlags}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,14 +17,20 @@ export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
|||||||
const res = await fetch("/api/auth/session", {
|
const res = await fetch("/api/auth/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ nickname, password }),
|
body: JSON.stringify({ id: nickname, password }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||||
// 성공 시 다음 경로로 이동 (기본은 홈)
|
// 성공 시 다음 경로로 이동 (기본은 홈)
|
||||||
window.location.href = next || "/";
|
window.location.href = next || "/";
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || "로그인 실패");
|
const raw = err?.message;
|
||||||
|
const fallback = "아이디 또는 비밀번호가 일치하지 않습니다";
|
||||||
|
const msg =
|
||||||
|
typeof raw === "string" && raw !== "[object Object]" && raw.trim().length > 0
|
||||||
|
? raw
|
||||||
|
: fallback;
|
||||||
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -33,7 +39,7 @@ export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
placeholder="닉네임"
|
placeholder="아이디"
|
||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Item = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
|
board?: { id: string; name: string; slug: string } | null;
|
||||||
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
|
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
|
||||||
postTags?: { tag: { name: string; slug: string } }[];
|
postTags?: { tag: { name: string; slug: string } }[];
|
||||||
author?: { nickname: string } | null;
|
author?: { nickname: string } | null;
|
||||||
@@ -36,6 +37,8 @@ function stripHtml(html: string | null | undefined): string {
|
|||||||
return html.replace(/<[^>]*>/g, "").trim();
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { UserNameMenu } from "./UserNameMenu";
|
||||||
|
|
||||||
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular" | "views" | "likes" | "comments"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
|
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular" | "views" | "likes" | "comments"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -233,6 +236,16 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link href={`/posts/${p.id}`} className={`group block truncate text-neutral-900 ${variant === "board" ? "py-[36px]" : ""}`}>
|
<Link href={`/posts/${p.id}`} className={`group block truncate text-neutral-900 ${variant === "board" ? "py-[36px]" : ""}`}>
|
||||||
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]">공지</span>}
|
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]">공지</span>}
|
||||||
|
{/* 게시판 정보 배지: 보드 지정 리스트가 아닐 때만 표시 */}
|
||||||
|
{!boardId && p.board && (
|
||||||
|
<Link
|
||||||
|
href={`/boards/${p.board.slug || p.board.id}`}
|
||||||
|
className="mr-2 inline-flex items-center rounded-full border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-100 px-2 py-0.5 text-[11px]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{p.board.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`${
|
className={`${
|
||||||
variant === "board" && titleHoverOrange
|
variant === "board" && titleHoverOrange
|
||||||
@@ -265,7 +278,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-200 text-[11px] text-neutral-700">{initials(p.author?.nickname)}</span>
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-200 text-[11px] text-neutral-700">{initials(p.author?.nickname)}</span>
|
||||||
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
|
<span className="truncate max-w-[84px]">
|
||||||
|
<UserNameMenu userId={(p as any).author?.userId} nickname={p.author?.nickname} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
|
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
|
||||||
|
|||||||
171
src/app/components/ProfileEditModal.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { Modal } from "@/app/components/ui/Modal";
|
||||||
|
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialProfileImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileEditModal({ open, onClose, initialProfileImageUrl }: Props) {
|
||||||
|
const { show } = useToast();
|
||||||
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(initialProfileImageUrl || null);
|
||||||
|
const [currentPassword, setCurrentPassword] = React.useState("");
|
||||||
|
const [newPassword, setNewPassword] = React.useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
async function changePassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (saving) return;
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
show("모든 비밀번호 입력란을 채워주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8 || newPassword.length > 100) {
|
||||||
|
show("새 비밀번호는 8~100자여야 합니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
show("새 비밀번호와 확인이 일치하지 않습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const res = await fetch("/api/me/password", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || "비밀번호 변경에 실패했습니다");
|
||||||
|
}
|
||||||
|
show("비밀번호가 변경되었습니다");
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
} catch (e: any) {
|
||||||
|
show(e?.message || "비밀번호 변경에 실패했습니다");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose}>
|
||||||
|
<div className="w-[720px] max-w-[92vw]">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold">프로필 수정</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 py-1 text-sm text-neutral-500 hover:text-neutral-800"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-700 mb-3">프로필 이미지</h3>
|
||||||
|
<div className="rounded-md border border-neutral-200 p-3">
|
||||||
|
{/* 원형 미리보기 */}
|
||||||
|
<div className="flex items-center justify-center mb-3">
|
||||||
|
<div className="relative w-[112px] h-[112px]">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-[8px] flex items-center justify-center">
|
||||||
|
<UserAvatar
|
||||||
|
src={previewUrl || null}
|
||||||
|
alt="프로필 미리보기"
|
||||||
|
width={140}
|
||||||
|
height={140}
|
||||||
|
className="rounded-full w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 업로드/제거 컨트롤 (경로 텍스트 숨김) */}
|
||||||
|
<ProfileImageEditor
|
||||||
|
initialUrl={initialProfileImageUrl || null}
|
||||||
|
onUrlChange={(u) => setPreviewUrl(u || null)}
|
||||||
|
hideUrlText
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-700 mb-3">비밀번호 변경</h3>
|
||||||
|
<form onSubmit={changePassword} className="rounded-md border border-neutral-200 p-3 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-neutral-600">현재 비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="현재 비밀번호"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-neutral-600">새 비밀번호</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="새 비밀번호 (8~100자)"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium text-neutral-600">새 비밀번호 확인</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="새 비밀번호 확인"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-9 px-3 rounded-md border border-neutral-300 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "처리 중..." : "비밀번호 변경"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
33
src/app/components/ProfileEditTrigger.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ProfileEditModal } from "@/app/components/ProfileEditModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialProfileImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileEditTrigger({ initialProfileImageUrl }: Props) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="w-[20px] h-[20px] shrink-0"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label="프로필 수정"
|
||||||
|
title="프로필 수정"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="#707070"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ProfileEditModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
initialProfileImageUrl={initialProfileImageUrl || null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,9 +5,11 @@ import { useToast } from "@/app/components/ui/ToastProvider";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
initialUrl?: string | null;
|
initialUrl?: string | null;
|
||||||
|
onUrlChange?: (url: string | null) => void;
|
||||||
|
hideUrlText?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProfileImageEditor({ initialUrl }: Props) {
|
export function ProfileImageEditor({ initialUrl, onUrlChange, hideUrlText }: Props) {
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const [url, setUrl] = React.useState<string>(initialUrl || "");
|
const [url, setUrl] = React.useState<string>(initialUrl || "");
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
@@ -24,6 +26,7 @@ export function ProfileImageEditor({ initialUrl }: Props) {
|
|||||||
if (!res.ok) throw new Error(data?.error || "저장 실패");
|
if (!res.ok) throw new Error(data?.error || "저장 실패");
|
||||||
show("프로필 이미지가 저장되었습니다");
|
show("프로필 이미지가 저장되었습니다");
|
||||||
setUrl(newUrl || "");
|
setUrl(newUrl || "");
|
||||||
|
onUrlChange?.(newUrl);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
show(e.message || "저장 실패");
|
show(e.message || "저장 실패");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,7 +58,7 @@ export function ProfileImageEditor({ initialUrl }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{url ? <span className="text-xs text-neutral-500 break-all">{url}</span> : null}
|
{!hideUrlText && url ? <span className="text-xs text-neutral-500 break-all">{url}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar({ fullWidth = false, className = "" }: { fullWidth?: boolean; className?: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [term, setTerm] = useState("");
|
const [term, setTerm] = useState("");
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +14,7 @@ export function SearchBar() {
|
|||||||
}}
|
}}
|
||||||
role="search"
|
role="search"
|
||||||
aria-label="사이트 검색"
|
aria-label="사이트 검색"
|
||||||
className="relative w-full max-w-[384px] group"
|
className={`relative w-full group ${fullWidth ? "max-w-none" : "max-w-[384px]"} ${className}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
67
src/app/components/SendMessageForm.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
|
||||||
|
export function SendMessageForm({ receiverId, receiverNickname }: { receiverId: string; receiverNickname?: string | null }) {
|
||||||
|
const { show } = useToast();
|
||||||
|
const [body, setBody] = React.useState("");
|
||||||
|
const [sending, setSending] = React.useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!body.trim()) {
|
||||||
|
show("메시지를 입력하세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ receiverId, body }),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg =
|
||||||
|
j?.error?.message ||
|
||||||
|
(j?.error?.fieldErrors ? Object.values(j.error.fieldErrors as any)[0]?.[0] : null) ||
|
||||||
|
j?.error ||
|
||||||
|
"전송 실패";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
setBody("");
|
||||||
|
show("쪽지를 보냈습니다");
|
||||||
|
} catch (e: any) {
|
||||||
|
show(e?.message || "전송 실패");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-2">
|
||||||
|
<div className="text-sm text-neutral-700">
|
||||||
|
받는 사람: <span className="font-semibold">{receiverNickname || receiverId}</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="메시지를 입력하세요"
|
||||||
|
className="w-full min-h-[96px] rounded-md border border-neutral-300 p-3 text-sm"
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={sending}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending ? "전송 중..." : "쪽지 보내기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
94
src/app/components/UserNameMenu.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMessageModal } from "@/app/components/ui/MessageModalProvider";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userId?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
isAnonymous?: boolean;
|
||||||
|
className?: string;
|
||||||
|
underlineOnHover?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserNameMenu({
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
isAnonymous,
|
||||||
|
className,
|
||||||
|
underlineOnHover = true,
|
||||||
|
}: Props) {
|
||||||
|
const { openMessageModal } = useMessageModal();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (!ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onEsc);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onEsc);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const label = isAnonymous ? "익명" : (nickname ?? "익명");
|
||||||
|
const canInteract = !!userId && !isAnonymous;
|
||||||
|
|
||||||
|
if (!canInteract) {
|
||||||
|
return (
|
||||||
|
<span className={className}>{label}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={`relative inline-block ${className || ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`text-left ${underlineOnHover ? "hover:underline" : ""} text-inherit cursor-pointer`}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute z-50 mt-1 w-36 rounded-md border border-neutral-200 bg-white shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/users/${encodeURIComponent(userId!)}`}
|
||||||
|
className="block px-3 py-2 text-sm text-neutral-800 hover:bg-neutral-50"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
프로필 보기
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left block px-3 py-2 text-sm text-neutral-800 hover:bg-neutral-50 cursor-pointer"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
openMessageModal({ receiverId: userId!, receiverNickname: nickname ?? null });
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
쪽지쓰기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
79
src/app/components/ui/MessageModalProvider.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { SendMessageForm } from "@/app/components/SendMessageForm";
|
||||||
|
|
||||||
|
type MessageTarget = { receiverId: string; receiverNickname?: string | null } | null;
|
||||||
|
type Ctx = { openMessageModal: (target: { receiverId: string; receiverNickname?: string | null }) => void };
|
||||||
|
|
||||||
|
const MessageModalCtx = createContext<Ctx>({ openMessageModal: () => {} });
|
||||||
|
|
||||||
|
export function useMessageModal() {
|
||||||
|
return useContext(MessageModalCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageModalProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [target, setTarget] = useState<MessageTarget>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const openMessageModal = (t: { receiverId: string; receiverNickname?: string | null }) => {
|
||||||
|
setTarget({ receiverId: t.receiverId, receiverNickname: t.receiverNickname ?? null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setTarget(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageModalCtx.Provider value={{ openMessageModal }}>
|
||||||
|
{children}
|
||||||
|
{target && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[1000] flex items-center justify-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={close}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="relative z-[1001] w-[92%] max-w-[520px] rounded-lg bg-white shadow-xl border border-neutral-200"
|
||||||
|
>
|
||||||
|
<div className="px-5 py-3 border-b border-neutral-200 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900">쪽지 보내기</h3>
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-neutral-100 cursor-pointer"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} />
|
||||||
|
</div>
|
||||||
|
<div className="px-5 pb-4 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 cursor-pointer"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MessageModalCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import QueryProvider from "@/app/QueryProvider";
|
|||||||
import { AppHeader } from "@/app/components/AppHeader";
|
import { AppHeader } from "@/app/components/AppHeader";
|
||||||
import { AppFooter } from "@/app/components/AppFooter";
|
import { AppFooter } from "@/app/components/AppFooter";
|
||||||
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { MessageModalProvider } from "@/app/components/ui/MessageModalProvider";
|
||||||
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,6 +23,7 @@ export default function RootLayout({
|
|||||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<MessageModalProvider>
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||||
<div className="mx-auto w-full">
|
<div className="mx-auto w-full">
|
||||||
@@ -41,6 +43,7 @@ export default function RootLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</MessageModalProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -19,14 +19,19 @@ export default function LoginPage() {
|
|||||||
const res = await fetch("/api/auth/session", {
|
const res = await fetch("/api/auth/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ nickname, password }),
|
body: JSON.stringify({ id: nickname, password }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||||
show("로그인되었습니다");
|
show("로그인되었습니다");
|
||||||
location.href = next;
|
location.href = next;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
show(err.message || "로그인 실패");
|
const raw = err?.message;
|
||||||
|
const msg =
|
||||||
|
typeof raw === "string" && raw !== "[object Object]" && raw.trim().length > 0
|
||||||
|
? raw
|
||||||
|
: "아이디 또는 비밀번호가 일치하지 않습니다";
|
||||||
|
show(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -36,7 +41,7 @@ export default function LoginPage() {
|
|||||||
<h1>로그인</h1>
|
<h1>로그인</h1>
|
||||||
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
<input
|
<input
|
||||||
placeholder="닉네임"
|
placeholder="아이디"
|
||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }}
|
style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import { PostList } from "@/app/components/PostList";
|
import { PostList } from "@/app/components/PostList";
|
||||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||||
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
|
import { ProfileEditTrigger } from "@/app/components/ProfileEditTrigger";
|
||||||
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) {
|
import { SendMessageForm } from "@/app/components/SendMessageForm";
|
||||||
|
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string; to?: string }> }) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const activeTab = sp?.tab || "posts";
|
const activeTab = sp?.tab || "posts";
|
||||||
const page = parseInt(sp?.page || "1", 10);
|
const page = parseInt(sp?.page || "1", 10);
|
||||||
const sort = sp?.sort || "recent";
|
const sort = sp?.sort || "recent";
|
||||||
const q = sp?.q || "";
|
const q = sp?.q || "";
|
||||||
|
const toUserId = sp?.to || "";
|
||||||
|
|
||||||
// 현재 로그인한 사용자 정보 가져오기
|
// 현재 로그인한 사용자 정보 가져오기
|
||||||
let currentUser: {
|
let currentUser: {
|
||||||
@@ -123,11 +125,7 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
|||||||
{/* 닉네임 */}
|
{/* 닉네임 */}
|
||||||
<div className="flex items-center gap-[4px]">
|
<div className="flex items-center gap-[4px]">
|
||||||
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
||||||
<button className="w-[20px] h-[20px] shrink-0">
|
<ProfileEditTrigger initialProfileImageUrl={currentUser.profileImage || null} />
|
||||||
<svg width="20" height="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="#707070"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/* 관리자 설정 버튼 */}
|
{/* 관리자 설정 버튼 */}
|
||||||
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
||||||
@@ -141,8 +139,7 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 프로필 이미지 편집 */}
|
{/* 프로필 수정은 연필 아이콘(모달)에서 처리 */}
|
||||||
<ProfileImageEditor initialUrl={currentUser.profileImage || null} />
|
|
||||||
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
||||||
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
||||||
<div className="flex items-center gap-[4px] min-w-0">
|
<div className="flex items-center gap-[4px] min-w-0">
|
||||||
@@ -277,16 +274,71 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{activeTab === "messages-received" && (
|
{activeTab === "messages-received" && (async () => {
|
||||||
|
const pageSize = 20;
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: { receiverId: currentUser.userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: pageSize,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
select: { id: true, body: true, createdAt: true, sender: { select: { userId: true, nickname: true } } },
|
||||||
|
});
|
||||||
|
return (
|
||||||
<div className="bg-white rounded-[24px] p-4">
|
<div className="bg-white rounded-[24px] p-4">
|
||||||
<div className="text-center text-neutral-500 py-10">받은 쪽지함 기능은 준비 중입니다.</div>
|
<ul className="divide-y divide-neutral-200">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-neutral-800">
|
||||||
|
<Link href={`/users/${m.sender.userId}`} className="font-semibold hover:underline">{m.sender.nickname || "알 수 없음"}</Link>
|
||||||
|
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
|
||||||
{activeTab === "messages-sent" && (
|
|
||||||
<div className="bg-white rounded-[24px] p-4">
|
|
||||||
<div className="text-center text-neutral-500 py-10">보낸 쪽지함 기능은 준비 중입니다.</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{activeTab === "messages-sent" && (async () => {
|
||||||
|
const pageSize = 20;
|
||||||
|
const receiver = toUserId ? await prisma.user.findUnique({ where: { userId: toUserId }, select: { userId: true, nickname: true } }) : null;
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: { senderId: currentUser.userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: pageSize,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
select: { id: true, body: true, createdAt: true, receiver: { select: { userId: true, nickname: true } } },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-[24px] p-4 space-y-6">
|
||||||
|
{receiver ? (
|
||||||
|
<div className="border border-neutral-200 rounded-md p-3">
|
||||||
|
<SendMessageForm receiverId={receiver.userId} receiverNickname={receiver.nickname} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-neutral-500">닉네임 메뉴에서 ‘쪽지쓰기’를 클릭하면 작성 폼이 나타납니다.</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-2">보낸 쪽지</h3>
|
||||||
|
<ul className="divide-y divide-neutral-200">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-neutral-800">
|
||||||
|
<Link href={`/users/${m.receiver.userId}`} className="font-semibold hover:underline">{m.receiver.nickname || "알 수 없음"}</Link>
|
||||||
|
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="row-start-1 row-end-[-1] flex flex-col items-center justify-center gap-4 relative z-10">
|
<div className="row-start-1 row-end-[-1] flex flex-col items-center justify-center gap-4 relative z-10">
|
||||||
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인이 필요합니다</div>
|
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인</div>
|
||||||
<InlineLoginForm next="/" />
|
<InlineLoginForm next="/" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
function stripHtml(html: string | null | undefined): string {
|
function stripHtml(html: string | null | undefined): string {
|
||||||
@@ -170,7 +171,9 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
||||||
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
|
<span className="truncate max-w-[84px]">
|
||||||
|
<UserNameMenu userId={(p as any).author?.userId} nickname={p.author?.nickname} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||||
<span>조회 {p.stat?.views ?? 0}</span>
|
<span>조회 {p.stat?.views ?? 0}</span>
|
||||||
|
|||||||
25
src/app/posts/[id]/ViewTracker.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function ViewTracker({ postId }: { postId: string }) {
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
// 조회수 증가 (중복 방지: CSR 마운트 1회만)
|
||||||
|
try {
|
||||||
|
fetch(`/api/posts/${postId}/view`, {
|
||||||
|
method: "POST",
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,8 @@ import { HeroBanner } from "@/app/components/HeroBanner";
|
|||||||
import { RelatedPosts } from "./RelatedPosts";
|
import { RelatedPosts } from "./RelatedPosts";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { CommentSection } from "@/app/components/CommentSection";
|
import { CommentSection } from "@/app/components/CommentSection";
|
||||||
|
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||||
|
import { ViewTracker } from "./ViewTracker";
|
||||||
|
|
||||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||||
export default async function PostDetail({ params }: { params: any }) {
|
export default async function PostDetail({ params }: { params: any }) {
|
||||||
@@ -53,6 +55,7 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<ViewTracker postId={id} />
|
||||||
{/* 상단 배너 */}
|
{/* 상단 배너 */}
|
||||||
{showBanner && (
|
{showBanner && (
|
||||||
<section>
|
<section>
|
||||||
@@ -68,9 +71,18 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
<section className="rounded-xl overflow-hidden bg-white">
|
<section className="rounded-xl overflow-hidden bg-white">
|
||||||
<header className="px-4 py-3 border-b border-neutral-200">
|
<header className="px-4 py-3 border-b border-neutral-200">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900 break-words">{post.title}</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-neutral-900 break-words">{post.title}</h1>
|
||||||
{createdAt && (
|
<div className="mt-1 text-xs text-neutral-500 flex items-center gap-2">
|
||||||
<p className="mt-1 text-xs text-neutral-500">{createdAt.toLocaleString()}</p>
|
<span>
|
||||||
)}
|
<UserNameMenu
|
||||||
|
userId={post?.author?.userId ?? null}
|
||||||
|
nickname={post?.author?.nickname ?? null}
|
||||||
|
isAnonymous={post?.isAnonymous}
|
||||||
|
underlineOnHover={false}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{createdAt && <span aria-hidden>•</span>}
|
||||||
|
{createdAt && <span>{createdAt.toLocaleString()}</span>}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/app/components/ui/Button";
|
import { Button } from "@/app/components/ui/Button";
|
||||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { UploadButton } from "@/app/components/UploadButton";
|
||||||
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
@@ -11,32 +13,102 @@ export default function RegisterPage() {
|
|||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
agreeTerms: false,
|
agreeTerms: false,
|
||||||
|
profileImage: "" as string | undefined,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [errors, setErrors] = React.useState<Record<string, string[]>>({});
|
const [errors, setErrors] = React.useState<Record<string, string[]>>({});
|
||||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value, type, checked } = e.target;
|
const { name, value, type, checked } = e.target;
|
||||||
setForm((f) => ({ ...f, [name]: type === "checkbox" ? checked : value }));
|
setForm((f) => {
|
||||||
|
const next = { ...f, [name]: type === "checkbox" ? checked : value };
|
||||||
|
// 비밀번호/확인 입력 시 일치하면 confirmPassword 에러 제거
|
||||||
|
if (name === "password" || name === "confirmPassword") {
|
||||||
|
setErrors((errs) => {
|
||||||
|
const nextErrs = { ...errs };
|
||||||
|
if (next.password === next.confirmPassword) {
|
||||||
|
delete nextErrs.confirmPassword;
|
||||||
|
} else if (next.password && next.confirmPassword) {
|
||||||
|
nextErrs.confirmPassword = ["비밀번호가 일치하지 않습니다"];
|
||||||
|
}
|
||||||
|
return nextErrs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// 클라이언트 가드: 비밀번호 불일치 시 제출 차단
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setErrors((errs) => ({ ...errs, confirmPassword: ["비밀번호가 일치하지 않습니다"] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// 닉네임 중복 선확인
|
||||||
|
const checkRes = await fetch(`/api/auth/check-nickname?nickname=${encodeURIComponent(form.nickname)}`);
|
||||||
|
if (!checkRes.ok) {
|
||||||
|
const checkData = await checkRes.json().catch(() => ({}));
|
||||||
|
const fieldErrors = (checkData?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||||
|
if (Object.keys(fieldErrors).length) setErrors((prev) => ({ ...prev, ...fieldErrors }));
|
||||||
|
const msg = checkData?.error?.message || fieldErrors.nickname?.[0] || "아이디 확인 실패";
|
||||||
|
throw new Error(msg);
|
||||||
|
} else {
|
||||||
|
const check = await checkRes.json();
|
||||||
|
if (!check.available) {
|
||||||
|
setErrors((errs) => ({ ...errs, nickname: ["이미 사용 중인 아이디입니다"] }));
|
||||||
|
throw new Error("이미 사용 중인 아이디입니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 닉네임(name) 중복 선확인
|
||||||
|
const checkNameRes = await fetch(`/api/auth/check-name?name=${encodeURIComponent(form.name)}`);
|
||||||
|
if (!checkNameRes.ok) {
|
||||||
|
const checkNameData = await checkNameRes.json().catch(() => ({}));
|
||||||
|
const fieldErrors = (checkNameData?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||||
|
if (Object.keys(fieldErrors).length) setErrors((prev) => ({ ...prev, ...fieldErrors }));
|
||||||
|
const msg = checkNameData?.error?.message || fieldErrors.name?.[0] || "닉네임 확인 실패";
|
||||||
|
throw new Error(msg);
|
||||||
|
} else {
|
||||||
|
const check = await checkNameRes.json();
|
||||||
|
if (!check.available) {
|
||||||
|
setErrors((errs) => ({ ...errs, name: ["이미 사용 중인 닉네임입니다"] }));
|
||||||
|
throw new Error("이미 사용 중인 닉네임입니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify({
|
||||||
|
...form,
|
||||||
|
// 빈 문자열은 undefined로 치환하여 검증을 단순화
|
||||||
|
profileImage: form.profileImage ? form.profileImage : undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 409) {
|
||||||
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||||
setErrors(fieldErrors);
|
if (fieldErrors.nickname?.length || fieldErrors.name?.length) {
|
||||||
|
setErrors((errs) => ({ ...errs, ...fieldErrors }));
|
||||||
|
const msg = fieldErrors.nickname?.[0] || fieldErrors.name?.[0] || "회원가입 실패";
|
||||||
|
throw new Error(msg);
|
||||||
|
} else {
|
||||||
|
setErrors((errs) => ({ ...errs, nickname: ["이미 사용 중인 아이디입니다"] }));
|
||||||
|
throw new Error("이미 사용 중인 아이디입니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||||
|
if (Object.keys(fieldErrors).length) setErrors(fieldErrors);
|
||||||
const msg = data?.error?.message || Object.values(fieldErrors)[0]?.[0] || "회원가입 실패";
|
const msg = data?.error?.message || Object.values(fieldErrors)[0]?.[0] || "회원가입 실패";
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
setErrors({});
|
setErrors({});
|
||||||
show("회원가입 성공! 로그인해주세요");
|
show("회원가입이 완료되었습니다");
|
||||||
|
setTimeout(() => {
|
||||||
location.href = "/login";
|
location.href = "/login";
|
||||||
|
}, 1200);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
show(err.message || "회원가입 실패");
|
show(err.message || "회원가입 실패");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -47,19 +119,53 @@ export default function RegisterPage() {
|
|||||||
<div style={{ maxWidth: 480, margin: "40px auto" }}>
|
<div style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||||
<h1>회원가입</h1>
|
<h1>회원가입</h1>
|
||||||
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
<input name="nickname" placeholder="아이디" value={form.nickname} onChange={onChange} aria-invalid={!!errors.nickname} aria-describedby="err-nickname" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
{/* 프로필 이미지 업로드/미리보기 */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<UserAvatar src={form.profileImage || null} alt={form.name || "프로필"} width={64} height={64} className="rounded-full" />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<UploadButton
|
||||||
|
onUploaded={(url) => setForm((f) => ({ ...f, profileImage: url }))}
|
||||||
|
aspectRatio={1}
|
||||||
|
maxWidth={512}
|
||||||
|
maxHeight={512}
|
||||||
|
quality={0.9}
|
||||||
|
/>
|
||||||
|
{form.profileImage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, profileImage: "" }))}
|
||||||
|
style={{ fontSize: 12, color: "#666", textDecoration: "underline", alignSelf: "flex-start", background: "transparent", border: 0, padding: 0, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
이미지 제거
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<span style={{ fontSize: 12, color: "#666" }}>프로필 이미지는 선택 사항입니다</span>
|
||||||
|
{errors.profileImage?.length ? (
|
||||||
|
<span id="err-profileImage" style={{ color: "#c00", fontSize: 12 }}>{errors.profileImage[0]}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input name="nickname" placeholder="아이디 (2~20자)" value={form.nickname} onChange={onChange} aria-invalid={!!errors.nickname} aria-describedby="err-nickname" style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||||
|
</div>
|
||||||
{errors.nickname?.length ? (
|
{errors.nickname?.length ? (
|
||||||
<span id="err-nickname" style={{ color: "#c00", fontSize: 12 }}>{errors.nickname[0]}</span>
|
<span id="err-nickname" style={{ color: "#c00", fontSize: 12 }}>{errors.nickname[0]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<input name="name" placeholder="닉네임" value={form.name} onChange={onChange} aria-invalid={!!errors.name} aria-describedby="err-name" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input name="name" placeholder="닉네임 (1~50자)" value={form.name} onChange={onChange} aria-invalid={!!errors.name} aria-describedby="err-name" style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||||
|
</div>
|
||||||
{errors.name?.length ? (
|
{errors.name?.length ? (
|
||||||
<span id="err-name" style={{ color: "#c00", fontSize: 12 }}>{errors.name[0]}</span>
|
<span id="err-name" style={{ color: "#c00", fontSize: 12 }}>{errors.name[0]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<input name="password" placeholder="비밀번호" type="password" value={form.password} onChange={onChange} aria-invalid={!!errors.password} aria-describedby="err-password" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input name="password" placeholder="비밀번호 (8~100자)" type="password" value={form.password} onChange={onChange} aria-invalid={!!errors.password} aria-describedby="err-password" style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||||
|
</div>
|
||||||
{errors.password?.length ? (
|
{errors.password?.length ? (
|
||||||
<span id="err-password" style={{ color: "#c00", fontSize: 12 }}>{errors.password[0]}</span>
|
<span id="err-password" style={{ color: "#c00", fontSize: 12 }}>{errors.password[0]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<input name="confirmPassword" placeholder="비밀번호 확인" type="password" value={form.confirmPassword} onChange={onChange} aria-invalid={!!errors.confirmPassword} aria-describedby="err-confirmPassword" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input name="confirmPassword" placeholder="비밀번호 확인" type="password" value={form.confirmPassword} onChange={onChange} aria-invalid={!!errors.confirmPassword} aria-describedby="err-confirmPassword" style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||||
|
</div>
|
||||||
{errors.confirmPassword?.length ? (
|
{errors.confirmPassword?.length ? (
|
||||||
<span id="err-confirmPassword" style={{ color: "#c00", fontSize: 12 }}>{errors.confirmPassword[0]}</span>
|
<span id="err-confirmPassword" style={{ color: "#c00", fontSize: 12 }}>{errors.confirmPassword[0]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -82,7 +188,7 @@ export default function RegisterPage() {
|
|||||||
<span id="err-agree" style={{ color: "#c00", fontSize: 12 }}>{errors.agreeTerms[0]}</span>
|
<span id="err-agree" style={{ color: "#c00", fontSize: 12 }}>{errors.agreeTerms[0]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={loading}>{loading ? "가입 중..." : "가입하기"}</Button>
|
<Button type="submit" disabled={loading || form.password !== form.confirmPassword}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
79
src/app/users/[id]/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PostList } from "@/app/components/PostList";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
|
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||||
|
|
||||||
|
export default async function UserPublicProfile({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const p = await params;
|
||||||
|
const userId = p.id;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
nickname: true,
|
||||||
|
profileImage: true,
|
||||||
|
level: true,
|
||||||
|
grade: true,
|
||||||
|
points: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) return notFound();
|
||||||
|
|
||||||
|
// 메인배너 표시 설정을 재사용(일관 UI)
|
||||||
|
const SETTINGS_KEY = "mainpage_settings" as const;
|
||||||
|
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
|
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||||
|
const showBanner: boolean = parsed.showBanner ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{showBanner && (
|
||||||
|
<section>
|
||||||
|
<HeroBanner showPartnerCats={false} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="bg-white rounded-[16px] px-4 py-6 md:px-8 md:py-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-[72px] h-[72px]">
|
||||||
|
<UserAvatar
|
||||||
|
src={user.profileImage}
|
||||||
|
alt={user.nickname || "프로필"}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
className="rounded-full w-[72px] h-[72px] object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold text-neutral-900 truncate">{user.nickname}</h1>
|
||||||
|
<GradeIcon grade={user.grade} width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-600 mt-1 flex items-center gap-3">
|
||||||
|
<span>Lv. {user.level}</span>
|
||||||
|
<span aria-hidden>•</span>
|
||||||
|
<span>{getGradeName(user.grade)}</span>
|
||||||
|
<span aria-hidden>•</span>
|
||||||
|
<span>{user.points.toLocaleString()} P</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-xl overflow-hidden">
|
||||||
|
<header className="px-4 py-3 border-b border-neutral-200">
|
||||||
|
<h2 className="text-lg font-bold text-neutral-900">작성한 게시글</h2>
|
||||||
|
</header>
|
||||||
|
<div className="p-0">
|
||||||
|
<PostList authorId={user.userId} variant="board" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,41 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 개별 필드 스키마 재사용을 위한 별도 export
|
||||||
|
export const nicknameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2, "아이디는 2자 이상이어야 합니다")
|
||||||
|
.max(20, "아이디는 20자 이하이어야 합니다");
|
||||||
|
export const nameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "닉네임을 입력해주세요")
|
||||||
|
.max(50, "닉네임은 50자 이하이어야 합니다");
|
||||||
|
|
||||||
export const registerSchema = z
|
export const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
nickname: z.string().min(2).max(20),
|
nickname: nicknameSchema,
|
||||||
name: z.string().min(1).max(50),
|
name: nameSchema,
|
||||||
password: z.string().min(8).max(100),
|
password: z
|
||||||
confirmPassword: z.string().min(8).max(100),
|
.string()
|
||||||
|
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||||
|
.max(100, "비밀번호는 100자 이하이어야 합니다"),
|
||||||
|
confirmPassword: z
|
||||||
|
.string()
|
||||||
|
.min(8, "비밀번호 확인은 8자 이상이어야 합니다")
|
||||||
|
.max(100, "비밀번호 확인은 100자 이하이어야 합니다"),
|
||||||
agreeTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의 필요" }) }),
|
agreeTerms: z.literal(true, { errorMap: () => ({ message: "약관 동의 필요" }) }),
|
||||||
|
// 프로필 이미지는 선택 사항. 내부 업로드(/uploads/...) 또는 http(s) URL만 허용
|
||||||
|
profileImage: z
|
||||||
|
.string()
|
||||||
|
.max(1000)
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v === undefined ||
|
||||||
|
v.startsWith("/uploads/") ||
|
||||||
|
v.startsWith("http://") ||
|
||||||
|
v.startsWith("https://"),
|
||||||
|
{ message: "잘못된 이미지 주소" }
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.refine((d) => d.password === d.confirmPassword, {
|
.refine((d) => d.password === d.confirmPassword, {
|
||||||
message: "비밀번호가 일치하지 않습니다",
|
message: "비밀번호가 일치하지 않습니다",
|
||||||
@@ -16,8 +45,14 @@ export const registerSchema = z
|
|||||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
nickname: z.string().min(2).max(20),
|
id: z
|
||||||
password: z.string().min(8).max(100),
|
.string()
|
||||||
|
.min(2, "아이디는 2자 이상이어야 합니다")
|
||||||
|
.max(20, "아이디는 20자 이하이어야 합니다"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||||
|
.max(100, "비밀번호는 100자 이하이어야 합니다"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginInput = z.infer<typeof loginSchema>;
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
|||||||