fix
Some checks failed
deploy-on-main / deploy (push) Failing after 22s

This commit is contained in:
koreacomp5
2025-11-10 00:04:17 +09:00
parent a007ac11ce
commit 4337a8f69a
39 changed files with 1345 additions and 222 deletions

View 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 });
}

View 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 });
}

View File

@@ -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 } });
}

View File

@@ -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 },
});

View File

@@ -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;

View 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 });
}
}

View 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 });
}

View File

@@ -1,12 +1,19 @@
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({
where: {
where: {
postId: id,
parentId: null, // 최상위 댓글만
},
@@ -27,26 +34,36 @@ 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 });
}

View File

@@ -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 } },
},
});

View File

@@ -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 } } } },
},

View File

@@ -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>
))}

View File

@@ -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) => {

View File

@@ -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>
)}

View File

@@ -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"

View File

@@ -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>

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>

View 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;
}

View File

@@ -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

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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>;