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 parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ 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)) {
|
||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
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);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, name, password } = parsed.data;
|
||||
const exists = await prisma.user.findFirst({ where: { nickname } });
|
||||
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
|
||||
const { nickname, name, password, profileImage } = parsed.data as {
|
||||
nickname: string;
|
||||
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({
|
||||
data: {
|
||||
nickname,
|
||||
name,
|
||||
passwordHash: hashPassword(password),
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -29,11 +29,18 @@ export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ 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)) {
|
||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
// 사용자의 관리자 권한 여부 확인
|
||||
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 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 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({
|
||||
@@ -29,23 +36,33 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
||||
});
|
||||
|
||||
// 재귀적으로 댓글 구조 변환
|
||||
const transformComment = (comment: any) => ({
|
||||
id: comment.id,
|
||||
parentId: comment.parentId,
|
||||
depth: comment.depth,
|
||||
content: comment.isSecret ? "비밀댓글입니다." : comment.content,
|
||||
isAnonymous: comment.isAnonymous,
|
||||
isSecret: comment.isSecret,
|
||||
author: comment.author ? {
|
||||
userId: comment.author.userId,
|
||||
nickname: comment.author.nickname,
|
||||
profileImage: comment.author.profileImage,
|
||||
} : null,
|
||||
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
replies: comment.replies ? comment.replies.map(transformComment) : [],
|
||||
});
|
||||
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,
|
||||
parentId: comment.parentId,
|
||||
depth: comment.depth,
|
||||
content: canViewSecret ? comment.content : "비밀댓글입니다.",
|
||||
isAnonymous: comment.isAnonymous,
|
||||
isSecret: comment.isSecret,
|
||||
author: comment.author
|
||||
? {
|
||||
userId: comment.author.userId,
|
||||
nickname: comment.author.nickname,
|
||||
profileImage: comment.author.profileImage,
|
||||
}
|
||||
: null,
|
||||
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
replies: comment.replies ? comment.replies.map(transformComment) : [],
|
||||
};
|
||||
};
|
||||
|
||||
const presented = topComments.map(transformComment);
|
||||
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({
|
||||
where: { id },
|
||||
include: {
|
||||
author: { select: { userId: true, nickname: true } },
|
||||
board: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,9 +117,10 @@ export async function GET(req: Request) {
|
||||
title: true,
|
||||
createdAt: true,
|
||||
boardId: true,
|
||||
board: { select: { id: true, name: true, slug: true } },
|
||||
isPinned: true,
|
||||
status: true,
|
||||
author: { select: { nickname: true } },
|
||||
author: { select: { userId: true, nickname: true } },
|
||||
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
||||
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SearchBar } from "@/app/components/SearchBar";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import useSWR from "swr";
|
||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||
import { GradeIcon } from "@/app/components/GradeIcon";
|
||||
@@ -577,14 +577,24 @@ export function AppHeader() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchBar />
|
||||
<SearchBar fullWidth />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{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}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AutoLoginAdmin() {
|
||||
fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nickname: "admin", password: "1234" }),
|
||||
body: JSON.stringify({ id: "admin", password: "1234" }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((loginData) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import Link from "next/link";
|
||||
import { UserNameMenu } from "./UserNameMenu";
|
||||
|
||||
type CommentAuthor = {
|
||||
userId: string;
|
||||
@@ -27,6 +28,190 @@ type Props = {
|
||||
postId: string;
|
||||
};
|
||||
|
||||
type CommentItemProps = {
|
||||
comment: Comment;
|
||||
depth?: number;
|
||||
expandedReplies: Set<string>;
|
||||
toggleReplies: (commentId: string) => void;
|
||||
replyingTo: string | null;
|
||||
setReplyingTo: (id: string | null) => void;
|
||||
replyContents: Record<string, string>;
|
||||
setReplyContents: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
handleSubmitReply: (parentId: string | null) => void;
|
||||
replySecretFlags: Record<string, boolean>;
|
||||
setReplySecretFlags: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
};
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "방금 전";
|
||||
if (minutes < 60) return `${minutes}분 전`;
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 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 hasReplies = comment.replies && comment.replies.length > 0;
|
||||
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 (
|
||||
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
|
||||
<div className="flex gap-3 py-3">
|
||||
<UserAvatar
|
||||
src={comment.author?.profileImage}
|
||||
alt={comment.author?.nickname || "익명"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-neutral-900">
|
||||
{/* 닉네임 드롭다운 */}
|
||||
{/* 익명 댓글이면 드롭다운 없이 표시 */}
|
||||
{comment.isAnonymous ? (
|
||||
`익명${comment.anonId}`
|
||||
) : (
|
||||
<UserNameMenu userId={comment.author?.userId} nickname={comment.author?.nickname} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
|
||||
{comment.updatedAt !== comment.createdAt && (
|
||||
<span className="text-xs text-neutral-400">(수정됨)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{canReply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(comment.id);
|
||||
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
|
||||
}}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
답글
|
||||
</button>
|
||||
)}
|
||||
{hasReplies && (
|
||||
<button
|
||||
onClick={() => toggleReplies(comment.id)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}개`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 답글 입력 폼 */}
|
||||
{isReplyingHere && (
|
||||
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyContents[comment.id] ?? ""}
|
||||
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
|
||||
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"
|
||||
rows={3}
|
||||
/>
|
||||
<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
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
setReplyContents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[comment.id];
|
||||
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"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmitReply(comment.id)}
|
||||
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대댓글 목록 */}
|
||||
{hasReplies && isExpanded && (
|
||||
<div className="mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
export function CommentSection({ postId }: Props) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -34,6 +219,8 @@ export function CommentSection({ postId }: Props) {
|
||||
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();
|
||||
@@ -64,6 +251,7 @@ export function CommentSection({ postId }: Props) {
|
||||
postId,
|
||||
parentId,
|
||||
content,
|
||||
isSecret: parentId ? !!replySecretFlags[parentId] : newIsSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -79,8 +267,14 @@ export function CommentSection({ postId }: Props) {
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
setReplySecretFlags((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setNewContent("");
|
||||
setNewIsSecret(false);
|
||||
}
|
||||
setReplyingTo(null);
|
||||
loadComments();
|
||||
@@ -100,119 +294,6 @@ export function CommentSection({ postId }: Props) {
|
||||
setExpandedReplies(newExpanded);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "방금 전";
|
||||
if (minutes < 60) return `${minutes}분 전`;
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function CommentItem({ comment, depth = 0 }: { comment: Comment; depth?: number }) {
|
||||
const canReply = depth < 2; // 최대 3단계까지만
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
const isExpanded = expandedReplies.has(comment.id);
|
||||
|
||||
return (
|
||||
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
|
||||
<div className="flex gap-3 py-3">
|
||||
<UserAvatar
|
||||
src={comment.author?.profileImage}
|
||||
alt={comment.author?.nickname || "익명"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-neutral-900">
|
||||
{comment.isAnonymous ? `익명${comment.anonId}` : comment.author?.nickname || "익명"}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
|
||||
{comment.updatedAt !== comment.createdAt && (
|
||||
<span className="text-xs text-neutral-400">(수정됨)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{canReply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(comment.id);
|
||||
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
|
||||
}}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
답글
|
||||
</button>
|
||||
)}
|
||||
{hasReplies && (
|
||||
<button
|
||||
onClick={() => toggleReplies(comment.id)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}개`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 답글 입력 폼 */}
|
||||
{replyingTo === comment.id && (
|
||||
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<textarea
|
||||
value={replyContents[comment.id] ?? ""}
|
||||
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
|
||||
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"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
setReplyContents((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"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmitReply(comment.id)}
|
||||
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대댓글 목록 */}
|
||||
{hasReplies && isExpanded && (
|
||||
<div className="mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="rounded-xl overflow-hidden bg-white p-4">
|
||||
@@ -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"
|
||||
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
|
||||
onClick={() => handleSubmitReply(null)}
|
||||
disabled={!newContent.trim()}
|
||||
@@ -254,7 +343,19 @@ export function CommentSection({ postId }: Props) {
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -17,14 +17,20 @@ export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nickname, password }),
|
||||
body: JSON.stringify({ id: nickname, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||
// 성공 시 다음 경로로 이동 (기본은 홈)
|
||||
window.location.href = next || "/";
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -33,7 +39,7 @@ export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
||||
<input
|
||||
placeholder="닉네임"
|
||||
placeholder="아이디"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
|
||||
@@ -15,6 +15,7 @@ type Item = {
|
||||
createdAt: string;
|
||||
isPinned: boolean;
|
||||
status: string;
|
||||
board?: { id: string; name: string; slug: string } | null;
|
||||
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
|
||||
postTags?: { tag: { name: string; slug: string } }[];
|
||||
author?: { nickname: string } | null;
|
||||
@@ -36,6 +37,8 @@ function stripHtml(html: string | null | undefined): string {
|
||||
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 }) {
|
||||
const sp = useSearchParams();
|
||||
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">
|
||||
<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>}
|
||||
{/* 게시판 정보 배지: 보드 지정 리스트가 아닐 때만 표시 */}
|
||||
{!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
|
||||
className={`${
|
||||
variant === "board" && titleHoverOrange
|
||||
@@ -265,7 +278,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
</div>
|
||||
<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="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 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>
|
||||
|
||||
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 = {
|
||||
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 [url, setUrl] = React.useState<string>(initialUrl || "");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -24,6 +26,7 @@ export function ProfileImageEditor({ initialUrl }: Props) {
|
||||
if (!res.ok) throw new Error(data?.error || "저장 실패");
|
||||
show("프로필 이미지가 저장되었습니다");
|
||||
setUrl(newUrl || "");
|
||||
onUrlChange?.(newUrl);
|
||||
} catch (e: any) {
|
||||
show(e.message || "저장 실패");
|
||||
} finally {
|
||||
@@ -55,7 +58,7 @@ export function ProfileImageEditor({ initialUrl }: Props) {
|
||||
</button>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SearchBar() {
|
||||
export function SearchBar({ fullWidth = false, className = "" }: { fullWidth?: boolean; className?: string }) {
|
||||
const router = useRouter();
|
||||
const [term, setTerm] = useState("");
|
||||
return (
|
||||
@@ -14,7 +14,7 @@ export function SearchBar() {
|
||||
}}
|
||||
role="search"
|
||||
aria-label="사이트 검색"
|
||||
className="relative w-full max-w-[384px] group"
|
||||
className={`relative w-full group ${fullWidth ? "max-w-none" : "max-w-[384px]"} ${className}`}
|
||||
>
|
||||
<button
|
||||
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 { AppFooter } from "@/app/components/AppFooter";
|
||||
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
||||
import { MessageModalProvider } from "@/app/components/ui/MessageModalProvider";
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -22,25 +23,27 @@ export default function RootLayout({
|
||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||
<QueryProvider>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto w-full">
|
||||
<Suspense fallback={null}>
|
||||
<AppHeader />
|
||||
</Suspense>
|
||||
<MessageModalProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto w-full">
|
||||
<Suspense fallback={null}>
|
||||
<AppHeader />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex-1 bg-[#F2F2F2]">
|
||||
<div className="max-w-[1500px] mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<div className="">
|
||||
<div className="max-w-[1920px] mx-auto px-4">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex-1 bg-[#F2F2F2]">
|
||||
<div className="max-w-[1500px] mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<div className="">
|
||||
<div className="max-w-[1920px] mx-auto px-4">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MessageModalProvider>
|
||||
</ToastProvider>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
|
||||
@@ -19,14 +19,19 @@ export default function LoginPage() {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nickname, password }),
|
||||
body: JSON.stringify({ id: nickname, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||
show("로그인되었습니다");
|
||||
location.href = next;
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -36,7 +41,7 @@ export default function LoginPage() {
|
||||
<h1>로그인</h1>
|
||||
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<input
|
||||
placeholder="닉네임"
|
||||
placeholder="아이디"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
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 { PostList } from "@/app/components/PostList";
|
||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
|
||||
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) {
|
||||
import { ProfileEditTrigger } from "@/app/components/ProfileEditTrigger";
|
||||
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 activeTab = sp?.tab || "posts";
|
||||
const page = parseInt(sp?.page || "1", 10);
|
||||
const sort = sp?.sort || "recent";
|
||||
const q = sp?.q || "";
|
||||
const toUserId = sp?.to || "";
|
||||
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
let currentUser: {
|
||||
@@ -123,11 +125,7 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
{/* 닉네임 */}
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
||||
<button className="w-[20px] h-[20px] shrink-0">
|
||||
<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>
|
||||
<ProfileEditTrigger initialProfileImageUrl={currentUser.profileImage || null} />
|
||||
</div>
|
||||
{/* 관리자 설정 버튼 */}
|
||||
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
||||
@@ -141,8 +139,7 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 프로필 이미지 편집 */}
|
||||
<ProfileImageEditor initialUrl={currentUser.profileImage || null} />
|
||||
{/* 프로필 수정은 연필 아이콘(모달)에서 처리 */}
|
||||
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
||||
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
||||
<div className="flex items-center gap-[4px] min-w-0">
|
||||
@@ -277,16 +274,71 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{activeTab === "messages-received" && (
|
||||
<div className="bg-white rounded-[24px] p-4">
|
||||
<div className="text-center text-neutral-500 py-10">받은 쪽지함 기능은 준비 중입니다.</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "messages-sent" && (
|
||||
<div className="bg-white rounded-[24px] p-4">
|
||||
<div className="text-center text-neutral-500 py-10">보낸 쪽지함 기능은 준비 중입니다.</div>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<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 className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</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>
|
||||
</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="text-[18px] text-[#5c5c5c] font-[700]">로그인이 필요합니다</div>
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700]">로그인</div>
|
||||
<InlineLoginForm next="/" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
function stripHtml(html: string | null | undefined): string {
|
||||
@@ -170,7 +171,9 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
||||
</div>
|
||||
</div>
|
||||
<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 className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||
<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 prisma from "@/lib/prisma";
|
||||
import { CommentSection } from "@/app/components/CommentSection";
|
||||
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||
import { ViewTracker } from "./ViewTracker";
|
||||
|
||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||
export default async function PostDetail({ params }: { params: any }) {
|
||||
@@ -53,6 +55,7 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ViewTracker postId={id} />
|
||||
{/* 상단 배너 */}
|
||||
{showBanner && (
|
||||
<section>
|
||||
@@ -68,9 +71,18 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
<section className="rounded-xl overflow-hidden bg-white">
|
||||
<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>
|
||||
{createdAt && (
|
||||
<p className="mt-1 text-xs text-neutral-500">{createdAt.toLocaleString()}</p>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-neutral-500 flex items-center gap-2">
|
||||
<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>
|
||||
<div className="p-4 md:p-6">
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/app/components/ui/Button";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { show } = useToast();
|
||||
@@ -11,32 +13,102 @@ export default function RegisterPage() {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeTerms: false,
|
||||
profileImage: "" as string | undefined,
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [errors, setErrors] = React.useState<Record<string, string[]>>({});
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
// 클라이언트 가드: 비밀번호 불일치 시 제출 차단
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setErrors((errs) => ({ ...errs, confirmPassword: ["비밀번호가 일치하지 않습니다"] }));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
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", {
|
||||
method: "POST",
|
||||
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();
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||
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[]>;
|
||||
setErrors(fieldErrors);
|
||||
if (Object.keys(fieldErrors).length) setErrors(fieldErrors);
|
||||
const msg = data?.error?.message || Object.values(fieldErrors)[0]?.[0] || "회원가입 실패";
|
||||
throw new Error(msg);
|
||||
}
|
||||
setErrors({});
|
||||
show("회원가입 성공! 로그인해주세요");
|
||||
location.href = "/login";
|
||||
show("회원가입이 완료되었습니다");
|
||||
setTimeout(() => {
|
||||
location.href = "/login";
|
||||
}, 1200);
|
||||
} catch (err: any) {
|
||||
show(err.message || "회원가입 실패");
|
||||
} finally {
|
||||
@@ -47,19 +119,53 @@ export default function RegisterPage() {
|
||||
<div style={{ maxWidth: 480, margin: "40px auto" }}>
|
||||
<h1>회원가입</h1>
|
||||
<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 ? (
|
||||
<span id="err-nickname" style={{ color: "#c00", fontSize: 12 }}>{errors.nickname[0]}</span>
|
||||
) : 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 ? (
|
||||
<span id="err-name" style={{ color: "#c00", fontSize: 12 }}>{errors.name[0]}</span>
|
||||
) : 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 ? (
|
||||
<span id="err-password" style={{ color: "#c00", fontSize: 12 }}>{errors.password[0]}</span>
|
||||
) : 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 ? (
|
||||
<span id="err-confirmPassword" style={{ color: "#c00", fontSize: 12 }}>{errors.confirmPassword[0]}</span>
|
||||
) : null}
|
||||
@@ -82,7 +188,7 @@ export default function RegisterPage() {
|
||||
<span id="err-agree" style={{ color: "#c00", fontSize: 12 }}>{errors.agreeTerms[0]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||
<Button type="submit" disabled={loading || form.password !== form.confirmPassword}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||
</form>
|
||||
</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";
|
||||
|
||||
// 개별 필드 스키마 재사용을 위한 별도 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
|
||||
.object({
|
||||
nickname: z.string().min(2).max(20),
|
||||
name: z.string().min(1).max(50),
|
||||
password: z.string().min(8).max(100),
|
||||
confirmPassword: z.string().min(8).max(100),
|
||||
nickname: nicknameSchema,
|
||||
name: nameSchema,
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||
.max(100, "비밀번호는 100자 이하이어야 합니다"),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(8, "비밀번호 확인은 8자 이상이어야 합니다")
|
||||
.max(100, "비밀번호 확인은 100자 이하이어야 합니다"),
|
||||
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, {
|
||||
message: "비밀번호가 일치하지 않습니다",
|
||||
@@ -16,8 +45,14 @@ export const registerSchema = z
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
|
||||
export const loginSchema = z.object({
|
||||
nickname: z.string().min(2).max(20),
|
||||
password: z.string().min(8).max(100),
|
||||
id: z
|
||||
.string()
|
||||
.min(2, "아이디는 2자 이상이어야 합니다")
|
||||
.max(20, "아이디는 20자 이하이어야 합니다"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||
.max(100, "비밀번호는 100자 이하이어야 합니다"),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
|
||||